Data files: channels, raw bytes, and the backwards PRINT# (BASIC II)

← Acorn BBC BASIC

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#, and PTR#/EXT#/EOF#. It is not about loading and saving whole programs: LOAD, SAVE and CHAIN are a separate mechanism — a single OSFILE call driven by an 18-byte control block — documented in the load_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, 42 does not write the characters 4 2. It writes a 1-byte type tag and then the number's raw bytes in reverse — five bytes 40 00 00 00 2A. Strings go out backwards too. The format is private to BASIC; a PRINT# file is meant to be read back only by INPUT#.


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:

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:

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:

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 81mantissa = 0x80000000, exp = 0x812³¹ × 2^(129−160) = 1.0; and e written as PRINT#h, 2.718281828 lands as FF 58 54 F8 2D 820xADF85458 × 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):

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) uses OSFILE and the 18-byte control block, not channels — see the load_program (&BE62) banner. The control-flow and value stacks that hold evaluated values during PRINT#/INPUT# are covered in Control flow on five stacks.