Data files in BBC BASIC II: channels, raw bytes, and the backwards PRINT#
Scope. This article is about data files in BBC BASIC II — the channel-based file I/O reached through
OPENIN/OPENOUT/OPENUP,CLOSE,BGET#/BPUT#,PRINT#/INPUT#, andPTR#/EXT#/EOF#. It is not about loading and saving whole programs:LOAD,SAVEandCHAINare a separate mechanism — a singleOSFILEcall driven by an 18-byte control block — documented in theload_program(&BE62) banner, and out of scope here. Every address and value below is specific to BASIC II and was read from the disassembly (which reassembles byte-identically to the ROM).
The headline, for the impatient:
PRINT#does not write text.PRINT#channel, 42does not write the characters42. It writes a 1-byte type tag and then the number's raw bytes in reverse — five bytes40 00 00 00 2A. Strings go out backwards too. The format is private to BASIC; aPRINT#file is meant to be read back only byINPUT#.
1. The channel model
BBC BASIC owns no file logic of its own — every data-file word is a thin wrapper over a MOS (operating-system) call. A file is identified by a one-byte handle returned when it is opened. The whole vocabulary maps onto five MOS calls (see eval_channel (&BFB5), the shared #handle parser):
| BASIC | MOS call | A | Notes |
|---|---|---|---|
OPENIN f$ |
OSFIND |
&40 |
open existing for input |
OPENOUT f$ |
OSFIND |
&80 |
create for output |
OPENUP f$ |
OSFIND |
&C0 |
open existing for update |
CLOSE #h |
OSFIND |
&00 |
CLOSE#0 closes every open file |
BPUT# h, b |
OSBPUT |
— | write one byte (A), handle in Y |
=BGET# h |
OSBGET |
— | read one byte, handle in Y |
PTR# h = |
OSARGS |
&01 |
write the sequential pointer |
=PTR# h |
OSARGS |
&00 |
read the sequential pointer |
=EXT# h |
OSARGS |
&02 |
read the extent (length) |
=EOF# h |
OSBYTE |
&7F |
test for end of file |
The handle is passed in Y (except OSFILE/OSARGS, which take a control block / a zero-page value address). OSFIND returns the new handle in A, or 0 if the open failed — which is why the idiom is chan=OPENIN("x"):IF chan=0 THEN .... Two things follow from this being pure MOS plumbing: the meaning of a handle, the maximum number of open files, and the on-media layout all belong to the current filing system, not to BASIC; and PTR#/EXT# work in whatever units (usually bytes) that filing system uses.
The # itself is mandatory and is parsed by eval_channel (&BFB5) (raising Missing # if absent); the handle is evaluated as an integer into IWA (&2A).
2. The lowest level: BGET# and BPUT#
These are the primitives — one byte each, no interpretation:
BPUT# h, v(stmt_bput(&BF58)) evaluatesv, coerces it to an integer, and callsOSBPUTwith the low byte inAand the handle inY.=BGET# h(fn_bget(&BF6F)) callsOSBGET(handle inY) and returns the byte as an integer.
Everything in §3 is built out of these.
3. PRINT# / INPUT#: a type-tagged, byte-reversed record format
This is the part that surprises people, and the reason data files written on a BBC are awkward to read on anything else.
3.1 What PRINT# actually emits
print_file (&8D2B) ignores all of screen PRINT's formatting — there is no TAB, no ~, no ;/' handling. It simply walks a comma-separated value list (print_file_loop (&8D30), which loops only while the next character is ,), and for each value writes a type tag byte followed by the value's bytes:
| First byte (tag) | Type | Then | Order |
|---|---|---|---|
&40 |
integer | 4 bytes from IWA | MSB first (print_file_int_loop (&8D4D), LDX #3 … DEX) |
&FF (negative) |
real | 5 packed FP bytes | reversed — mantissa LSB first, exponent last (print_file_real (&8D57), LDX #4 … DEX) |
&00 |
string | 1 length byte, then the characters | reversed (print_file_str (&8D64), LDX len … DEX) |
The tag is BASIC's own internal type code for the value (zp_var_type, &27): 0 string, &40 integer, &FF real. The disassembly's own inline comments name the quirk outright — &8D52 (&8D52) "next byte (MSB first)" and &8D72 (&8D72) "next character (written in reverse)".
3.2 Worked examples
PRINT#h, &12345678 -> 40 12 34 56 78
tag ── value, high byte first ──
(IWA holds 78 56 34 12 little-endian;
written +3,+2,+1,+0)
PRINT#h, "AB" -> 00 02 42 41
tag len 'B' 'A' (characters reversed)
PRINT#h, 42 -> 40 00 00 00 2A (an integer, NOT "42")
PRINT#h, 1.0 -> FF 00 00 00 00 81
tag ── packed real, reversed ──
(packed +0..+4 = 81 00 00 00 00:
exp &81, significand 0 — exponent
lands LAST)
PRINT#h, -1.0 -> FF 00 00 00 80 81
(sign bit is in the 4th data byte)
3.3 Why "reversed"
It is not a deliberate wire format so much as a consequence of the copy loops: each value is emitted by a single down-counting loop (LDX #count … DEX), so the highest-indexed byte goes out first. The numbers therefore land big-endian (the reverse of the 6502's native little-endian), and a string lands last-character-first.
The point is that INPUT# undoes it by counting down too, so the asymmetry cancels:
inputf_skip_hash(&B9CF) reads the tag byte (&B9F6(&B9F6)) and checks it against the target variable's type (raising Type mismatch otherwise).- An integer is read with
LDX #3 … STA zp_iwa,x … DEX(inputf_int_loop(&BA21)) — the first byte off the file lands iniwa+3, restoring little-endian. - A real likewise into
fp_temp1+4…+0then unpacked (inputf_real(&BA2B)). - A string reads the length, then
STA strbuf_base,x … DEX(inputf_read_loop(&BA0A)) — the inline comment reads "into the buffer (reversed)" — so the original character order is recovered.
So PRINT# and INPUT# are exact mirror images, and the on-disc bytes are an implementation detail neither side ever has to reason about. The practical consequences for anyone outside BASIC:
- A file written by
PRINT#is only sanely read byINPUT#. To consume one elsewhere you must parse the tag bytes and byte-reverse each field (and reverse string characters). PRINT#andINPUT#must agree on the value types in order —INPUT#validates the tag and errors on a mismatch; it does not coerce.- Mixing
BPUT#/BGET#(raw bytes) withPRINT#/INPUT#(tagged records) on the same file means tracking the tag/length framing yourself.
3.4 Reading a real off the wire
A real is the only multi-field value, so it is worth spelling out — and its byte order surprises people twice over. In memory the packed five bytes (fwa_pack_temp1 (&A385) → fwa_pack_var (&A38D), buffer at fp_temp1 (&046C)) are exponent-first:
fp_temp1 |
byte |
|---|---|
+0 |
exponent (excess-128; 0 ⇒ the value is zero) |
+1 |
sign (bit 7) + mantissa MSB (bits 6–0); the implied leading 1 is dropped |
+2 |
mantissa byte 2 |
+3 |
mantissa byte 3 |
+4 |
mantissa byte 4 (LSB) |
print_file_real writes that buffer from +4 down to +0, so on the wire (after the &FF tag) the order is mantissa LSB first, exponent last. Reading the five data bytes back as b0 b1 b2 b3 b4:
b0 = mantissa LSB sign = b3 >> 7 # 1 = negative
b1 = mantissa byte 3 exp = b4 # 0 ⇒ value is 0
b2 = mantissa byte 2 mantissa = ((b3 & 0x7F) | 0x80) << 24
b3 = sign + mantissa MSB | b2 << 16 | b1 << 8 | b0
b4 = exponent value = (-1)**sign * mantissa * 2**(exp - 160)
The −160 folds together the −128 exponent bias and the 2⁻³² that turns the 32-bit integer significand back into the [0.5, 1) fraction — the same formula derived in the 5-byte float article. Worked: FF 00 00 00 00 81 → mantissa = 0x80000000, exp = 0x81 → 2³¹ × 2^(129−160) = 1.0; and e written as PRINT#h, 2.718281828 lands as FF 58 54 F8 2D 82 → 0xADF85458 × 2^(130−160) = 2.71828182…. INPUT# does exactly this in reverse — inputf_real (&BA2B) reads the five bytes into fp_temp1+4…+0 and calls fwa_unpack_temp1 (&A3B2).
4. Position and extent: PTR#, EXT#, EOF#
The sequential pointer and the file length are 32-bit values handled by OSARGS, which transfers a 4-byte quantity through a zero-page address in X — BASIC uses IWA (&2A):
PTR# h = n(stmt_ptr(&BF30)) —OSARGSA=1,X=&2A,Y=handle: set the sequential pointer.=PTR# h(fn_ptr(&BF47)) —OSARGSA=0: read it.=EXT# h(fn_ext(&BF46)) —OSARGSA=2: read the extent (length).fn_extandfn_ptrshare code, selecting theOSARGSsub-function from the carry flag.=EOF# h(fn_eof(&ACB8)) — notOSARGS: it callsOSBYTE &7F, which returns end-of-file status inX(0= at EOF), and BASIC turns that intoTRUE/FALSE.
Setting PTR# allows random access on an OPENUP channel; BGET#/BPUT# then operate at that position and advance it.
5. Routine reference
| Concern | Label | Address |
|---|---|---|
#channel parser (shared) |
eval_channel (&BFB5) |
&BFB5 |
OPENIN/OPENOUT/OPENUP (shared open) |
openup_action (&BF82) |
&BF82 |
CLOSE |
stmt_close (&BF99) |
&BF99 |
BPUT# |
stmt_bput (&BF58) |
&BF58 |
=BGET# |
fn_bget (&BF6F) |
&BF6F |
PRINT# writer |
print_file (&8D2B) |
&8D2B |
| — integer / real / string payload | print_file_int_loop (&8D4D) / print_file_real (&8D57) / print_file_str (&8D64) |
&8D4D / &8D57 / &8D64 |
INPUT# reader |
inputf_skip_hash (&B9CF) |
&B9CF |
| — integer / real / string payload | inputf_int_loop (&BA21) / inputf_real (&BA2B) / inputf_read_loop (&BA0A) |
&BA21 / &BA2B / &BA0A |
PTR#= / =PTR# / =EXT# |
stmt_ptr (&BF30) / fn_ptr (&BF47) / fn_ext (&BF46) |
&BF30 / &BF47 / &BF46 |
=EOF# |
fn_eof (&ACB8) |
&ACB8 |
See also: whole-program loading (
LOAD/SAVE/CHAIN) usesOSFILEand the 18-byte control block, not channels — see theload_program(&BE62) banner. The control-flow and value stacks that hold evaluated values duringPRINT#/INPUT#are covered in Control flow on five stacks.
