Three things Snake found the moment somebody ran it in a window, and all three are the same kind of mistake: the console grew a screen and kept asking the terminal. IT COULD NOT CLEAR THE SCREEN. Every program here that moves a cursor does it with ANSI escapes, because until there was a screen the thing on the other end was somebody's terminal. The controller drew "[2J" as three letters and left the board underneath. It now parses them, which is what a video terminal did - a VT100 is exactly this. The whole corpus uses two, ESC[2J and ESC[H, and the general shape is recognised so anything else is swallowed rather than drawn: a sequence nobody implemented should leave no marks. Cursor positioning is in too, since it is the same parse and one line more. IT DID NOT SEE KEYS FROM THE WINDOW, but did when the terminal behind it was focused, which is the whole diagnosis in one sentence. Snake polls the READY bit and never blocks, and consoleFetch - what the status port asks - was polling standard input regardless of whether a front end had installed a hook. So a window's keys were invisible to every program that looks before it reads, and a keystroke aimed at the terminal would be picked up instead. The hook now takes a question. Zero is the status port looking, and must not present or sleep: a program polling in a loop would otherwise be charged a frame for every glance. One is the data port blocking, where presenting is exactly right, because a machine waiting for a key is still a machine somebody is looking at. One value for both would have made either polling ruinous or waiting dead. AND IT RAN SLOWLY, which was the same bug wearing a hat: a game that never receives a steering key is a game that only ever goes one way. Six more checks in Tests/video.sh, to 32: that ESC[2J clears, that ESC[H goes to the corner without disturbing what is drawn, that ESC[3;5H counts rows and columns from one, and that an unknown sequence is swallowed and leaves nothing behind. The hook itself is still the one thing here the suite cannot reach - it exists only when there is a window, and this host has no display. It was found by a person playing Snake, which is where the Test Manual says these go on being found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
405 lines
17 KiB
Bash
Executable File
405 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Checks what the video device actually draws.
|
|
#
|
|
# THE SUITE HAS NO DISPLAY, and a screen nothing can look at is a screen nothing checks. So
|
|
# the device renders into a buffer that is a pure function of video memory, and the machine
|
|
# can be asked to save it with --screen. Every check below runs a program, saves the picture
|
|
# and reads pixels out of it - no window, no display server, and the same answer every time.
|
|
#
|
|
# Each check is a named claim about one behaviour rather than a comparison against a
|
|
# recorded image. A recorded image would say "something changed" and leave which of the
|
|
# palette, the tile, the attribute, the map or the scroll register broke to be found by
|
|
# hand, which for a screen is the hardest kind of bug to see.
|
|
#
|
|
# Written by Anachronaut
|
|
|
|
set -u
|
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
BUILD="$ROOT/Tests/build/video"
|
|
ASM="$ROOT/Assembler"
|
|
EMU="$ROOT/SplitBit"
|
|
|
|
for tool in "$ASM" "$EMU"; do
|
|
[ -x "$tool" ] || { echo "$(basename "$tool") is not built."; exit 1; }
|
|
done
|
|
|
|
rm -rf "$BUILD"; mkdir -p "$BUILD"
|
|
|
|
PASS=0
|
|
FAIL=0
|
|
FAILED_NAMES=()
|
|
|
|
GREEN=$'\033[32m'; RED=$'\033[31m'; RESET=$'\033[0m'
|
|
[ -t 1 ] || { GREEN=""; RED=""; RESET=""; }
|
|
|
|
result() {
|
|
# result <ok|no> <name> <detail>
|
|
if [ "$1" = "ok" ]; then
|
|
PASS=$((PASS + 1)); printf " [%sok %s] %-38s %s\n" "$GREEN" "$RESET" "$2" "$3"
|
|
else
|
|
FAIL=$((FAIL + 1)); FAILED_NAMES+=("$2")
|
|
printf " [%sFAIL%s] %-38s %s\n" "$RED" "$RESET" "$2" "$3"
|
|
fi
|
|
}
|
|
|
|
# ---- Writing to video memory from a program ----
|
|
#
|
|
# Through the controller, because that is the only way to reach a device's bank: the CPU
|
|
# never touches it directly. The Data port puts a byte at the destination and steps the
|
|
# address on, which is what makes a poke six instructions instead of a loop.
|
|
prologue() {
|
|
cat <<'ASM'
|
|
#Program
|
|
start:
|
|
INIA 0d3
|
|
OUTA 0xE3
|
|
INIA 0x30
|
|
OUTA 0xE2
|
|
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() {
|
|
# poke <address> <byte>
|
|
printf ' INIA 0x%02X\n OUTA 0xE4\n INIA 0x%02X\n OUTA 0xE5\n INIA 0x%02X\n OUTA 0xE9\n' \
|
|
$(( ($1 >> 8) & 0xFF )) $(( $1 & 0xFF )) $(( $2 & 0xFF ))
|
|
}
|
|
|
|
port() {
|
|
# port <port> <byte>
|
|
printf ' INIA 0x%02X\n OUTA 0x%02X\n' $(( $2 & 0xFF )) $(( $1 & 0xFF ))
|
|
}
|
|
|
|
show() {
|
|
# show <port> - sends a port's value to the console, so a test can read a register.
|
|
# INA reads straight into A, so there is nothing to move first.
|
|
printf ' INA 0x%02X\n OUTA 0x00\n' $(( $1 & 0xFF ))
|
|
}
|
|
|
|
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"
|
|
cat > "$BUILD/$name.asm"
|
|
"$ASM" "$BUILD/$name.asm" -o "$BUILD/$name.bin" >"$BUILD/$name.log" 2>&1 || {
|
|
echo "could not assemble $name"; sed 's/^/ /' "$BUILD/$name.log"; return 1; }
|
|
"$EMU" --fast --screen "$BUILD/$name.ppm" "$BUILD/$name.bin" > "$BUILD/$name.out" 2>&1
|
|
}
|
|
|
|
# One pixel out of a PPM, as "r,g,b".
|
|
pixel() {
|
|
python3 - "$BUILD/$1.ppm" "$2" "$3" <<'PY'
|
|
import sys
|
|
data = open(sys.argv[1], "rb").read()
|
|
# P6, width height, maxval, then the bytes. The header is three whitespace-separated
|
|
# fields after the magic, which is all this needs to know about the format.
|
|
fields = data.split(b"\n", 3)
|
|
width, height = (int(n) for n in fields[1].split())
|
|
body = fields[3]
|
|
x, y = int(sys.argv[2]), int(sys.argv[3])
|
|
at = (y * width + x) * 3
|
|
print("%d,%d,%d" % tuple(body[at:at + 3]))
|
|
PY
|
|
}
|
|
|
|
size() {
|
|
head -c 20 "$BUILD/$1.ppm" | sed -n '2p'
|
|
}
|
|
|
|
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)" = "0,0,0" ] \
|
|
&& result ok "the machine wakes with paper" "black, before any program set one" \
|
|
|| result no "the machine wakes with paper" "got $(pixel wakeup 0 0)"
|
|
[ "$(pixel wakeup 2 1)" = "216,216,216" ] \
|
|
&& 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
|
|
# and column 3 of row 2. A tile drawn one cell out is the commonest way a tile engine is
|
|
# wrong, so the check is where it is AND where it is not.
|
|
{ prologue
|
|
poke 0xC004 0xFF; poke 0xC005 0x00; poke 0xC006 0x00
|
|
for i in $(seq 0 63); do poke $((0x0040 + i)) 0x01; done
|
|
poke 0x4000 0x01; poke 0x4001 0x00
|
|
poke $((0x4000 + 2 * 256 + 3 * 2)) 0x01
|
|
epilogue
|
|
} | run corner || exit 1
|
|
|
|
[ "$(pixel corner 0 0)" = "255,0,0" ] \
|
|
&& result ok "a tile lands where it is put" "cell 0,0 is red" \
|
|
|| result no "a tile lands where it is put" "got $(pixel corner 0 0)"
|
|
[ "$(pixel corner 7 7)" = "255,0,0" ] \
|
|
&& result ok "and fills its whole cell" "pixel 7,7 too" \
|
|
|| result no "and fills its whole cell" "got $(pixel corner 7 7)"
|
|
[ "$(pixel corner 8 0)" = "0,0,0" ] \
|
|
&& result ok "and stops at the cell edge" "pixel 8,0 is not" \
|
|
|| result no "and stops at the cell edge" "got $(pixel corner 8 0)"
|
|
[ "$(pixel corner 24 16)" = "255,0,0" ] \
|
|
&& result ok "row 2 column 3 is where it says" "pixel 24,16" \
|
|
|| result no "row 2 column 3 is where it says" "got $(pixel corner 24 16)"
|
|
|
|
# ---- The palette is what colours it ----
|
|
#
|
|
# Same tile, same map, a different palette entry. Nothing about the picture changes except
|
|
# the three bytes the colour came from.
|
|
{ prologue
|
|
poke 0xC004 0x00; poke 0xC005 0xFF; poke 0xC006 0x40
|
|
for i in $(seq 0 63); do poke $((0x0040 + i)) 0x01; done
|
|
poke 0x4000 0x01; poke 0x4001 0x00
|
|
epilogue
|
|
} | run palette || exit 1
|
|
|
|
[ "$(pixel palette 0 0)" = "0,255,64" ] \
|
|
&& result ok "the palette is what colours it" "entry 1 moved, the tile did not" \
|
|
|| result no "the palette is what colours it" "got $(pixel palette 0 0)"
|
|
|
|
# ---- The attribute picks a palette bank ----
|
|
#
|
|
# The tile is drawn in index 1 and never changes. Entry 1 is red and entry 17 is blue, and
|
|
# the only difference between the two cells is the attribute nibble: 0 leaves the index
|
|
# alone, 1 adds sixteen. This is the whole of the recolouring feature in one check.
|
|
{ prologue
|
|
poke 0xC004 0xFF; poke 0xC005 0x00; poke 0xC006 0x00
|
|
poke 0xC044 0x00; poke 0xC045 0x00; poke 0xC046 0xFF
|
|
for i in $(seq 0 63); do poke $((0x0040 + i)) 0x01; done
|
|
poke 0x4000 0x01; poke 0x4001 0x00
|
|
poke 0x4002 0x01; poke 0x4003 0x01
|
|
epilogue
|
|
} | run attribute || exit 1
|
|
|
|
[ "$(pixel attribute 0 0)" = "255,0,0" ] \
|
|
&& result ok "attribute 0 leaves the index alone" "still entry 1" \
|
|
|| result no "attribute 0 leaves the index alone" "got $(pixel attribute 0 0)"
|
|
[ "$(pixel attribute 8 0)" = "0,0,255" ] \
|
|
&& result ok "and attribute 1 adds sixteen" "the same tile, entry 17" \
|
|
|| result no "and attribute 1 adds sixteen" "got $(pixel attribute 8 0)"
|
|
|
|
# ---- Scrolling moves a register, not memory ----
|
|
#
|
|
# The tile is in map row 3 and nothing moves it. Setting the scroll origin to 3 brings that
|
|
# row to the top of the screen, which is the whole reason a terminal on this machine is
|
|
# affordable at all.
|
|
{ prologue
|
|
poke 0xC004 0xFF; poke 0xC005 0xFF; poke 0xC006 0x00
|
|
for i in $(seq 0 63); do poke $((0x0040 + i)) 0x01; done
|
|
poke $((0x4000 + 3 * 256)) 0x01
|
|
port 0x34 0x03
|
|
epilogue
|
|
} | run scroll || exit 1
|
|
|
|
[ "$(pixel scroll 0 0)" = "255,255,0" ] \
|
|
&& result ok "scrolling moves which row is on top" "map row 3 at screen row 0" \
|
|
|| result no "scrolling moves which row is on top" "got $(pixel scroll 0 0)"
|
|
[ "$(pixel scroll 0 8)" = "0,0,0" ] \
|
|
&& result ok "and takes the rest with it" "map row 4 below it" \
|
|
|| result no "and takes the rest with it" "got $(pixel scroll 0 8)"
|
|
|
|
# ---- The map is a ring ----
|
|
#
|
|
# Origin 127 with 128 rows puts map row 127 at the top and map row 0 immediately under it.
|
|
# A map that clipped instead of wrapping would show nothing on the second row.
|
|
{ prologue
|
|
poke 0xC004 0xFF; poke 0xC005 0xFF; poke 0xC006 0xFF
|
|
for i in $(seq 0 63); do poke $((0x0040 + i)) 0x01; done
|
|
poke 0x4000 0x01
|
|
port 0x34 0x7F
|
|
epilogue
|
|
} | run ring || exit 1
|
|
|
|
[ "$(pixel ring 0 8)" = "255,255,255" ] \
|
|
&& result ok "the map is a ring" "row 0 follows row 127" \
|
|
|| result no "the map is a ring" "got $(pixel ring 0 8)"
|
|
|
|
# ---- Modes ----
|
|
{ prologue; port 0x31 0x01; epilogue; } | run wide || exit 1
|
|
[ "$(size wide)" = "640 400" ] \
|
|
&& result ok "mode 1 is 640 by 400" "$(size wide)" \
|
|
|| result no "mode 1 is 640 by 400" "got $(size wide)"
|
|
|
|
{ prologue; epilogue; } | run narrow || exit 1
|
|
[ "$(size narrow)" = "320 200" ] \
|
|
&& result ok "and mode 0 is 320 by 200" "$(size narrow)" \
|
|
|| result no "and mode 0 is 320 by 200" "got $(size narrow)"
|
|
|
|
# The geometry is asked for rather than assumed, so a program can be written once and find
|
|
# out what it is running on.
|
|
{ prologue; show 0x32; show 0x33; epilogue; } | run geometry || exit 1
|
|
GOT="$(head -c 2 "$BUILD/geometry.out" | od -An -tu1 | tr -s ' ' | sed 's/^ //;s/ $//')"
|
|
[ "$GOT" = "40 25" ] \
|
|
&& result ok "the ports say how big the screen is" "40 columns, 25 rows" \
|
|
|| result no "the ports say how big the screen is" "got \"$GOT\""
|
|
|
|
# ---- A mode that does not exist ----
|
|
#
|
|
# Not taken, and not fatal either. A screen is a poor place to stop the machine: a program
|
|
# that asked for something impossible still has the screen it had.
|
|
{ prologue; port 0x31 0x09; show 0x32; epilogue; } | run badmode || exit 1
|
|
GOT="$(head -c 1 "$BUILD/badmode.out" | od -An -tu1 | tr -d ' ')"
|
|
[ "$GOT" = "40" ] \
|
|
&& 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")" = "216,216,216" ]; }
|
|
papered() { [ "$(pixel "$1" "$2" "$3")" = "0,0,0" ]; }
|
|
|
|
{ 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"
|
|
|
|
# ---- The sequences the corpus already speaks ----
|
|
#
|
|
# Every program here that moves a cursor does it with ANSI escapes, because until there was
|
|
# a screen the thing on the other end was somebody's terminal. A controller that did not
|
|
# understand them drew "[2J" on the screen and left the board underneath, which is what Snake
|
|
# did the first time it was run in a window.
|
|
|
|
{ printf '#Program\nstart:\n'; say "A"; emit 27; say "[2J"; epilogue; } | run clearscreen || exit 1
|
|
papered clearscreen 2 1 \
|
|
&& result ok "ESC[2J clears the screen" "the letter is gone" \
|
|
|| result no "ESC[2J clears the screen" "still inked at 2,1"
|
|
|
|
{ printf '#Program\nstart:\n'; emit 10; emit 10; say "A"; emit 27; say "[H"; say "A"
|
|
epilogue
|
|
} | run home || exit 1
|
|
inked home 2 1 \
|
|
&& result ok "ESC[H goes back to the corner" "the second letter is at row 0" \
|
|
|| result no "ESC[H goes back to the corner" "nothing at 2,1"
|
|
inked home 2 17 \
|
|
&& result ok "and leaves what was drawn alone" "the first is still on row 2" \
|
|
|| result no "and leaves what was drawn alone" "nothing at 2,17"
|
|
|
|
{ printf '#Program\nstart:\n'; emit 27; say "[3;5H"; say "A"; epilogue; } | run position || exit 1
|
|
inked position 34 17 \
|
|
&& result ok "ESC[3;5H puts the cursor there" "row 3, column 5, counting from one" \
|
|
|| result no "ESC[3;5H puts the cursor there" "nothing at 34,17"
|
|
|
|
# A sequence nobody implemented should leave no marks, which is what a real terminal does
|
|
# with one it does not know. Drawing it would be worse than ignoring it.
|
|
{ printf '#Program\nstart:\n'; emit 27; say "[9m"; say "A"; epilogue; } | run unknownseq || exit 1
|
|
inked unknownseq 2 1 \
|
|
&& result ok "an unknown sequence is swallowed" "the letter after it is at cell 0" \
|
|
|| result no "an unknown sequence is swallowed" "nothing at 2,1"
|
|
papered unknownseq 10 1 \
|
|
&& result ok "and leaves nothing behind" "cell 1 is untouched" \
|
|
|| result no "and leaves nothing behind" "something at 10,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."
|
|
exit 0
|
|
fi
|
|
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
|
|
exit 1
|