#!/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" } # 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' } 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