Put CosmOS on the screen without changing a line of it

The console is now a display controller as well as a port: it owns a font, keeps a cursor,
handles newline, carriage return, backspace and wrapping, and scrolls. That is an ordinary
kind of chip - it is what a video terminal's character generator did - and it is the reason
this rung needed no changes to CosmOS at all. CosmOS already writes bytes to port 0x00.

It writes to BOTH the screen and standard output, which is deliberate. A machine with a
screen and a serial line is an ordinary machine, the emulator's standard output is that
serial line, and one console drives both. It is also what keeps all 165 recorded results
passing under Voyager, and what makes --screen work on the plain SplitBit: there is one
console and it drives everything it has.

Scrolling moves the video device's origin and no memory. The row arriving at the bottom is
cleared because the map is a ring and it holds what was there 128 rows ago; the rows going
off the top are not, and that is a hundred rows of scrollback nothing had to keep. The test
reads the register back rather than looking at the screen, because a console blitting rows
instead would look identical and cost twelve percent of a frame for every line printed.

The font is vendored from Hatchet-GPU with a note saying where it came from, since that
repository is not part of this one. 135 glyphs in ASCII order, which is the thing that makes
it worth keeping - PETSCII's whole inconvenience was that its order was not ASCII's, so a
machine using it needed a translation table in front of every string. Here the machine
subtracts 32. It is stored one bit a pixel and expanded into tile memory at reset: 1,088
bytes against 16 kilobytes.

Voyager gets a keyboard. A window has no standard input, and a machine blocking on it inside
a frame would stop drawing and stop answering, so a front end with a window installs a hook
that the console calls while it has nothing: it keeps the window alive and hands back a key.
The hook has to tell "nobody has typed yet", which happens sixty times a second, apart from
"the window has gone", which is the end of input - one value for both would have made the
first keystroke look like a closed machine. In line mode the console echoes what it is
given, because there is no terminal behind a window to do it and that was always the
terminal's job.

Tests/video.sh grew from 14 checks to 26, half of them about the console rather than the
device: those programs ask the video device for nothing and write bytes to port 0x00 like
every SplitBit program always has. Verified by breaking two things - removing the scroll
failed exactly the two checks about scrolling, and removing the cursor advance failed
exactly the three that depend on it.

Two video checks had quietly depended on palette entry 0 being black, which stopped being
true the moment a machine woke up able to show text. They now set what they are about to
look at, and a new check pins the waking state itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
Anachronaut
2026-08-28 22:26:27 -04:00
co-authored by Claude Opus 5
parent 83623a3df3
commit 773b0f8add
13 changed files with 600 additions and 36 deletions
+119
View File
@@ -58,6 +58,13 @@ start:
INIA 0x03
OUTA 0xE8 ; Video memory becomes bank 3
ASM
# ---- Said rather than assumed ----
#
# The machine wakes up with a palette so that it can show text before any program has
# run, so palette entry 0 is the console's paper rather than black. A check that wanted
# black and got paper would be a check that had quietly depended on a default. These
# tests are about the device, so they set what they are about to look at.
poke 0xC000 0x00; poke 0xC001 0x00; poke 0xC002 0x00
}
poke() {
@@ -81,6 +88,20 @@ epilogue() {
printf ' HALT\n#Vectors\n Boot start\n'
}
# One byte to the console, by its number.
emit() {
printf ' INIA 0d%d\n OUTA 0x00\n' "$1"
}
# A string to the console, which is all a program has ever had to do to put text on a
# SplitBit. That it now appears on a screen is the whole of this rung.
say() {
local i
for (( i = 0; i < ${#1}; i++ )); do
printf ' INIA 0d%d\n OUTA 0x00\n' "'${1:$i:1}"
done
}
# Assembles what is on standard input, runs it, and leaves the picture in $BUILD/<name>.ppm.
run() {
local name="$1"
@@ -112,6 +133,24 @@ size() {
echo "Checking what the video device draws."
# ---- The machine wakes up able to show text ----
#
# Before any program has done anything: the font is in tile memory and the two colours a
# console needs are in the palette. Checked at the pixel, because a font that loaded into
# the wrong place would still be a font that loaded.
{ printf '#Program\nstart:\n'
# 'A' is ASCII 65, so glyph 33, and its top-left pixel is paper while its middle is ink.
printf ' INIA 0d65\n OUTA 0x00\n'
epilogue
} | run wakeup || exit 1
[ "$(pixel wakeup 0 0)" = "16,20,18" ] \
&& result ok "the machine wakes with paper" "before any program set one" \
|| result no "the machine wakes with paper" "got $(pixel wakeup 0 0)"
[ "$(pixel wakeup 2 1)" = "220,230,220" ] \
&& result ok "and with a font to write in" "a letter A, drawn in ink" \
|| result no "and with a font to write in" "got $(pixel wakeup 2 1)"
# ---- A tile lands where it is put ----
#
# Palette entry 1 is red, tile 1 is 64 pixels of index 1, and two cells name it: the corner
@@ -239,6 +278,86 @@ GOT="$(head -c 1 "$BUILD/badmode.out" | od -An -tu1 | tr -d ' ')"
&& result ok "an impossible mode is not taken" "still 40 columns" \
|| result no "an impossible mode is not taken" "got $GOT"
# ---- The console draws ----
#
# Nothing below asks the video device for anything. Every one of these programs does what
# every SplitBit program has always done - write a byte to port 0x00 - and the picture is
# the point. That is why CosmOS needed no changes to run on a screen.
#
# 'A' has ink at (2,1) inside its cell and paper at the corner, which is what makes a letter
# tellable from an empty cell one pixel at a time.
inked() { [ "$(pixel "$1" "$2" "$3")" = "220,230,220" ]; }
papered() { [ "$(pixel "$1" "$2" "$3")" = "16,20,18" ]; }
{ printf '#Program\nstart:\n'; say "AA"; epilogue; } | run twoletters || exit 1
inked twoletters 2 1 \
&& result ok "a character lands at the cursor" "cell 0 has a letter in it" \
|| result no "a character lands at the cursor" "nothing at 2,1"
inked twoletters 10 1 \
&& result ok "and the cursor moves along" "the second is in cell 1" \
|| result no "and the cursor moves along" "nothing at 10,1"
{ printf '#Program\nstart:\n'; say "A"; emit 10; say "A"; epilogue; } | run newline || exit 1
inked newline 2 9 \
&& result ok "a newline starts the next row" "the second is a row down" \
|| result no "a newline starts the next row" "nothing at 2,9"
papered newline 10 1 \
&& result ok "and goes back to the first column" "cell 1 of row 0 is untouched" \
|| result no "and goes back to the first column" "something at 10,1"
{ printf '#Program\nstart:\n'; say "A"; emit 8; epilogue; } | run backspace || exit 1
papered backspace 2 1 \
&& result ok "backspace rubs the letter out" "the cell is paper again" \
|| result no "backspace rubs the letter out" "still inked at 2,1"
# Forty columns, so the forty-first character is on the next row whether anybody asked for a
# newline or not.
{ printf '#Program\nstart:\n'
for i in $(seq 1 41); do say "A"; done
epilogue
} | run wrap || exit 1
inked wrap 2 9 \
&& result ok "the line wraps at the last column" "character 41 is on row 1" \
|| result no "the line wraps at the last column" "nothing at 2,9"
# ---- Scrolling, which is the reason a terminal is affordable here ----
#
# Twenty-five rows, so a twenty-sixth line moves the screen rather than the cursor. The
# check is the ORIGIN: a console blitting rows instead would leave it at zero, and would
# have moved 1,920 bytes to do the same thing.
{ printf '#Program\nstart:\n'
say "A"
for i in $(seq 1 25); do emit 10; done
show 0x34
say "B"
epilogue
} | run scrolled || exit 1
# COUNTED FROM THE FRONT, not the back: the emulator's own halt line follows whatever the
# program wrote, so the last byte of the file belongs to the machine rather than to the
# program. One 'A', twenty-five newlines, then the origin, which is byte 27. It is below 32
# so it goes to standard output without being drawn, and disturbs no pixel below.
GOT="$(head -c 27 "$BUILD/scrolled.out" | tail -c 1 | od -An -tu1 | tr -d ' ')"
[ "$GOT" = "1" ] \
&& result ok "the screen scrolls by moving a register" "the origin is 1, not 0" \
|| result no "the screen scrolls by moving a register" "the origin is $GOT"
inked scrolled 2 193 \
&& result ok "and the cursor stays on the bottom row" "the last line, row 24" \
|| result no "and the cursor stays on the bottom row" "nothing at 2,193"
papered scrolled 2 1 \
&& result ok "the row that came into view is clear" "not what was there a ring ago" \
|| result no "the row that came into view is clear" "something at 2,1"
# And what scrolled off the top is still in the map, which is scrollback nothing had to keep.
{ printf '#Program\nstart:\n'
say "A"
for i in $(seq 1 25); do emit 10; done
port 0x34 0x00
epilogue
} | run scrollback || exit 1
inked scrollback 2 1 \
&& result ok "what scrolled off is still there" "the origin went back and found it" \
|| result no "what scrolled off is still there" "nothing at 2,1"
echo
if [ "$FAIL" -eq 0 ]; then
echo "All $PASS video checks passed."