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
1203 lines
55 KiB
Bash
Executable File
1203 lines
55 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 ; The atlas - tiles and palette - becomes bank 3
|
|
INIA 0d4
|
|
OUTA 0xE3
|
|
INIA 0x3A
|
|
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 ----
|
|
#
|
|
# 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.
|
|
pokeAtlas 0xFC00 0x00; pokeAtlas 0xFC01 0x00; pokeAtlas 0xFC02 0x00
|
|
}
|
|
|
|
# ---- Which memory, said and not guessed ----
|
|
#
|
|
# The screen is two banks, and an address alone cannot say which one it means: tile 5 and
|
|
# bitmap pixel 5 are both address 0x0005. So every write below names the memory it is for,
|
|
# and there is deliberately no bare poke that picks by address - a helper that guessed would
|
|
# be right for the tiles and wrong for a picture, silently.
|
|
pokeTo() {
|
|
# pokeTo <bank> <address> <byte>
|
|
printf ' INIA 0d%d\n OUTA 0xE3\n INIA 0x%02X\n OUTA 0xE4\n INIA 0x%02X\n OUTA 0xE5\n INIA 0x%02X\n OUTA 0xE9\n' \
|
|
"$1" $(( ($2 >> 8) & 0xFF )) $(( $2 & 0xFF )) $(( $3 & 0xFF ))
|
|
}
|
|
|
|
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
|
|
# one.
|
|
pokeAtlasRun() {
|
|
# pokeAtlasRun <address> <byte> <count>
|
|
printf ' INIA 0d3\n OUTA 0xE3\n INIA 0x%02X\n OUTA 0xE4\n INIA 0x%02X\n OUTA 0xE5\n INIA 0x%02X\n' \
|
|
$(( ($1 >> 8) & 0xFF )) $(( $1 & 0xFF )) $(( $2 & 0xFF ))
|
|
local i
|
|
for (( i = 0; i < $3; i++ )); do printf ' OUTA 0xE9\n'; done
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
# 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
|
|
}
|
|
|
|
# Waits, as a keyboard file: a zero is a moment of nobody typing, which is the commonest
|
|
# thing that happens behind a window and the only thing a file otherwise cannot say.
|
|
waiting() {
|
|
python3 -c "import sys; sys.stdout.buffer.write(b'\\x00' * int(sys.argv[1]) + b'x')" "$1" \
|
|
> "$BUILD/waits.keys"
|
|
echo "$BUILD/waits.keys"
|
|
}
|
|
|
|
# Assembles what is on standard input, runs it, and leaves the picture in $BUILD/<name>.ppm.
|
|
# A second argument names a keyboard file to feed it.
|
|
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; }
|
|
# ---- Bounded, the way run.sh bounds things ----
|
|
#
|
|
# A program here can WAIT for something that never comes, and one did: breaking the frame
|
|
# interrupt on purpose left a machine asleep for ever and took the whole suite with it,
|
|
# which is a worse way to be told than a failing check. Ten seconds, and a test that hangs
|
|
# says so instead of hanging.
|
|
if [ -n "${2:-}" ]; then
|
|
timeout 10 "$EMU" --fast --keyboard "$2" --screen "$BUILD/$name.ppm" \
|
|
"$BUILD/$name.bin" > "$BUILD/$name.out" 2>&1
|
|
else
|
|
timeout 10 "$EMU" --fast --screen "$BUILD/$name.ppm" "$BUILD/$name.bin" \
|
|
> "$BUILD/$name.out" 2>&1
|
|
fi
|
|
if [ $? -eq 124 ]; then
|
|
echo " $name did not finish within ten seconds"
|
|
fi
|
|
}
|
|
|
|
# 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
|
|
pokeAtlas 0xFC04 0xFF; pokeAtlas 0xFC05 0x00; pokeAtlas 0xFC06 0x00
|
|
for i in $(seq 0 63); do pokeAtlas $((0x0040 + i)) 0x01; done
|
|
pokeScreen 0x4000 0x01; pokeScreen 0x4001 0x00
|
|
pokeScreen $((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
|
|
pokeAtlas 0xFC04 0x00; pokeAtlas 0xFC05 0xFF; pokeAtlas 0xFC06 0x40
|
|
for i in $(seq 0 63); do pokeAtlas $((0x0040 + i)) 0x01; done
|
|
pokeScreen 0x4000 0x01; pokeScreen 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
|
|
pokeAtlas 0xFC04 0xFF; pokeAtlas 0xFC05 0x00; pokeAtlas 0xFC06 0x00
|
|
pokeAtlas 0xFC44 0x00; pokeAtlas 0xFC45 0x00; pokeAtlas 0xFC46 0xFF
|
|
for i in $(seq 0 63); do pokeAtlas $((0x0040 + i)) 0x01; done
|
|
pokeScreen 0x4000 0x01; pokeScreen 0x4001 0x00
|
|
pokeScreen 0x4002 0x01; pokeScreen 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
|
|
pokeAtlas 0xFC04 0xFF; pokeAtlas 0xFC05 0xFF; pokeAtlas 0xFC06 0x00
|
|
for i in $(seq 0 63); do pokeAtlas $((0x0040 + i)) 0x01; done
|
|
pokeScreen $((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
|
|
pokeAtlas 0xFC04 0xFF; pokeAtlas 0xFC05 0xFF; pokeAtlas 0xFC06 0xFF
|
|
for i in $(seq 0 63); do pokeAtlas $((0x0040 + i)) 0x01; done
|
|
pokeScreen 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)"
|
|
|
|
# ---- 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" ] \
|
|
&& 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)"
|
|
|
|
# ---- Blinking while the machine is stopped ----
|
|
#
|
|
# THE MACHINE IS NOT RUNNING while it waits for a key, and that is exactly when somebody is
|
|
# looking at the cursor. Time still has to reach the devices: a display controller does not
|
|
# stop blinking because the processor is waiting on a keyboard, any more than a disk stops
|
|
# turning. Waiting is charged as idle cycles and the devices are told as it happens, so the
|
|
# phase below is a pure function of how long nobody typed for.
|
|
#
|
|
# Key mode, so nothing is echoed and the cursor stays in the corner where it can be seen.
|
|
BLINKER='#Program
|
|
start:
|
|
INIA 0x05
|
|
OUTA 0x02
|
|
INA 0x00
|
|
HALT
|
|
#Vectors
|
|
Boot start'
|
|
|
|
echo "$BLINKER" | run blinkon "$(waiting 4)" || exit 1
|
|
coloured blinkon 0 0 "216,216,216" \
|
|
&& result ok "the cursor is lit while waiting" "sixty thousand cycles in" \
|
|
|| result no "the cursor is lit while waiting" "got $(pixel blinkon 0 0)"
|
|
|
|
echo "$BLINKER" | run blinkoff "$(waiting 40)" || exit 1
|
|
coloured blinkoff 0 0 "0,0,0" \
|
|
&& result ok "and dark half a second later" "the machine's clock, not the host's" \
|
|
|| result no "and dark half a second later" "got $(pixel blinkoff 0 0)"
|
|
|
|
echo "$BLINKER" | run blinkagain "$(waiting 70)" || exit 1
|
|
coloured blinkagain 0 0 "216,216,216" \
|
|
&& result ok "and lit again after that" "which is what blinking is" \
|
|
|| result no "and lit again after that" "got $(pixel blinkagain 0 0)"
|
|
|
|
# ---- A byte a pixel ----
|
|
#
|
|
# The other kind of screen. No tile to look up and no attribute to add: the byte IS the
|
|
# palette index, and it lives over the top of the tiles and the map, because 64,000 bytes of
|
|
# picture leaves room for nothing else in a 65,536 byte bank.
|
|
|
|
# Palette entry 5, then one pixel of it at row 2, column 3 - which is byte 2*320+3 = 643.
|
|
{ prologue
|
|
pokeAtlas 0xFC14 0x20; pokeAtlas 0xFC15 0xC0; pokeAtlas 0xFC16 0x90
|
|
pokeScreen 0x0283 0x05
|
|
port 0x31 0x02
|
|
epilogue
|
|
} | run bitmap || exit 1
|
|
[ "$(size bitmap)" = "320 200" ] \
|
|
&& result ok "bitmap mode is 320 by 200" "$(size bitmap)" \
|
|
|| result no "bitmap mode is 320 by 200" "got $(size bitmap)"
|
|
coloured bitmap 3 2 "32,192,144" \
|
|
&& result ok "and a byte is a pixel's colour" "byte 643 is row 2, column 3" \
|
|
|| result no "and a byte is a pixel's colour" "got $(pixel bitmap 3 2)"
|
|
coloured bitmap 4 2 "0,0,0" \
|
|
&& result ok "and only that pixel" "the one beside it is untouched" \
|
|
|| result no "and only that pixel" "got $(pixel bitmap 4 2)"
|
|
|
|
# ---- And the console keeps off it ----
|
|
#
|
|
# There is no character screen in bitmap mode, so there is nowhere to put a glyph. The
|
|
# alternative is what a machine with shared video memory really does, which is scribble on
|
|
# somebody's picture with marks nobody can read. It still says everything down the serial
|
|
# line, which is where it was going as well.
|
|
{ prologue
|
|
pokeAtlas 0xFC14 0x20; pokeAtlas 0xFC15 0xC0; pokeAtlas 0xFC16 0x90
|
|
pokeScreen 0x0283 0x05
|
|
port 0x31 0x02
|
|
say "A"
|
|
epilogue
|
|
} | run bitmaptext || exit 1
|
|
coloured bitmaptext 3 2 "32,192,144" \
|
|
&& result ok "printing does not touch a bitmap" "the pixel survived a letter" \
|
|
|| result no "printing does not touch a bitmap" "got $(pixel bitmaptext 3 2)"
|
|
[ "$(said bitmaptext)" = "65" ] \
|
|
&& result ok "and the letter still goes out" "down the serial line" \
|
|
|| result no "and the letter still goes out" "got $(said bitmaptext)"
|
|
|
|
# Asking how many columns there are in bitmap mode is asking about something that is not
|
|
# there, and nought is the true answer rather than a leftover from the last mode.
|
|
{ prologue; port 0x31 0x02; show 0x32; port 0x31 0x00; show 0x32; epilogue
|
|
} | run bitmapsize || exit 1
|
|
[ "$(said bitmapsize)" = "0 40" ] \
|
|
&& result ok "a bitmap has no columns" "and forty again when it is text" \
|
|
|| result no "a bitmap has no columns" "got $(said bitmapsize)"
|
|
|
|
# ---- The example that draws one, run as it ships ----
|
|
#
|
|
# Everything above builds its program here, which means every check above passes on an
|
|
# emulator whose two banks are wired up EXACTLY the way this file assumes. picture.asm is the
|
|
# thing somebody reads to learn how to draw, and nothing ran it.
|
|
#
|
|
# That is not hypothetical. Splitting video memory into two banks broke this program and no
|
|
# check noticed, because registering the second bank leaves DestBank pointing at it - so the
|
|
# palette went into the screen instead of the atlas and the picture came out black.
|
|
#
|
|
# What is checked is the gradient the program's own comment promises: two hundred rows, each
|
|
# one colour, running blue to white to yellow. A blank screen has one colour and a picture
|
|
# drawn with the wrong palette has a handful, so counting them catches both.
|
|
"$ASM" -I "$ROOT/Programs/Libraries" "$ROOT/Programs/Examples/picture.asm" \
|
|
-o "$BUILD/picture.bin" > "$BUILD/picture.log" 2>&1
|
|
timeout 30 "$EMU" --fast --cycles 5000000 --screen "$BUILD/picture.ppm" \
|
|
"$BUILD/picture.bin" > "$BUILD/picture.out" 2>&1 || true
|
|
SHADES="$(python3 -c "
|
|
d = open('$BUILD/picture.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)"
|
|
[ "$SHADES" = "200" ] \
|
|
&& result ok "the example draws its picture" "two hundred rows, two hundred colours" \
|
|
|| result no "the example draws its picture" "$SHADES colours, not 200"
|
|
[ "$(pixel picture 10 0)" = "0,0,255" ] && [ "$(pixel picture 10 199)" = "199,199,56" ] \
|
|
&& result ok "and it runs blue to yellow" "the palette is in the atlas, where it belongs" \
|
|
|| result no "and it runs blue to yellow" "top $(pixel picture 10 0), bottom $(pixel picture 10 199)"
|
|
|
|
# ---- The frame, which is the only beat this machine has ----
|
|
#
|
|
# There is no clock. Every program that wanted to happen at a certain speed has until now
|
|
# counted instructions and hoped, which is why Snake's pause silently halved when a cycle
|
|
# stopped being an instruction. A screen finishing sixty times a second is a real one, and it
|
|
# arrives on the MACHINE'S clock, so the same program sees the same number of frames in the
|
|
# same number of cycles however fast the host really went.
|
|
|
|
FRAMER='#Program
|
|
start:
|
|
SETD.0 Frames
|
|
RSTA
|
|
STA.0
|
|
INIA 0x01
|
|
OUTA 0x35
|
|
SIF
|
|
loop:
|
|
WAIT
|
|
SETD.0 Frames
|
|
LDA.0
|
|
INIB 0d10
|
|
CCF
|
|
SUB
|
|
BRQ done
|
|
BRI loop
|
|
done:
|
|
SETD.0 Frames
|
|
LDA.0
|
|
INIB 0d48
|
|
CCF
|
|
ADD
|
|
MVQA
|
|
OUTA 0x00
|
|
HALT
|
|
frame:
|
|
SETD.0 Frames
|
|
LDA.0
|
|
INCA
|
|
STA.0
|
|
RETI
|
|
#Data
|
|
Frames:
|
|
0x00
|
|
#Vectors
|
|
Boot start
|
|
Device 0x30 frame'
|
|
|
|
echo "$FRAMER" | run frames || exit 1
|
|
[ "$(said frames)" = "58" ] \
|
|
&& result ok "the screen interrupts once a frame" "ten of them, counted" \
|
|
|| result no "the screen interrupts once a frame" "got $(said frames)"
|
|
|
|
# ---- And the machine was ASLEEP for them ----
|
|
#
|
|
# Which is the whole point of having a frame to wait for, and the one thing the picture
|
|
# cannot show. Ten frames is 166,670 cycles and the program does a few hundred cycles of work
|
|
# in them; a machine spinning on the status port instead would show the same characters, take
|
|
# the same time, and spend every cycle of it on the bus.
|
|
IDLE="$(grep -oE '[0-9]+ of them waiting' "$BUILD/frames.out" | grep -oE '^[0-9]+')"
|
|
TOTAL="$(grep -oE 'after [0-9]+' "$BUILD/frames.out" | grep -oE '[0-9]+')"
|
|
[ -n "$IDLE" ] && [ "$IDLE" -gt $(( TOTAL - TOTAL / 50 )) ] \
|
|
&& result ok "and slept through nearly all of it" "$IDLE of $TOTAL cycles idle" \
|
|
|| result no "and slept through nearly all of it" "$IDLE of $TOTAL cycles idle"
|
|
|
|
# Nothing is asked for, so nothing arrives - and that matters more than it sounds. An
|
|
# interrupt with no handler installed is a fault, so a screen that interrupted whether or not
|
|
# it was asked would take down every program written before frames existed.
|
|
UNARMED='#Program
|
|
start:
|
|
SIF
|
|
INIA 0d100
|
|
spin:
|
|
DECA
|
|
BNA spin
|
|
INIA 0d65
|
|
OUTA 0x00
|
|
HALT
|
|
#Vectors
|
|
Boot start'
|
|
echo "$UNARMED" | run unarmed || exit 1
|
|
[ "$(said unarmed)" = "65" ] \
|
|
&& result ok "and none arrives unless asked for" "no handler, no fault" \
|
|
|| result no "and none arrives unless asked for" "got $(said unarmed)"
|
|
|
|
# A program with no handler can watch for the frame instead, the way one can poll the console
|
|
# rather than being interrupted by it.
|
|
POLLER='#Program
|
|
start:
|
|
SETD.0 Seen
|
|
RSTA
|
|
STA.0
|
|
poll:
|
|
INA 0x30
|
|
INIB 0x01
|
|
AND
|
|
BRQ poll
|
|
SETD.0 Seen
|
|
LDA.0
|
|
INCA
|
|
STA.0
|
|
INIB 0d3
|
|
CCF
|
|
SUB
|
|
BNQ poll
|
|
SETD.0 Seen
|
|
LDA.0
|
|
INIB 0d48
|
|
CCF
|
|
ADD
|
|
MVQA
|
|
OUTA 0x00
|
|
HALT
|
|
#Data
|
|
Seen:
|
|
0x00
|
|
#Vectors
|
|
Boot start'
|
|
echo "$POLLER" | run poller || exit 1
|
|
[ "$(said poller)" = "51" ] \
|
|
&& result ok "or watch for it without one" "three frames, polled" \
|
|
|| result no "or watch for it without one" "got $(said poller)"
|
|
|
|
# ---- And looking is what answers it ----
|
|
#
|
|
# Three frames polled have to have TAKEN three frames. A flag that stayed up once it was
|
|
# first set would let this loop through all three without a frame going by, print exactly the
|
|
# same character, and look perfectly correct - so the count is not the check, the clock is.
|
|
TOTAL="$(grep -oE 'after [0-9]+' "$BUILD/poller.out" | grep -oE '[0-9]+')"
|
|
[ "$TOTAL" -gt 33334 ] \
|
|
&& result ok "and the flag comes down when looked at" "$TOTAL cycles, so three frames passed" \
|
|
|| result no "and the flag comes down when looked at" "$TOTAL cycles, too few to be three frames"
|
|
|
|
# ---- Scrolling by less than a cell, and sideways ----
|
|
#
|
|
# A red tile in the corner and nowhere else, so that where it lands says exactly what the
|
|
# scroll registers did. Every check below is the SAME program with one register changed, and
|
|
# what is compared is where the red stops.
|
|
scrollSetup() {
|
|
prologue
|
|
pokeAtlas 0xFC04 0xFF; pokeAtlas 0xFC05 0x00; pokeAtlas 0xFC06 0x00
|
|
for i in $(seq 0 63); do pokeAtlas $((0x0040 + i)) 0x01; done
|
|
pokeScreen 0x4000 0x01; pokeScreen 0x4001 0x00
|
|
}
|
|
|
|
# Where it is with nothing scrolled: the red runs from 0 to 7 and stops.
|
|
{ scrollSetup; epilogue; } | run scroll0 || exit 1
|
|
[ "$(pixel scroll0 7 0)" = "255,0,0" ] && [ "$(pixel scroll0 8 0)" != "255,0,0" ] \
|
|
&& result ok "the tile ends at the cell edge" "red from 0 to 7" \
|
|
|| result no "the tile ends at the cell edge" "7 is $(pixel scroll0 7 0), 8 is $(pixel scroll0 8 0)"
|
|
|
|
# One pixel of fine X moves the picture one pixel LEFT: the view slides right, so the red
|
|
# now ends at 6. One pixel, not eight, is the whole point of the register.
|
|
{ scrollSetup; port 0x37 0x01; epilogue; } | run scrollfx || exit 1
|
|
[ "$(pixel scrollfx 6 0)" = "255,0,0" ] && [ "$(pixel scrollfx 7 0)" != "255,0,0" ] \
|
|
&& result ok "fine X moves it one pixel" "the edge went from 7 to 6" \
|
|
|| result no "fine X moves it one pixel" "6 is $(pixel scrollfx 6 0), 7 is $(pixel scrollfx 7 0)"
|
|
|
|
{ scrollSetup; port 0x38 0x01; epilogue; } | run scrollfy || exit 1
|
|
[ "$(pixel scrollfy 0 6)" = "255,0,0" ] && [ "$(pixel scrollfy 0 7)" != "255,0,0" ] \
|
|
&& result ok "and fine Y moves it one pixel" "the edge went from 7 to 6" \
|
|
|| result no "and fine Y moves it one pixel" "6 is $(pixel scrollfy 0 6), 7 is $(pixel scrollfy 0 7)"
|
|
|
|
# Seven is as far as it goes. Eight is zero again and NOT one cell along, which is what "it
|
|
# does not carry" means where a program can see it.
|
|
{ scrollSetup; port 0x37 0x08; epilogue; } | run scrollwrap || exit 1
|
|
[ "$(pixel scrollwrap 7 0)" = "255,0,0" ] && [ "$(pixel scrollwrap 8 0)" != "255,0,0" ] \
|
|
&& result ok "eight of fine is none of it" "the low three bits, and no carry" \
|
|
|| result no "eight of fine is none of it" "7 is $(pixel scrollwrap 7 0)"
|
|
|
|
# Coarse X moves a whole cell. With the column origin at 1 the corner cell is off the left
|
|
# and cell 1 of the map is where the screen starts - so the corner is no longer red.
|
|
{ scrollSetup; pokeScreen $((0x4000 + 2)) 0x01; port 0x36 0x01; epilogue; } | run scrollcx || exit 1
|
|
[ "$(pixel scrollcx 0 0)" = "255,0,0" ] && [ "$(pixel scrollcx 8 0)" != "255,0,0" ] \
|
|
&& result ok "coarse X moves a whole cell" "the map moved one cell left" \
|
|
|| result no "coarse X moves a whole cell" "0 is $(pixel scrollcx 0 0), 8 is $(pixel scrollcx 8 0)"
|
|
|
|
# And it is a ring, the same as the rows are. Column 127 is the last one a map row has, so
|
|
# an origin there puts it on screen with column 0 beside it.
|
|
{ scrollSetup; pokeScreen $((0x4000 + 127 * 2)) 0x01; port 0x36 0x7F; epilogue; } | run scrollwrapx || exit 1
|
|
[ "$(pixel scrollwrapx 0 0)" = "255,0,0" ] && [ "$(pixel scrollwrapx 8 0)" = "255,0,0" ] \
|
|
&& result ok "the columns are a ring too" "127 on screen with 0 beside it" \
|
|
|| result no "the columns are a ring too" "0 is $(pixel scrollwrapx 0 0), 8 is $(pixel scrollwrapx 8 0)"
|
|
|
|
# ---- And the console follows the column origin ----
|
|
#
|
|
# It has always followed the row origin, which is where its scrollback comes from. A letter
|
|
# written while the view is scrolled sideways has to land where the writer meant - on the
|
|
# screen - and not at the map cell that happens to share its number.
|
|
{ printf '#Program\nstart:\n'
|
|
port 0x36 0x03
|
|
say "A"
|
|
epilogue
|
|
} | run scrollconsole || exit 1
|
|
inked scrollconsole 2 1 \
|
|
&& result ok "the console writes where it means to" "the letter is in the first cell of the screen" \
|
|
|| result no "the console writes where it means to" "nothing at 2,1"
|
|
|
|
# ---- The tile engine, driven by a program rather than by the console ----
|
|
#
|
|
# Everything above drives the screen from a bare test program. This boots the whole system
|
|
# and runs Grid.sbx on it, because Grid is the first thing that uses the engine as an engine:
|
|
# it redefines a tile above the font, fills all 128 map rows, and scrolls by moving the
|
|
# origin. What is checked is what came out of the renderer, not what the program believed.
|
|
#
|
|
# The keyboard file is what makes it possible to catch it MID-SCROLL: "Grid" and a return,
|
|
# then a long silence, so the machine is still running when the cycle limit stops it and the
|
|
# picture is taken.
|
|
"$ASM" -I "$ROOT/Programs/CosmOS/Source" "$ROOT/Programs/CosmOS/Source/cosmos.asm" \
|
|
-o "$BUILD/cosmos.bin" > "$BUILD/cosmos.log" 2>&1
|
|
python3 -c "open('$BUILD/grid.keys','wb').write(b'Grid\n' + b'\x00'*4000)"
|
|
timeout 30 "$EMU" --fast --cycles 8000000 --keyboard "$BUILD/grid.keys" \
|
|
--screen "$BUILD/grid.ppm" --disk "$ROOT/Tests/build/disks/cosmos.img" \
|
|
"$BUILD/cosmos.bin" > "$BUILD/grid.out" 2>&1 || true
|
|
|
|
if [ -f "$BUILD/grid.ppm" ]; then
|
|
# ---- Asked in a way that a moving picture can answer ----
|
|
#
|
|
# Not "is pixel 0 a line and pixel 4 the ground", which was the first version and was
|
|
# really a check that the scroll happened to be at a cell boundary. Grid now moves a pixel
|
|
# a frame, so where the lines are depends on which frame this is - but a grid of one tile
|
|
# is PERIODIC whatever the offset: every pixel matches the one eight along. And it is not
|
|
# all one colour, or a blank screen would pass.
|
|
GRIDLIKE="$(python3 "$ROOT/Tests/periodic.py" "$BUILD/grid.ppm" grid)"
|
|
[ "$GRIDLIKE" = "yes" ] \
|
|
&& result ok "a program drew a grid of its own tile" "the picture repeats every eight pixels" \
|
|
|| result no "a program drew a grid of its own tile" "not a grid of one tile ($GRIDLIKE)"
|
|
|
|
# ---- And still a grid once it has scrolled off the filled part ----
|
|
#
|
|
# A map row holds 128 cells and an eighty column screen shows eighty of them, so a
|
|
# program that fills what the SCREEN is wide leaves 48 columns empty - and scrolling
|
|
# sideways walks into them. The grid went blank for six seconds and came back. Twenty
|
|
# million cycles is well past where that happened.
|
|
timeout 30 "$EMU" --fast --cycles 20000000 --keyboard "$BUILD/grid.keys" \
|
|
--screen "$BUILD/gridfar.ppm" --disk "$ROOT/Tests/build/disks/cosmos.img" \
|
|
"$BUILD/cosmos.bin" > "$BUILD/gridfar.out" 2>&1 || true
|
|
FARGRID="$(python3 "$ROOT/Tests/periodic.py" "$BUILD/gridfar.ppm" grid)"
|
|
[ "$FARGRID" = "yes" ] \
|
|
&& result ok "and is still one after scrolling a long way" "no gap where the map ran out" \
|
|
|| result no "and is still one after scrolling a long way" "$FARGRID"
|
|
|
|
# The attribute nibble adds sixteen to every index in the tile, so consecutive map rows
|
|
# come out in consecutive schemes. Eight pixels apart is one cell row apart whatever the
|
|
# fine offset is, so this one survives the scrolling too.
|
|
BANDED="$(python3 "$ROOT/Tests/periodic.py" "$BUILD/grid.ppm" bands)"
|
|
[ "$BANDED" = "yes" ] \
|
|
&& result ok "the attribute nibble recolours it" "each cell row is its own scheme" \
|
|
|| result no "the attribute nibble recolours it" "$BANDED"
|
|
else
|
|
result no "a program drew a grid of its own tile" "no picture came out"
|
|
fi
|
|
|
|
# ---- A program gives the screen back ----
|
|
#
|
|
# Grid takes the whole screen: it redefines a tile, writes all sixteen colour schemes over the
|
|
# console's own, and fills every cell of the map. Then it asks the system for what was there
|
|
# before, and the system has somewhere to put it because the machine has a drive made of
|
|
# memory.
|
|
#
|
|
# WHAT IS COMPARED IS THE SCREEN BEFORE AGAINST THE SCREEN AFTER, cell by cell. Checking that
|
|
# it merely looks like text would pass on a restore that put back somebody else's text, and
|
|
# checking a few pixels would pass on one that got the palette right and the map wrong.
|
|
python3 -c "open('$BUILD/before.keys','wb').write(b'dir\n' + b'Say a line to come back to\n' + b'\x00'*200)"
|
|
python3 -c "open('$BUILD/after.keys','wb').write(b'dir\n' + b'Say a line to come back to\n' + b'Grid\n' + b'\x00'*600 + b'q' + b'\x00'*200)"
|
|
for phase in before after; 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
|
|
SAME="$(python3 "$ROOT/Tests/samescreen.py" "$BUILD/before.ppm" "$BUILD/after.ppm")"
|
|
[ "$SAME" = "yes" ] \
|
|
&& result ok "a program gives the screen back" "every row it did not write on is as it was" \
|
|
|| result no "a program gives the screen back" "$SAME"
|
|
|
|
# ---- And when there is nowhere to put it ----
|
|
#
|
|
# The same program on a machine with no volatile drive. osTakeScreen answers no, and a program
|
|
# told no does what it did before there was anywhere to save a screen: it clears up after
|
|
# itself. What must NOT happen is the shell printing its prompt into somebody's grid, which is
|
|
# what happened the day the run targets had no scratch drive and this check did not exist.
|
|
timeout 30 "$EMU" --fast --cycles 200000000 --keyboard "$BUILD/after.keys" \
|
|
--screen "$BUILD/noscratch.ppm" --disk "$ROOT/Tests/build/disks/cosmos.img" \
|
|
"$BUILD/cosmos.bin" > "$BUILD/noscratch.out" 2>&1 || true
|
|
LEFT="$(python3 "$ROOT/Tests/periodic.py" "$BUILD/noscratch.ppm" grid)"
|
|
[ "$LEFT" != "yes" ] \
|
|
&& 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
|
|
# writes three lines, clears, and writes one letter: it has to land in the very first cell.
|
|
# Before the cursor was homed it landed on the fourth row, on a screen that no longer had
|
|
# anything on the first three to justify it.
|
|
{ printf '#Program\nstart:\n'
|
|
say "AAA"; emit 10
|
|
say "AAA"; emit 10
|
|
say "AAA"; emit 10
|
|
port 0x05 0x01
|
|
say "X"
|
|
epilogue
|
|
} | run clearhome || exit 1
|
|
inked clearhome 2 1 \
|
|
&& result ok "clearing puts the cursor home" "the next letter landed in the first cell" \
|
|
|| result no "clearing puts the cursor home" "nothing at 2,1"
|
|
# And the three lines really are gone, so the check above is about the cursor rather than
|
|
# about a clear that did nothing.
|
|
# The SECOND row, which nothing writes to either way - so this fails when the clear did not
|
|
# clear and passes whether or not the cursor was homed. Pointed at the fourth row it failed
|
|
# for the same reason as the check above, which is a second check that says nothing.
|
|
papered clearhome 2 9 \
|
|
&& result ok "and the screen really was cleared" "the second row is empty" \
|
|
|| result no "and the screen really was cleared" "there is still ink on the second row"
|
|
|
|
# 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"
|
|
|
|
# ---- The character generator is a chip, not a memory that remembers ----
|
|
#
|
|
# The font and the sixteen schemes used to be written into video RAM at reset and existed
|
|
# nowhere else, so a program that overwrote a glyph had destroyed the only copy. They come
|
|
# from a ROM in the device now, and the Command port asks for either back.
|
|
#
|
|
# TWO RUNS RATHER THAN ONE PICTURE, because the map holds a tile NUMBER and the glyph is
|
|
# looked up when the frame is drawn - so restoring the font changes every cell using it,
|
|
# including the ones drawn before. What the two runs differ by is the command.
|
|
#
|
|
# The glyph for 'A' is filled with ink, which makes the cell a solid block, so the top left
|
|
# pixel of it is ink where a real 'A' has paper. That is a pixel no font disagrees about.
|
|
{ prologue
|
|
pokeAtlasRun 0x0840 0x01 64 # Tile 33, which is 'A', every pixel ink.
|
|
say "A"
|
|
epilogue
|
|
} | run fontwrecked || exit 1
|
|
[ "$(pixel fontwrecked 0 0)" = "216,216,216" ] \
|
|
&& result ok "a program can overwrite a glyph" "the wrecked A is a solid block" \
|
|
|| result no "a program can overwrite a glyph" "not ink at 0,0"
|
|
|
|
{ prologue
|
|
pokeAtlasRun 0x0840 0x01 64
|
|
port 0x39 0x01 # And ask the character generator for it back.
|
|
say "A"
|
|
epilogue
|
|
} | run fontback || exit 1
|
|
[ "$(pixel fontback 0 0)" = "0,0,0" ] \
|
|
&& result ok "and ask the device for it back" "the A has its own shape again" \
|
|
|| result no "and ask the device for it back" "still ink at 0,0"
|
|
|
|
# ---- And asking does not cost a program the tiles it defined ----
|
|
#
|
|
# The font used to clear the whole of tile memory before writing itself, which was harmless
|
|
# while it only happened at reset and is wrong the moment a program can ask for it: a program
|
|
# that defined a tile of its own and then wanted its text back would have paid for it with
|
|
# the tile. It writes the glyphs it has and stops.
|
|
{ prologue
|
|
pokeAtlasRun 0x3200 0x01 64 # Tile 200, well above anything the font occupies.
|
|
pokeScreen 0x4000 0xC8 # And that tile in the first cell of the map.
|
|
pokeScreen 0x4001 0x00
|
|
port 0x39 0x03 # Both the font and the palette back.
|
|
epilogue
|
|
} | run fontkeeps || exit 1
|
|
[ "$(pixel fontkeeps 0 0)" = "216,216,216" ] \
|
|
&& result ok "and leaves a program's own tiles alone" "tile 200 survived the font coming back" \
|
|
|| result no "and leaves a program's own tiles alone" "tile 200 was cleared"
|
|
|
|
# ---- The palette the same way ----
|
|
#
|
|
# Ink and paper made the same colour is a screen with writing on it that cannot be read,
|
|
# which is exactly what the fault screen has to survive. The device is asked for the sixteen
|
|
# schemes back and the writing returns.
|
|
{ prologue
|
|
pokeAtlas 0xFC04 0x00; pokeAtlas 0xFC05 0x00; pokeAtlas 0xFC06 0x00 # Scheme 0's ink, made black.
|
|
say "A"
|
|
epilogue
|
|
} | run inkwrecked || exit 1
|
|
[ "$(pixel inkwrecked 3 1)" = "0,0,0" ] \
|
|
&& result ok "a program can overwrite a scheme" "ink and paper are the same colour" \
|
|
|| result no "a program can overwrite a scheme" "the A is still visible"
|
|
|
|
{ prologue
|
|
pokeAtlas 0xFC04 0x00; pokeAtlas 0xFC05 0x00; pokeAtlas 0xFC06 0x00
|
|
port 0x39 0x02 # The schemes back, and only the schemes.
|
|
say "A"
|
|
epilogue
|
|
} | run inkback || exit 1
|
|
[ "$(pixel inkback 3 1)" = "216,216,216" ] \
|
|
&& result ok "and ask the device for those back too" "the A can be read again" \
|
|
|| result no "and ask the device for those back too" "still nothing at 3,1"
|
|
|
|
# ---- The fault screen, from a screen with nowhere to print on it ----
|
|
#
|
|
# Every other test of the fault screen reads what came down the serial line, and the serial
|
|
# line is not where the problem was: a program that faulted in BITMAP MODE left the console
|
|
# with no text rows, so it drew nothing at all, and the machine looked hung while it was
|
|
# merely unable to say so. What has to be checked is the PICTURE.
|
|
#
|
|
# Two things about it, and both matter. The picture is 640 by 400, which is the eighty column
|
|
# text mode - so the screen really was put back, from a mode that has no characters in it.
|
|
# And it holds the fault red, which says the message was drawn and drawn in a colour that can
|
|
# be read whatever palette the program had left behind.
|
|
python3 -c "open('$BUILD/blind.keys','wb').write(b'Crash blind\n' + b'\x00'*400)"
|
|
timeout 30 "$EMU" --fast --cycles 8000000 --keyboard "$BUILD/blind.keys" \
|
|
--screen "$BUILD/blind.ppm" --disk "$ROOT/Tests/build/disks/cosmos.img" \
|
|
"$BUILD/cosmos.bin" > "$BUILD/blind.out" 2>&1 || true
|
|
|
|
if [ -f "$BUILD/blind.ppm" ]; then
|
|
SIZE="$(head -c 20 "$BUILD/blind.ppm" | sed -n '2p')"
|
|
[ "$SIZE" = "640 400" ] \
|
|
&& result ok "a fault puts the screen back where text can be seen" "eighty columns again, from bitmap mode" \
|
|
|| result no "a fault puts the screen back where text can be seen" "the picture is $SIZE"
|
|
|
|
REDDISH="$(python3 - "$BUILD/blind.ppm" <<'PY2'
|
|
import sys
|
|
data = open(sys.argv[1], "rb").read()
|
|
parts = data.split(b"\n", 3)
|
|
pixels = parts[3]
|
|
# The red the machine wakes up with, which is the one the fault screen writes into the
|
|
# entries attribute one draws from. Counted rather than looked for once, so a single stray
|
|
# pixel of it could not pass for a message.
|
|
red = sum(1 for i in range(0, len(pixels) - 2, 3)
|
|
if (pixels[i], pixels[i + 1], pixels[i + 2]) == (0xD0, 0x40, 0x38))
|
|
print("yes" if red > 200 else "only %d red pixels" % red)
|
|
PY2
|
|
)"
|
|
[ "$REDDISH" = "yes" ] \
|
|
&& result ok "and says what happened in a colour that can be read" "the message is drawn in the fault red" \
|
|
|| result no "and says what happened in a colour that can be read" "$REDDISH"
|
|
else
|
|
result no "a fault puts the screen back where text can be seen" "no picture was written"
|
|
fi
|
|
|
|
echo
|
|
if [ "$FAIL" -eq 0 ]; then
|
|
echo "All $PASS video checks passed."
|
|
exit 0
|
|
fi
|
|
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
|
|
exit 1
|