A second screen, and one port to say which is shown

A screen drawn where it can be seen is seen half drawn. A program that
moves forty things and rewrites the map underneath them is wrong for as
long as it takes to put them all right, and at a megahertz that is long
enough to look at.

So the device brings a second screen bank, on port 0x3B, and port 0x3C
says which of the two is displayed. Everything a program draws into the
other one is invisible until one byte shows the whole of it at once.

ONE REGISTER IS ENOUGH, where the hardware this imitates needed two. The
other said which screen the CPU's window pointed at; there is no window
here, because a program reaches a bank through the memory controller by
its number. Writing to the screen that is not shown is a matter of naming
its bank, and the device never has to be told.

And a flip cannot tear: a frame is drawn from one bank in one go, so a
flip either happened before that frame or happens before the next. There
is nothing to race, where the real machines had to catch the few lines
between frames to swap in.

The console draws into whichever screen is displayed rather than one of
its own, so a fault message lands where somebody can read it even if a
game had flipped. And CosmOS puts the displayed screen back at exit, the
way it already puts back the cursor and the ink: a program that faulted
while flipped could not have, and a shell that only came out right for
programs which remembered would come out wrong the day one crashed.

Flip.asm is the worked example. It deliberately does NOT restore the
display itself - that is the point of the paragraph above, and it is what
makes the system's guarantee the thing under test rather than the
program's good manners. Written the other way round first, where it
passed with the guarantee deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
Anachronaut
2026-09-02 10:31:29 -04:00
co-authored by Claude Opus 5
parent 66e7b84272
commit 023362b05a
17 changed files with 399 additions and 37 deletions
+2 -1
View File
@@ -22,6 +22,7 @@ Break.sbx 149
Grid.sbx 571
Press.sbx 872
Mode.sbx 48
Flip.sbx 291
Crash.sbx 632
vars.script 50
blocks.script 343
@@ -38,7 +39,7 @@ outer.script 376
inner.script 44
loop.script 35
crossed.txt 560
25 files, 1 directory
26 files, 1 directory
> exit
halted
Execution halted.
+2 -1
View File
@@ -12,6 +12,7 @@ Break.sbx 149
Grid.sbx 571
Press.sbx 872
Mode.sbx 48
Flip.sbx 291
Crash.sbx 632
vars.script 50
blocks.script 343
@@ -27,7 +28,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
24 files, 1 directory
25 files, 1 directory
> drive 1
> dir
other.txt 28
+2 -1
View File
@@ -29,6 +29,7 @@ Break.sbx 149
Grid.sbx 571
Press.sbx 872
Mode.sbx 48
Flip.sbx 291
Crash.sbx 632
vars.script 50
blocks.script 343
@@ -44,7 +45,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
24 files, 1 directory
25 files, 1 directory
> exit
halted
Execution halted.
+2 -1
View File
@@ -19,6 +19,7 @@ Break.sbx 149
Grid.sbx 571
Press.sbx 872
Mode.sbx 48
Flip.sbx 291
Crash.sbx 632
vars.script 50
blocks.script 343
@@ -34,7 +35,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
24 files, 1 directory
25 files, 1 directory
> exit
halted
Execution halted.
+2 -1
View File
@@ -105,6 +105,7 @@ Break.sbx 149
Grid.sbx 571
Press.sbx 872
Mode.sbx 48
Flip.sbx 291
Crash.sbx 632
vars.script 50
blocks.script 343
@@ -120,7 +121,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
24 files, 1 directory
25 files, 1 directory
> exit
halted
Execution halted.
+2 -1
View File
@@ -24,6 +24,7 @@ Break.sbx 149
Grid.sbx 571
Press.sbx 872
Mode.sbx 48
Flip.sbx 291
Crash.sbx 632
vars.script 50
blocks.script 343
@@ -39,7 +40,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
24 files, 1 directory
25 files, 1 directory
> exit
halted
Execution halted.
+2 -1
View File
@@ -12,6 +12,7 @@ Break.sbx 149
Grid.sbx 571
Press.sbx 872
Mode.sbx 48
Flip.sbx 291
Crash.sbx 632
vars.script 50
blocks.script 343
@@ -27,7 +28,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
24 files, 1 directory
25 files, 1 directory
> load
load what?
> load nosuch.sbx
+2 -1
View File
@@ -10,6 +10,7 @@ Break.sbx 149
Grid.sbx 571
Press.sbx 872
Mode.sbx 48
Flip.sbx 291
Crash.sbx 632
vars.script 50
blocks.script 343
@@ -25,7 +26,7 @@ nonl.script 38
outer.script 376
inner.script 44
loop.script 35
24 files, 1 directory
25 files, 1 directory
> load Say.sbx
loaded, starting at 5000
> run the disk took its time
+6
View File
@@ -124,6 +124,12 @@ for i in 1 2 3 4 5 6 7 8; do "$TOOL" put "$DISKS/sbfs.img" "filler$i.txt" >/dev/
"$ROOT/Assembler" -I "$ROOT/Programs/CosmOS/Source" \
"$ROOT/Programs/CosmOS/Apps/Mode.asm" -o "$WORK/Mode.sbx" >/dev/null
"$TOOL" put "$DISKS/cosmos.img" "$WORK/Mode.sbx" >/dev/null
# Flip.sbx draws a whole screen into the bank that is not being shown, waits, and then shows
# it. It is the only program here that leaves the machine displaying the OTHER screen, which
# is what makes it the thing that checks the system puts that back.
"$ROOT/Assembler" -I "$ROOT/Programs/CosmOS/Source" \
"$ROOT/Programs/CosmOS/Apps/Flip.asm" -o "$WORK/Flip.sbx" >/dev/null
"$TOOL" put "$DISKS/cosmos.img" "$WORK/Flip.sbx" >/dev/null
# Crash.sbx breaks in each of the four ways the system now catches, which is the only way to
# reach a fault screen from a recorded test.
"$ROOT/Assembler" -I "$ROOT/Programs/CosmOS/Source" \
+113
View File
@@ -63,6 +63,12 @@ start:
OUTA 0xE2
INIA 0x03
OUTA 0xE8 ; And the screen - the map, or a bitmap - becomes bank 4
INIA 0d5
OUTA 0xE3
INIA 0x3B
OUTA 0xE2
INIA 0x03
OUTA 0xE8 ; And the other screen, the one nobody is looking at, bank 5
ASM
# ---- Said rather than assumed ----
#
@@ -87,6 +93,7 @@ pokeTo() {
pokeAtlas() { pokeTo 3 "$1" "$2"; } # Tiles and the palette.
pokeScreen() { pokeTo 4 "$1" "$2"; } # The map, or a bitmap.
pokeBack() { pokeTo 5 "$1" "$2"; } # The same, in the screen not being shown.
# Many bytes from one address, using the Data port's own stepping rather than naming the
# address again for each. What a run of tile memory is for, and so far only the atlas needs
@@ -322,6 +329,74 @@ echo "Checking what the video device draws."
&& result ok "the map is a ring" "row 0 follows row 127" \
|| result no "the map is a ring" "got $(pixel ring 0 8)"
# ---- Two screens, and the flip between them ----
#
# One red cell in each screen, in different rows: row one of the screen being shown, and the
# corner of the one that is not. BOTH HALVES ARE CHECKED, because either alone is weak - that
# the corner stayed empty would also be true of a bank that went nowhere, and that row one
# appeared would also be true of a device with one screen written twice. Together they say
# the two banks are different memory and only one of them is the screen.
{ prologue
pokeAtlas 0xFC04 0xFF; pokeAtlas 0xFC05 0x00; pokeAtlas 0xFC06 0x00
for i in $(seq 0 63); do pokeAtlas $((0x0040 + i)) 0x01; done
pokeScreen $((0x4000 + 256)) 0x01
pokeBack 0x4000 0x01; pokeBack 0x4001 0x00
epilogue
} | run backbuffer || exit 1
[ "$(pixel backbuffer 0 8)" = "255,0,0" ] && [ "$(pixel backbuffer 0 0)" = "0,0,0" ] \
&& result ok "the back buffer is not the screen" "row one showed, the other screen did not" \
|| result no "the back buffer is not the screen" "row one $(pixel backbuffer 0 8), corner $(pixel backbuffer 0 0)"
# The same program, and one more byte out of one more port.
{ prologue
pokeAtlas 0xFC04 0xFF; pokeAtlas 0xFC05 0x00; pokeAtlas 0xFC06 0x00
for i in $(seq 0 63); do pokeAtlas $((0x0040 + i)) 0x01; done
pokeBack 0x4000 0x01; pokeBack 0x4001 0x00
port 0x3C 0x01
epilogue
} | run flipped || exit 1
[ "$(pixel flipped 0 0)" = "255,0,0" ] \
&& result ok "and the flip is what shows it" "one write to 0x3C, a whole new screen" \
|| result no "and the flip is what shows it" "got $(pixel flipped 0 0)"
# And back again, which is the half that says the first screen was kept rather than copied
# over. A program that flips to draw and flips back must find what it left.
{ prologue
pokeAtlas 0xFC04 0xFF; pokeAtlas 0xFC05 0x00; pokeAtlas 0xFC06 0x00
for i in $(seq 0 63); do pokeAtlas $((0x0040 + i)) 0x01; done
pokeScreen $((0x4000 + 256)) 0x01
pokeBack 0x4000 0x01
port 0x3C 0x01
port 0x3C 0x00
epilogue
} | run flippedback || exit 1
[ "$(pixel flippedback 0 8)" = "255,0,0" ] && [ "$(pixel flippedback 0 0)" = "0,0,0" ] \
&& result ok "and flipping back finds what was there" "row one kept, the corner still empty" \
|| result no "and flipping back finds what was there" "corner $(pixel flippedback 0 0), row one $(pixel flippedback 0 8)"
# Asked for rather than remembered, like every other register on this device. And a screen
# that does not exist is not taken, the same as a mode that does not exist.
{ prologue; port 0x3C 0x01; show 0x3C; port 0x3C 0x07; show 0x3C; epilogue
} | run whichscreen || exit 1
[ "$(said whichscreen)" = "1 1" ] \
&& result ok "which screen is shown can be asked" "and screen seven was not taken" \
|| result no "which screen is shown can be asked" "got $(said whichscreen)"
# ---- The console draws where the person is looking ----
#
# Not into a screen of its own. A game that flipped and then faulted needs the message to
# land where somebody can read it, and the console has no way of knowing that happened.
{ prologue; port 0x3C 0x01; say "A"; epilogue; } | run textflipped || exit 1
[ "$(pixel textflipped 2 1)" = "216,216,216" ] \
&& result ok "the console follows the flip" "the letter is on the screen being shown" \
|| result no "the console follows the flip" "got $(pixel textflipped 2 1)"
# And it really went to the other one: flipping back finds the first screen as it was.
{ prologue; port 0x3C 0x01; say "A"; port 0x3C 0x00; epilogue; } | run textnotback || exit 1
[ "$(pixel textnotback 2 1)" = "0,0,0" ] \
&& result ok "and wrote it in that screen only" "screen nought never saw the letter" \
|| result no "and wrote it in that screen only" "got $(pixel textnotback 2 1)"
# ---- Modes ----
{ prologue; port 0x31 0x01; epilogue; } | run wide || exit 1
[ "$(size wide)" = "640 400" ] \
@@ -929,6 +1004,44 @@ LEFT="$(python3 "$ROOT/Tests/periodic.py" "$BUILD/noscratch.ppm" grid)"
&& result ok "and clears up when it cannot be kept" "no grid left on the screen" \
|| result no "and clears up when it cannot be kept" "the grid is still there"
# ---- The back buffer, from inside the system ----
#
# Flip draws a whole screen into the bank nobody is looking at, waits, shows it, waits, and
# puts it back. Caught here while it is showing: the map it filled is one tile and one
# attribute everywhere, so the picture is a SINGLE COLOUR and counting them says so without
# depending on which colour scheme one happens to be.
python3 -c "open('$BUILD/flip.keys','wb').write(b'Flip\n' + b'\x00'*3000 + b' ' + b'\x00'*9000)"
timeout 30 "$EMU" --fast --cycles 200000000 --keyboard "$BUILD/flip.keys" \
--screen "$BUILD/flip.ppm" --disk "$ROOT/Tests/build/disks/cosmos.img" \
--ram-disk 2048 "$BUILD/cosmos.bin" > "$BUILD/flip.out" 2>&1 || true
FLIPPED="$(python3 -c "
d = open('$BUILD/flip.ppm', 'rb').read()
px = d[d.index(b'255\n') + 4:]
print(len({px[o:o + 3] for o in range(0, len(px), 3)}))
" 2>/dev/null || echo 0)"
[ "$FLIPPED" = "1" ] \
&& result ok "a program shows the other screen" "the whole picture is the one it filled" \
|| result no "a program shows the other screen" "$FLIPPED colours, not a filled screen"
# ---- And the system takes it back ----
#
# The shell's scrollback, its prompt and every line the person typed are in screen NOUGHT.
# A program that exited while showing screen one would hand back a shell drawing perfectly
# onto a screen nobody had ever written to - blank, and looking for all the world like the
# machine had lost everything. Compared cell by cell against the same session without Flip
# in it, which is the same way Grid's restore is checked.
python3 -c "open('$BUILD/flipbefore.keys','wb').write(b'dir\n' + b'Say a line to come back to\n' + b'\x00'*200)"
python3 -c "open('$BUILD/flipafter.keys','wb').write(b'dir\n' + b'Say a line to come back to\n' + b'Flip\n' + b'\x00'*3000 + b' ' + b'\x00'*3000 + b' ' + b'\x00'*3000)"
for phase in flipbefore flipafter; do
timeout 30 "$EMU" --fast --cycles 200000000 --keyboard "$BUILD/$phase.keys" \
--screen "$BUILD/$phase.ppm" --disk "$ROOT/Tests/build/disks/cosmos.img" \
--ram-disk 2048 "$BUILD/cosmos.bin" > "$BUILD/$phase.out" 2>&1 || true
done
BACK="$(python3 "$ROOT/Tests/samescreen.py" "$BUILD/flipbefore.ppm" "$BUILD/flipafter.ppm")"
[ "$BACK" = "yes" ] \
&& result ok "and the system puts the screen back" "screen nought, with what was on it" \
|| result no "and the system puts the screen back" "$BACK"
# ---- Clearing puts the cursor back at the top ----
#
# A screen with nothing on it and a cursor half way down it is not a cleared screen. This