Viewdata encoding¶
Reference: what the BBC end actually does on the wire, and how it was measured. Every claim is marked as measured, corroborated against the emulator, or inferred; that separation is the document’s contract, so a reader knows which line an eye on a real Beeb could still move.
Measured against Pace Commstar 1.40 in Prestel mode, running on an emulated BBC
Model B under Beebium, with bytes delivered to the ACIA through rpc-serial and
the resulting cells read back from the SAA5050 after it had resolved them.
The spike script is at docs/spikes/spike_control_codes.py. It needs a local
Beebium checkout, so it is not part of the test suite; the conclusions below are
what the suite pins instead.
The wire has two separate namespaces¶
This is the thing to understand before writing any serialiser. A byte in the C0 range means one of two entirely different things depending on how it arrives:
On the wire |
Meaning |
|---|---|
A bare C0 byte |
Screen and cursor control — clear, home, carriage return, line feed |
|
A teletext spacing attribute — colour, graphics, height, flash |
So 0x0C on its own clears the screen, whereas ESC 0x4C selects normal height.
0x1E on its own homes the cursor, whereas ESC 0x5E holds graphics. Conflating
the two produces a display that is wrong in ways that look like transport
corruption.
Attributes must be escape-encoded¶
ESC 0x41 followed by RED was read back as:
[ 0, 0] 0x01 fg=red control=True <- the attribute, occupying a cell
[ 0, 1] 0x52 fg=red control=False <- 'R'
The attribute lands in a character cell of its own and takes effect from the following cell. Both facts matter to layout: a colour change costs one of the forty columns.
Sending the SAA5050’s own codes directly does not work. 0x81 followed by
RED produced white text with no control cell at all — the byte vanished.
Prestel mode runs the line at 7E1, which has no eighth bit to carry, so this is
the expected outcome and not a Commstar quirk. Sextile therefore has one
encoding, not a choice of two.
Graphics select the same way¶
ESC 0x57 (graphics white) then ESC 0x5A (separated graphics) put following
cells into charset=separated; ESC 0x59 returned them to contiguous. The
separated/contiguous attribute is set-at — the control cell itself already
reports the new character set — whereas colour is set-after.
Which of the two comes first is not measured. The spike drove colour and
then separated; the compositor emits separated and then colour, because the
colour attribute is what enters graphics and it should enter the set already
chosen. Beebium’s Saa5050 reaches separated graphics either way — 0x1A
remembers the graphics charset and switches the current one if it is already
graphics, and 0x11–0x17 enter graphics in whichever charset is remembered
— so this is corroborated rather than verified. Every rule on every frame now
goes out in that order, and it is the one thing here worth an eye on a real
Beeb.
A background is set at its cell, not after it¶
Read from Beebium’s Saa5050, not measured: process_control_code runs before
the cell’s fg and bg are captured, so the cell carrying NEW_BACKGROUND
(0x1D) or BLACK_BACKGROUND (0x1C) is already the new background. A
foreground change on the same cell is invisible — the cell is blank — which is
why colour reads as set-after and background as set-at.
It decides where a coloured box appears to begin and end:
column 17 18 19 ... 38 39
▁▁ ██ ██ ██ ▁▁
choose new background black background
blue -- the box's first cell
so a box’s own first cell is spent on the attribute that colours it, and one
black cell before that is unavoidable: a colour attribute cannot colour itself.
Composition.panel does this arithmetic.
A field bar therefore begins two cells before anything can be typed into it,
and no arrangement avoids it. NEW_BACKGROUND takes the current foreground as
the background, so the order is forced — choose blue, make it the background,
choose white back — and the last two of those three cells are already inside the
new background. Choosing white first would give a white background; a wider gap
between the label and the field makes it worse rather than better, since the
background runs on until something stops it and any padding after the attributes
is coloured too.
Two cells of a bar that nothing can be typed into is what a coloured field costs
on this hardware. Both TypeAhead and FieldSet incur it.
Verified screen control¶
Only these bare C0 codes have been measured, and they are all the frame serialiser needs:
Byte |
Effect |
|---|---|
0x0C |
Clear screen |
0x1E |
Cursor home |
0x0D |
Carriage return |
0x0A |
Line feed |
Cursor left/right/up/down (0x08–0x0B) work in both directions; see “The cursor moves on command” below for what they do as output, and “The cursor keys reach us” for what they mean as input.
The keyboard transposition¶
Commstar in Prestel mode transmits, per docs/serial-ip232.md in the Beebium
tree and confirmed by its own test suite:
Keypress |
Transmits |
Displays as |
|---|---|---|
|
0x23 |
|
|
0x5F |
|
|
0x0D |
carriage return |
The viewdata # command key therefore arrives as 0x5F, not 0x23. The
Prestel command parser must accept it.
The editing keys, measured by docs/spikes/spike_editing_keys.py:
Keypress |
Transmits |
|---|---|
|
0x7F, ASCII delete — distinct from RETURN, so a digit can be rubbed out without sending the request |
|
0x09, the same byte as cursor right |
|
0x19, not the ASCII backspace one might expect |
|
nothing; consumed locally |
Attributes reset at the start of every row¶
Not measured but read directly from the emulation, which is a better oracle:
Saa5050::start_of_line() in src/core/include/beebium/Saa5050.hpp sets
State |
Value at the start of each row |
|---|---|
Foreground |
white (7) |
Background |
black (0) |
Character set |
alpha |
Graphics set |
contiguous |
Conceal |
off |
Hold graphics |
off |
So a row never inherits anything from the row above, white text needs no
attribute at all, and Canvas writes each row independently.
And confirmed by eye, which is worth saying because a partial repaint stakes a good deal on it: a form redraws one row in the middle of a frame and sends its attributes with it, so a background that outlived its row would spread down the screen behind everything beneath. Watched over a good many keystrokes on the position form, which moves a blue background between two rows on every TAB, and nothing has ever spread. By eye rather than by read-back because the emulator’s cell API exposes no colour at all.
Double height takes the row below, and needs the text on it too¶
Read from the emulation and then confirmed on screen: the title frame draws its service name this way, and it renders correctly under Commstar. Worth recording as more than an inference, because it is not what a reasonable guess would say — a guess would have left the row beneath blank.
0x0D selects double height and 0x0C returns to normal, both spacing
attributes occupying a cell. In Saa5050.hpp:
cell.double_height_top = (m_raster_shift == 1 && m_raster_offset == 0);
cell.double_height_bottom = (m_raster_shift == 1 && m_raster_offset != 0);
m_raster_shift is set by 0x0D and, like every other attribute, resets at the
start of each row. m_raster_offset is the row-level state that steps 0 → 20 →
0 as character rows complete while any double height is in force. So:
the row carrying the attribute is drawn as the top halves of its characters;
the row below is drawn as the bottom halves — but only if it carries the attribute as well, since
double_height_bottomrequires the shift on that row too;so both rows hold the same text, which is exactly the old BBC BASIC idiom of printing a double-height line twice, both prefixed with
CHR$(141).
Nothing else can go on the lower row: whatever were there would be drawn as the
bottom of something. drawing.centred_double writes both rows and refuses the
last row of the frame, there being no row beneath it.
Confirmed by eye rather than by a spike script, so what is pinned is the bytes
Sextile emits — ESC M then the colour, twice, with the same text — and not the
pixels. That is the right division: the emulation and the hardware agree about
those pixels, and the suite cannot see a screen.
The character set is corroborated¶
docs/discussion/teletext-repertoire-choice.md in the Beebium tree tabulates
the eleven positions where the SAA5050’s UK repertoire departs from ASCII, and
teletext_alpha_codepoint in src/core/src/TeletextText.cpp implements them.
Both agree with sextile.viewdata.charset exactly, including 0x60 as U+2015
HORIZONTAL BAR — the one glyph the spike could not confirm.
0x60 is the long dash, and the underscore is not. The font in
TeletextFont.hpp settles a thing that is easy to get one position wrong: 0x60
draws a bar the full width of the cell, 0x2D draws a three-pixel hyphen, and
0x5F — where ASCII keeps its low line — draws the hash, the viewdata command
key. So a range wanting a long dash is written with U+2015 and not with _,
whatever a teletext editor’s keyboard suggests; and a literal _ in source is
transliterated to a hyphen and comes out short.
0x5F .#.#.. 0x60 ...... 0x2D ......
.#.#.. ...... ......
#####. ...... ......
.#.#.. #####. .###..
#####. ...... ......
hash long dash hyphen
0x7F is a twelfth departure: the font at TeletextFont.hpp has a glyph there
labelled “Block”, which Sextile maps to U+25AE. Beebium’s text conversion
deliberately returns nothing for it, since a block does not copy usefully as
text — a rendering decision, not a claim that the cell is blank.
Page numbers have no practical length limit¶
Commstar collects a *nnn request and transmits it with the terminating
RETURN. Measured by docs/spikes/spike_page_number_buffer.py, it truncates
nothing:
Typed |
Reached the wire |
|---|---|
|
|
|
|
|
|
|
|
The nine-digit Prestel maximum was a property of Prestel’s database, not of the terminal. The real costs of a long page number are typing time — about 130ms per character at 75 baud — and the header space needed to display it.
The trailing _ is 0x5F, the viewdata #, confirming that a page request
terminates with 0x5F and not 0x23.
The cursor keys reach us¶
Commstar passes the BBC’s cursor keys straight through in Prestel chat mode,
measured by docs/spikes/spike_cursor_keys.py:
Key |
Transmits |
As viewdata |
|---|---|---|
LEFT |
0x08 |
cursor left |
RIGHT |
0x09 |
cursor right |
DOWN |
0x0A |
cursor down |
UP |
0x0B |
cursor up |
COPY |
nothing |
consumed locally |
They are the MOS codes 0x88-0x8B with the eighth bit taken by the 7E1 line, which lands them exactly on the viewdata cursor-control codes. So a reader has real arrow keys, and Sextile reads them as the same four movements as WASD.
One consequence worth knowing: 0x0A is both the cursor-down key and the second half of a terminal’s CR LF. The parser tells them apart by position – a line feed directly after a carriage return is the rest of that terminator, and on its own it is the key.
Trailing blanks need not be sent¶
A frame clears the screen before drawing, so a space at the end of a row
overwrites nothing: it exists only to walk the cursor forward. CR LF does that
in two bytes instead of up to forty, and after the last row with anything on it
nothing need be sent at all.
Measured by docs/spikes/spike_trimmed_frames.py, which sends the same frame
both ways and compares the resolved SAA5050 cells:
Frame |
Saved |
Difference |
|---|---|---|
the demonstration frame |
462 bytes |
none |
blank rows between content |
897 bytes |
none |
a row filled to column 40 |
842 bytes |
none |
nothing after the first row |
947 bytes |
none |
a trailing attribute |
942 bytes |
none |
a wholly full frame |
0 bytes |
none |
Real pages save between a third and three quarters, which at 1200 baud is eight seconds down to two or three.
The one case that would break it is a row filled to all forty columns: that wraps of its own accord, so a terminator after it would skip the row below. It is in the table above for that reason.
The cursor moves on command¶
Measured by docs/spikes/spike_cursor_output.py. Commstar acts on the cursor
codes sent to it, which makes it possible to redraw part of a screen instead
of all of it:
Sent |
Effect |
|---|---|
|
Home, then up — wraps to row 23 |
|
Home, then down to row n |
|
Back to column 0 of the current row |
|
Back one cell, over what is there |
|
Forward one cell, leaving it as it was |
Two findings matter more than the rest. Moving the cursor erases nothing — after home and five downs, all twenty-four rows still bore their labels. And cursor up from row 0 wraps to row 23, so the footer row is two bytes away rather than twenty-four.
Overwriting row 23 alone left rows 0 to 22 untouched. So a command line, or any
other partial redraw, costs about 2 + len(text) bytes: a few milliseconds at
9600 baud, and under a third of a second at 1200.
Smaller edits are cheaper still, and are what the command line actually uses. Because the cursor is left where the next character goes, typing one costs that character alone, and rubbing one out costs three bytes — cursor left, a space, cursor left. The space keeps the row’s background, the attributes that set it sitting earlier in the row and going untouched.
This is also what differential update would need, should the whole-frame repaint ever become the thing worth optimising.
A block of rows can be repainted, if each row is trimmed¶
Measured by docs/spikes/spike_suggestion_block.py, which asks whether a
type-ahead search is possible at all: the reader types, and the best three
matching places are repainted beneath the field.
A row written to all forty columns must not be followed by a cursor down.
It wraps by itself — spike_frame_geometry established that — so the cursor is
already on the next row, and the carriage return and cursor down that walk to
the next row of the block move it down a second one. A three-row block written
that way lands on rows 4, 6 and 8, overruns what is beneath it, and leaves the
rows between showing whatever they showed before. This is what the spike found
first, and nothing but a real screen would have found it: the arithmetic is
identical either way.
So each row of a multi-row repaint is sent trimmed to its last non-blank cell, which fixes the walk and costs a third fewer bytes into the bargain.
What it costs, on real Commstar, for three suggestions of a name and a country:
Repainted |
Bytes |
At 1200 baud |
|---|---|---|
The field alone — the common keystroke |
40 |
0.33 s |
The rows that changed |
81 |
0.68 s |
The whole block |
121 |
1.01 s |
A reader on a BBC keypad types perhaps two characters a second, so the block keeps up when the list is settling and lags a little while it churns. Nine suggestions would not: the same measurement scaled is nearly three seconds a keystroke, which is why the design is three.
Nothing below the block was disturbed, and the title row survived — so a repaint neither scrolls nor overruns once the trimming is right.
Two things this did not establish. Whether an attribute can leak out of a
row rewritten mid-frame is only inferred: the rows beneath kept their own text,
and attributes are known to reset at the start of every row, but the emulator’s
cell API did not expose a foreground colour to read back. And where the cursor
ends up after the walk could not be read at all — cursor_row and
cursor_column are not exposed — so, as with cursor visibility, that one needs
an eye on a real screen.
The cursor¶
Commstar shows a cursor by default, which is a distraction on a page nobody is typing into. Every frame therefore begins by hiding it, and the command line turns it back on where the next character will land — the one place in the service a cursor tells a reader anything.
0x11 (DC1) shows the cursor and 0x14 (DC4) hides it, following viewdata
convention and confirmed on a real screen. Both are consumed as controls rather
than displayed: 0x11 A B 0x14 C D renders as ABCD with no gap, so neither
takes a cell and neither is mistaken for the graphics colour at the same value.
Confirmed by looking rather than by reading the screen back, because the cursor flashes — which defeated a first attempt to measure it, two readings of the same state disagreeing because they caught opposite halves of the blink. A sample of a flashing thing is a coin toss.
A testing gotcha¶
Beebium’s teletext_screen().text maps cell codes to ASCII, not to the glyph the
SAA5050 draws. A cell holding 0x5F reads back as _ in that string even though
the screen shows #. Assert on cell.character when the identity of a character
matters; .text is for locating things, not for confirming them.
One consequence: the glyph at 0x60 could not be confirmed this way, so nothing in
Sextile depends on it. Em and en dashes transliterate to -.
Frame geometry¶
Measured by docs/spikes/spike_frame_geometry.py, and simpler than expected.
Home is row 0, and twenty-four rows written with CR/LF land on rows 0–23. Commstar reserves no status line at the top.
Column 40 wraps by itself. Forty characters with no CR or LF advance to the start of the next row, so a frame needs no line terminators at all.
The bottom wraps to the top; nothing scrolls. Writing a twenty-fifth row overwrote row 0 rather than scrolling the display. Row 24 of the BBC’s 25-row Mode 7 screen is never used.
So a frame is exactly 24 rows of 40 cells, and serialising one is: clear
screen, home, then precisely 960 cells. Emitting even one cell more corrupts the
top of the frame it just drew, which is why Frame is a fixed-size grid rather
than a stream of writes.
What this does not yet cover¶
Whether ip232 framing behaves as documented end to end — measured only as a codec so far, never with Commstar attached. Not on the critical path: tcpser is already the ip232 endpoint, so Sextile is a plain TCP server that tcpser dials into, exactly as it dials any other board.