A tile engine on ports 0x30 to 0x3F, bringing one bank of video memory registered the way the disk's buffer is. The CPU writes cell indices and the device turns them into pixels, which is the whole reason a screen is affordable at a megahertz: a frame is 16,667 cycles, a full 320 by 200 picture is 64,000 bytes, and a 40 by 25 map is 2,000. A program that changes two cells writes four bytes. The cost of a screen becomes the number of cells that changed rather than the number of pixels on it. Which makes colour depth free, so the tiles are eight bits: an 8 by 8 cell is 64 pixels and each picks independently out of 256 colours, with no per-cell limit of the kind that made a Spectrum two and C64 multicolour four. The low nibble of a cell's attribute is ADDED to every index in its tile, sixteen at a time, so a tile drawn in 0 to 15 appears in any of sixteen schemes without a second copy in tile memory - and a tile wanting all 256 leaves the nibble at zero and gets them. Neither use costs the other anything. Two decisions are arithmetic rather than taste, and both come from the machine having no multiply. A map row is a page whether the mode fills it or not, so a cell address is the row number as the high byte and the doubled column as the low byte with no arithmetic at all; otherwise every cursor move on a 40 column screen would cost a row-times-40 in software. And a palette entry is four bytes rather than three, so entry n is at n times four, a shift. THE MAP IS A RING and the Scroll register says which of its 128 rows is on top. Scrolling moves a register and no memory: blitting a 40 by 25 screen up one line is 1,920 bytes inside one bank, which is twelve percent of a frame even with the controller widened, and a program printing one page would spend six frames shuffling memory. It is now one port write - and the rows that scrolled off are still there, which is where a terminal gets scrollback it never had. The device is part of the machine rather than part of the window. It renders into a buffer that is a pure function of video memory, so the same program draws the same picture with nobody watching; Voyager puts that buffer on the glass and decides nothing. Both binaries take --screen, which saves a PPM when the machine stops, and that is what makes a screen checkable on a host with no display at all. Tests/video.sh checks fourteen named behaviours rather than comparing a recorded image, because 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. Verified by breaking three things in turn: the additive nibble failed exactly one check, the scroll origin exactly two, and moving every cell one pixel sideways exactly the four about placement. Tests/docs.sh could not count past nine, which is how a suite of ten scripts reported itself as wrong for the wrong reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
249 lines
9.2 KiB
Bash
Executable File
249 lines
9.2 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
|
|
}
|
|
|
|
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'
|
|
}
|
|
|
|
# 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."
|
|
|
|
# ---- 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"
|
|
|
|
echo
|
|
if [ "$FAIL" -eq 0 ]; then
|
|
echo "All $PASS video checks passed."
|
|
exit 0
|
|
fi
|
|
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
|
|
exit 1
|