#!/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 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
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 printf ' INIA 0x%02X\n OUTA 0x%02X\n' $(( $2 & 0xFF )) $(( $1 & 0xFF )) } show() { # show - 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" } # About 262,000 cycles of nothing. A DECA is one byte and a BNA is three, so four cycles a # turn, 256 times 256. The label suffix is so that two of these can sit in one program. spin() { printf ' RSTB\nspinOuter%s:\n RSTA\nspinInner%s:\n DECA\n BNA spinInner%s\n DECB\n BNB spinOuter%s\n' \ "$1" "$1" "$1" "$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/.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' } # ---- What a program said, as numbers ---- # # With two things taken out that are not the program's: the cursor sequences the console # generates to drive a host terminal, and the emulator's own halt line. Counting bytes from # either end of the raw file worked until the console started announcing the cursor, and # then quietly measured an escape. said() { python3 - "$BUILD/$1.out" <<'PY' import re, sys data = open(sys.argv[1], "rb").read() data = re.sub(rb"\x1b\[[0-9;]*[A-Za-z]", b"", data) data = re.sub(rb"Execution [^\n]*\n$", b"", data) print(" ".join(str(byte) for byte in data)) PY } 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="$(said geometry)" [ "$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="$(said badmode)" [ "$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" # ---- Cursor registers, in place of a protocol ---- # # The console used to be given escape sequences and parse them. It is not a terminal and the # screen is not on the other end of a serial line, so it takes registers instead: rows and # columns are written and READ BACK, which is the thing an escape sequence cannot do without # sending a query and parsing a reply. { printf '#Program\nstart:\n'; say "A"; port 0x05 0x01; epilogue; } | run clearcommand || exit 1 papered clearcommand 2 1 \ && result ok "the clear command clears the screen" "the letter is gone" \ || result no "the clear command clears the screen" "still inked at 2,1" { printf '#Program\nstart:\n' emit 10; emit 10; say "A" port 0x03 0x00; port 0x04 0x00 say "A" epilogue } | run cursorhome || exit 1 inked cursorhome 2 1 \ && result ok "the cursor goes where it is put" "row 0, column 0" \ || result no "the cursor goes where it is put" "nothing at 2,1" inked cursorhome 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'; port 0x03 0x02; port 0x04 0x04; say "A"; epilogue } | run cursorput || exit 1 inked cursorput 34 17 \ && result ok "row and column are counted from zero" "row 2, column 4" \ || result no "row and column are counted from zero" "nothing at 34,17" # Readable, which is the point of them being registers. Three characters put the cursor at # column 3, and asking says so. { printf '#Program\nstart:\n'; say "AAA"; show 0x04; show 0x03; epilogue; } | run cursorread || exit 1 GOT="$(said cursorread)" [ "$GOT" = "65 65 65 3 0" ] \ && result ok "and the cursor can be read back" "column 3, row 0" \ || result no "and the cursor can be read back" "got \"$GOT\"" # A cursor asked to go off the screen has an obvious place to be, and stopping the machine # over one would be a poor trade. { printf '#Program\nstart:\n'; port 0x04 0xFF; show 0x04; epilogue; } | run cursorclamp || exit 1 GOT="$(said cursorclamp)" [ "$GOT" = "39" ] \ && result ok "a cursor past the edge is clamped" "column 39, the last one" \ || result no "a cursor past the edge is clamped" "got $GOT" # ---- Colour, which costs a nibble and no hardware ---- # # A glyph is drawn in palette indices 0 and 1, paper and ink, and a cell's attribute nibble # adds sixteen to both. Sixteen banks is therefore sixteen ink and paper pairs, and the # default palette is arranged so that XOR 8 turns any of them inside out. coloured() { [ "$(pixel "$1" "$2" "$3")" = "$4" ]; } { printf '#Program\nstart:\n'; port 0x06 0x01; say "A"; epilogue; } | run inkred || exit 1 coloured inkred 2 1 "208,64,56" \ && result ok "the attribute register colours the ink" "bank 1 is red on black" \ || result no "the attribute register colours the ink" "got $(pixel inkred 2 1)" coloured inkred 0 0 "0,0,0" \ && result ok "and leaves the paper alone" "still black behind it" \ || result no "and leaves the paper alone" "got $(pixel inkred 0 0)" # The same colour with one bit more, which is the whole of highlighting. { printf '#Program\nstart:\n'; port 0x06 0x09; say "A"; epilogue; } | run highlight || exit 1 coloured highlight 0 0 "208,64,56" \ && result ok "XOR 8 turns a pair inside out" "red paper now" \ || result no "XOR 8 turns a pair inside out" "got $(pixel highlight 0 0)" coloured highlight 2 1 "0,0,0" \ && result ok "and the ink with it" "black letters on it" \ || result no "and the ink with it" "got $(pixel highlight 2 1)" # Readable, like every other console register. { printf '#Program\nstart:\n'; port 0x06 0x05; show 0x06; epilogue; } | run attrread || exit 1 [ "$(said attrread)" = "5" ] \ && result ok "and the attribute reads back" "bank 5" \ || result no "and the attribute reads back" "got $(said attrread)" # ---- The cursor ---- # # Drawn by the device, turned inside out rather than drawn over, so that a person editing a # line can still see the character they are standing on. Off unless asked for: a program # painting its own screen does not want one blinking in the middle of it. { printf '#Program\nstart:\n'; port 0x02 0x04; epilogue; } | run cursoron || exit 1 coloured cursoron 0 0 "216,216,216" \ && result ok "a cursor appears where the console is" "an empty cell, inside out" \ || result no "a cursor appears where the console is" "got $(pixel cursoron 0 0)" { printf '#Program\nstart:\n'; epilogue; } | run cursoroff || exit 1 coloured cursoroff 0 0 "0,0,0" \ && result ok "and there is none unless asked for" "the machine draws what it is told" \ || result no "and there is none unless asked for" "got $(pixel cursoroff 0 0)" { printf '#Program\nstart:\n'; port 0x02 0x04; port 0x03 0x03; port 0x04 0x07; epilogue } | run cursorwhere || exit 1 coloured cursorwhere 56 24 "216,216,216" \ && result ok "and it follows the cursor registers" "row 3, column 7" \ || result no "and it follows the cursor registers" "got $(pixel cursorwhere 56 24)" # ---- And it blinks on the machine's own clock ---- # # Which is what makes it deterministic: the phase is a pure function of the cycle count, so # a screen saved at a given cycle is the same screen every time. Half a million cycles in it # is dark, and this burns about 524,000 - a DECA and a BNA are four cycles a turn. { printf '#Program\nstart:\n'; port 0x02 0x04; spin a; spin b; epilogue; } | run cursorblink || exit 1 coloured cursorblink 0 0 "0,0,0" \ && result ok "the cursor blinks off again" "half a second later, dark" \ || result no "the cursor blinks off again" "got $(pixel cursorblink 0 0)" # 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