Files
SplitBit-Emulator/Tests/video.sh
T
AnachronautandClaude Opus 5 a069ee7a00 Orbit, and two bars that answer instead of reporting
Going sideways lifts the moon off you. Not because gravity weakened -
because at speed the surface falls away underneath as fast as the lander
falls towards it, which is what an orbit is.

A GRADIENT OUT OF INTEGER ARITHMETIC. Gravity is one sixteenth of a pixel
a tick and there is nothing between that and nothing, so it cannot be
scaled down. Instead four times the sideways speed goes into a byte every
tick and the tick's gravity is skipped whenever that byte carries: the
fraction cancelled is the speed over 64, smoothly, with no multiply and no
divide. At four pixels a frame it carries every time. That is the linear
approximation; the honest one is the square, and wants a table.

It did nothing at all for its first two versions. Once because the relief
was a 256th a tick, so orbit wanted a speed no lander would reach; and
once because A IS THE HIGH HALF of the shift register, so multiplying by
four left the answer in A while the code read B, which is nought. The same
trap as the scroll register and the pixel conversion before it.

And a bar for the vertical speed beside the one for drift, both GREEN
WHILE A LANDING WOULD SURVIVE AND RED WHILE IT WOULD NOT. That turns two
numbers into one question - can I put down - and answers it at a glance.

WHAT THIS COST: the flown delivery check. A recording is a list of buttons
and not a flight, so replaying it under different gravity flies somewhere
else; the delivery became a crash two columns short. The fixture is still
there and is still a faithful record of what somebody did, and is no
longer a record of what happens.

That is the standing cost of a flown fixture, and it is worse than the
transcript tests dropped earlier: those broke when an output moved, and
this breaks whenever a NUMBER moves. Making the delivery reachable without
flying - a way to start already carrying, or at a chosen base - is what
would fix it properly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-09-03 16:25:56 -04:00

2105 lines
101 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
# ---- The fixture disks, which this suite reads and does not build ----
#
# Everything that boots CosmOS below runs off Tests/build/disks/cosmos.img, and that is made
# by run.sh rather than here. When it is absent - after make sanitize, which clears the build
# directory - the emulator has no disk, seven checks find no picture, and the run reports
# SEVEN PRODUCT FAILURES for a missing fixture. That is the worst kind of red: it looks
# exactly like something broke.
#
# So the disks are built if they are not there, and this says so rather than limping on.
if [ ! -f "$ROOT/Tests/build/disks/cosmos.img" ]; then
echo "The fixture disks are not built; building them."
"$ROOT/Tests/makedisks.sh" "$ROOT/Tests/build" > /dev/null \
|| { echo "Couldn't build the test disks."; exit 1; }
fi
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.
# The colour most of a picture is made of. Asked this way rather than by naming a pixel,
# because a filled screen is a PATTERN - the font has one blank glyph and it is the space,
# whose attribute nibble is nought - and which pixel lands on paper depends on the shape of
# whichever character was filled with.
commonest() {
# commonest <ppm>
python3 -c "
import collections, sys
d = open(sys.argv[1], 'rb').read()
px = d[d.index(b'255\n') + 4:]
counts = collections.Counter(px[o:o + 3] for o in range(0, len(px), 3))
print(counts.most_common(1)[0][0].hex())
" "$1" 2>/dev/null || echo none
}
# How many pixels of one exact colour a picture has. What a sprite is counted by: it is a
# shape rather than a screenful, so the commonest colour says nothing about it.
countColour() {
# countColour <ppm> <rrggbb>
python3 -c "
import sys
d = open(sys.argv[1], 'rb').read()
px = d[d.index(b'255\n') + 4:]
want = bytes.fromhex(sys.argv[2])
print(sum(1 for o in range(0, len(px), 3) if px[o:o + 3] == want))
" "$1" "$2" 2>/dev/null || echo -1
}
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, the palette and the sprite table.
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
}
# One entry of the sprite table, which is sixteen bytes at 0xC000 plus sixteen times its
# number. X and Y are signed and go in low byte first, so a negative one is written as its
# two's complement here rather than being worked out at every call.
#
# The last eight bytes are left alone, and mean what a cleared table means: natural size and
# no depth test. spriteSize below is what fills them in.
spriteAt() {
# spriteAt <n> <tile> <attribute> <x> <y> <size> <flags>
local base=$(( 0xC000 + $1 * 16 ))
local x=$(( $4 & 0xFFFF ))
local y=$(( $5 & 0xFFFF ))
pokeAtlas "$base" "$2"
pokeAtlas "$(( base + 1 ))" "$3"
pokeAtlas "$(( base + 2 ))" "$(( x & 0xFF ))"
pokeAtlas "$(( base + 3 ))" "$(( (x >> 8) & 0xFF ))"
pokeAtlas "$(( base + 4 ))" "$(( y & 0xFF ))"
pokeAtlas "$(( base + 5 ))" "$(( (y >> 8) & 0xFF ))"
pokeAtlas "$(( base + 6 ))" "$6"
pokeAtlas "$(( base + 7 ))" "$7"
}
# How big a sprite is to be drawn, in pixels, and how far away it is. Nought either way means
# what nought means to the device: natural size, and no depth test.
spriteSize() {
# spriteSize <n> <width> <height> [depth]
local base=$(( 0xC000 + $1 * 16 ))
pokeAtlas "$(( base + 8 ))" "$(( $2 & 0xFF ))"
pokeAtlas "$(( base + 9 ))" "$(( ($2 >> 8) & 0xFF ))"
pokeAtlas "$(( base + 10 ))" "$(( $3 & 0xFF ))"
pokeAtlas "$(( base + 11 ))" "$(( ($3 >> 8) & 0xFF ))"
pokeAtlas "$(( base + 12 ))" "$(( ${4:-0} & 0xFF ))"
}
# One column of the depth buffer, which is one byte a screen column at 0xD000. Nought means
# nothing is in that column, which is what a program that never writes it says everywhere.
depthAt() {
# depthAt <column> <depth>
pokeAtlas "$(( 0xD000 + $1 ))" "$2"
}
# Every pixel of one tile the same index. Tile n begins at n times 64.
solidTile() {
# solidTile <tile> <index>
pokeAtlasRun "$(( $1 * 64 ))" "$2" 64
}
# The same, in one of the four pages. Page p begins at p times 16K, so a tile number means a
# different 64 bytes in each of them.
pageTile() {
# pageTile <page> <tile> <index>
pokeAtlasRun "$(( $1 * 0x4000 + $2 * 64 ))" "$3" 64
}
# One cell of the window layer, which is 0xC000 in the screen bank and a page a row, exactly
# as the map is - the same cells, and never anywhere near the map's own rows.
pokeWindow() {
# pokeWindow <row> <column> <tile> <attribute>
local at=$(( 0xC000 + $1 * 256 + $2 * 2 ))
pokeScreen "$at" "$3"
pokeScreen "$(( at + 1 ))" "$4"
}
# The top half one index and the bottom half another, which is how a tile gets a hole in it:
# index nought is what a sprite does not draw.
halfTile() {
# halfTile <tile> <top> <bottom>
pokeAtlasRun "$(( $1 * 64 ))" "$2" 32
pokeAtlasRun "$(( $1 * 64 + 32 ))" "$3" 32
}
# Four colours to tell tiles apart by, and the pair a background cell in scheme one uses.
spriteColours() {
pokeAtlas 0xFC04 0xFF; pokeAtlas 0xFC05 0x00; pokeAtlas 0xFC06 0x00 # 1 red
pokeAtlas 0xFC08 0x00; pokeAtlas 0xFC09 0xFF; pokeAtlas 0xFC0A 0x00 # 2 green
pokeAtlas 0xFC0C 0x00; pokeAtlas 0xFC0D 0x00; pokeAtlas 0xFC0E 0xFF # 3 blue
pokeAtlas 0xFC10 0xFF; pokeAtlas 0xFC11 0xFF; pokeAtlas 0xFC12 0x00 # 4 yellow
pokeAtlas 0xFC40 0x00; pokeAtlas 0xFC41 0x00; pokeAtlas 0xFC42 0x00 # 16 black paper
pokeAtlas 0xFC44 0x00; pokeAtlas 0xFC45 0xFF; pokeAtlas 0xFC46 0xFF # 17 cyan ink
}
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)"
# ---- Four pages of tiles ----
#
# ONE TILE NUMBER, four different pictures. Every cell below names tile 1 and they differ only
# in the attribute's page bits, which is the whole of what this adds: a byte reaches 256 tiles
# and two bits that were already being written on every cell reach 1024.
#
# The fourth page is the memory the sprite table and the palette are in, and that is checked
# here rather than forbidden - it is the same answer shared video memory has always given.
# Page 3 tile 8 lands at 0xC200, which is sprite entries 64 to 71, and 64 bytes of index four
# read as a size byte of 0x04: no width, so no sprite. The art works and nothing is drawn.
{ prologue
spriteColours
solidTile 1 0x01
pageTile 1 1 0x02
pageTile 2 1 0x03
pageTile 3 8 0x04
pokeScreen 0x4304 0x01; pokeScreen 0x4305 0x00
pokeScreen 0x4306 0x01; pokeScreen 0x4307 0x10
pokeScreen 0x4308 0x01; pokeScreen 0x4309 0x20
pokeScreen 0x430A 0x08; pokeScreen 0x430B 0x30
epilogue
} | run pages || exit 1
[ "$(pixel pages 16 24)" = "255,0,0" ] && [ "$(pixel pages 24 24)" = "0,255,0" ] \
&& [ "$(pixel pages 32 24)" = "0,0,255" ] \
&& result ok "the attribute says which page" "the same tile number, three pictures" \
|| result no "the attribute says which page" "$(pixel pages 16 24) $(pixel pages 24 24) $(pixel pages 32 24)"
[ "$(pixel pages 40 24)" = "255,255,0" ] \
&& result ok "and the fourth page is the sprite table" "art read out of the memory sprites are in" \
|| result no "and the fourth page is the sprite table" "got $(pixel pages 40 24)"
# The page bits and the scheme nibble are in one byte and must not disturb each other. Tile 2
# of page 1 is index one everywhere; scheme one adds sixteen, and entry 17 is cyan.
{ prologue
spriteColours
pageTile 1 2 0x01
pokeScreen 0x4304 0x02; pokeScreen 0x4305 0x11
epilogue
} | run pagescheme || exit 1
[ "$(pixel pagescheme 16 24)" = "0,255,255" ] \
&& result ok "a page and a scheme in one byte" "page one, scheme one, and both applied" \
|| result no "a page and a scheme in one byte" "got $(pixel pagescheme 16 24)"
# ---- Sprites ----
#
# A thing put at a PIXEL rather than in a cell. Tile 1 is solid index one, which the palette
# above makes red, and the sprite sits exactly over the cell at row 3 column 2 - so where it
# is can be checked against where it is not, which is the way a tile engine is usually wrong.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 16 24 0x11 0x00
epilogue
} | run sprite || exit 1
[ "$(pixel sprite 16 24)" = "255,0,0" ] && [ "$(pixel sprite 23 31)" = "255,0,0" ] \
&& result ok "a sprite lands where it is put" "and fills its whole eight by eight" \
|| result no "a sprite lands where it is put" "corner $(pixel sprite 16 24), far $(pixel sprite 23 31)"
[ "$(pixel sprite 15 24)" = "0,0,0" ] && [ "$(pixel sprite 24 24)" = "0,0,0" ] \
&& result ok "and stops at its own edge" "a pixel either side is background" \
|| result no "and stops at its own edge" "left $(pixel sprite 15 24), right $(pixel sprite 24 24)"
# ---- What it does not cover ----
#
# Index nought is a hole and not a colour. The tile is solid on top and empty underneath, and
# the cell behind it is cyan, so the bottom half of the sprite must show the cell.
{ prologue
spriteColours
halfTile 3 0x01 0x00
pokeScreen 0x4304 0x01; pokeScreen 0x4305 0x01 # Row 3, column 2: tile 1 in scheme 1.
solidTile 1 0x01
spriteAt 0 3 0x00 16 24 0x11 0x00
epilogue
} | run spritehole || exit 1
[ "$(pixel spritehole 16 24)" = "255,0,0" ] && [ "$(pixel spritehole 16 28)" = "0,255,255" ] \
&& result ok "a pixel of nought is not drawn" "the cell behind shows through the hole" \
|| result no "a pixel of nought is not drawn" "top $(pixel spritehole 16 24), bottom $(pixel spritehole 16 28)"
# ---- Bigger than a tile ----
#
# Two by two, so four tiles in reading order from the one named: 4 and 5 across the top, 6 and
# 7 underneath. Each is its own colour, which is the only way to catch a sprite that draws all
# four in the right places in the wrong order.
{ prologue
spriteColours
solidTile 4 0x01; solidTile 5 0x02; solidTile 6 0x03; solidTile 7 0x04
spriteAt 0 4 0x00 16 24 0x22 0x00
epilogue
} | run spritebig || exit 1
[ "$(pixel spritebig 16 24)" = "255,0,0" ] && [ "$(pixel spritebig 24 24)" = "0,255,0" ] \
&& [ "$(pixel spritebig 16 32)" = "0,0,255" ] && [ "$(pixel spritebig 24 32)" = "255,255,0" ] \
&& result ok "a sprite is m by n tiles" "four of them, in reading order" \
|| result no "a sprite is m by n tiles" "$(pixel spritebig 16 24) $(pixel spritebig 24 24) $(pixel spritebig 16 32) $(pixel spritebig 24 32)"
# Mirrored, which has to move the TILES and not only the pixels inside them - a two tile wide
# thing whose halves stayed put would turn inside out rather than round.
{ prologue
spriteColours
solidTile 4 0x01; solidTile 5 0x02; solidTile 6 0x03; solidTile 7 0x04
spriteAt 0 4 0x00 16 24 0x22 0x01
epilogue
} | run spriteflip || exit 1
[ "$(pixel spriteflip 16 24)" = "0,255,0" ] && [ "$(pixel spriteflip 24 24)" = "255,0,0" ] \
&& result ok "mirroring moves the tiles too" "the right hand tile came out on the left" \
|| result no "mirroring moves the tiles too" "$(pixel spriteflip 16 24) $(pixel spriteflip 24 24)"
# And upside down, the same argument on the other axis.
{ prologue
spriteColours
solidTile 4 0x01; solidTile 5 0x02; solidTile 6 0x03; solidTile 7 0x04
spriteAt 0 4 0x00 16 24 0x22 0x02
epilogue
} | run spriteover || exit 1
[ "$(pixel spriteover 16 24)" = "0,0,255" ] && [ "$(pixel spriteover 16 32)" = "255,0,0" ] \
&& result ok "and turning it over does as well" "the bottom tile came out on top" \
|| result no "and turning it over does as well" "$(pixel spriteover 16 24) $(pixel spriteover 16 32)"
# ---- Off the edge ----
#
# The reason the position is signed. Four pixels off the left is half a tile showing; a whole
# tile off is nothing at all, and must be nothing rather than a wrapped one at the far side.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 -4 24 0x11 0x00
spriteAt 1 1 0x00 -8 40 0x11 0x00
epilogue
} | run spriteedge || exit 1
[ "$(pixel spriteedge 0 24)" = "255,0,0" ] && [ "$(pixel spriteedge 4 24)" = "0,0,0" ] \
&& result ok "a sprite can sit off the edge" "half of it showing, and half not" \
|| result no "a sprite can sit off the edge" "at 0 $(pixel spriteedge 0 24), at 4 $(pixel spriteedge 4 24)"
[ "$(pixel spriteedge 0 40)" = "0,0,0" ] && [ "$(pixel spriteedge 312 40)" = "0,0,0" ] \
&& result ok "and right off it is gone" "not wrapped round to the other side" \
|| result no "and right off it is gone" "left $(pixel spriteedge 0 40), right $(pixel spriteedge 312 40)"
# ---- In front, and behind ----
#
# Behind means drawn only where the background had NOTHING - the same rule that makes a
# sprite's own nought a hole, read the other way round. The cell is solid on top and empty
# underneath, so a sprite behind it shows through the bottom half only.
{ prologue
spriteColours
halfTile 2 0x01 0x00
pokeScreen 0x4304 0x02; pokeScreen 0x4305 0x01 # Row 3, column 2: tile 2 in scheme 1.
solidTile 1 0x01
spriteAt 0 1 0x00 16 24 0x11 0x04
epilogue
} | run spritebehind || exit 1
[ "$(pixel spritebehind 16 24)" = "0,255,255" ] && [ "$(pixel spritebehind 16 28)" = "255,0,0" ] \
&& result ok "a sprite can go behind the map" "hidden where the cell had something" \
|| result no "a sprite can go behind the map" "top $(pixel spritebehind 16 24), bottom $(pixel spritebehind 16 28)"
# Where two overlap, the lower number is in front. Both solid, both at the same place, and
# the one that wins says which way round the table is read.
{ prologue
spriteColours
solidTile 1 0x01; solidTile 2 0x02
spriteAt 0 1 0x00 16 24 0x11 0x00
spriteAt 1 2 0x00 16 24 0x11 0x00
epilogue
} | run spriteorder || exit 1
[ "$(pixel spriteorder 16 24)" = "255,0,0" ] \
&& result ok "the lower number is in front" "sprite nought covered sprite one" \
|| result no "the lower number is in front" "got $(pixel spriteorder 16 24)"
# ---- Nothing, which is what the table wakes up as ----
#
# A size of nought either way draws nothing, and that is the off switch. Everything above
# would pass on a device that drew every entry regardless, because every other entry in those
# tables happens to be zeroed - this is the one that says zero MEANS something.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 16 24 0x01 0x00
spriteAt 1 1 0x00 40 24 0x10 0x00
spriteAt 2 1 0x00 64 24 0x11 0x00
epilogue
} | run spritenone || exit 1
[ "$(pixel spritenone 16 24)" = "0,0,0" ] && [ "$(pixel spritenone 40 24)" = "0,0,0" ] \
&& [ "$(pixel spritenone 64 24)" = "255,0,0" ] \
&& result ok "no width or no height draws nothing" "and the one beside them still does" \
|| result no "no width or no height draws nothing" "$(pixel spritenone 16 24) $(pixel spritenone 40 24) $(pixel spritenone 64 24)"
# A sprite reads its page from the same bits, because a sprite's attribute IS a cell's
# attribute - which is what lets the same art be a wall in one place and a moving thing in
# another with nothing rewritten.
{ prologue
spriteColours
solidTile 1 0x01
pageTile 1 1 0x02
spriteAt 0 1 0x10 16 24 0x11 0x00
epilogue
} | run spritepage || exit 1
[ "$(pixel spritepage 16 24)" = "0,255,0" ] \
&& result ok "a sprite has pages too" "tile one of page one, not of page nought" \
|| result no "a sprite has pages too" "got $(pixel spritepage 16 24)"
# ---- Bigger and smaller than it is ----
#
# The same one tile sprite drawn at 16 by 16 and at 4 by 4. What is checked is where it stops
# as much as where it starts: a stretch that got the ratio right and the extent wrong would
# put the right colour in the right corner and run off the end.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 16 24 0x11 0x00
spriteSize 0 16 16
spriteAt 1 1 0x00 64 24 0x11 0x00
spriteSize 1 4 4
epilogue
} | run spritescale || exit 1
[ "$(pixel spritescale 31 39)" = "255,0,0" ] && [ "$(pixel spritescale 32 39)" = "0,0,0" ] \
&& result ok "a sprite can be drawn bigger" "sixteen pixels of it, and not seventeen" \
|| result no "a sprite can be drawn bigger" "at 31 $(pixel spritescale 31 39), at 32 $(pixel spritescale 32 39)"
[ "$(pixel spritescale 67 27)" = "255,0,0" ] && [ "$(pixel spritescale 68 27)" = "0,0,0" ] \
&& result ok "and smaller" "four pixels of it, and not five" \
|| result no "and smaller" "at 67 $(pixel spritescale 67 27), at 68 $(pixel spritescale 68 27)"
# The axes are separate, which is the shape a wall column in a pseudo-3D game is: one tile
# wide at its own size, and stretched to whatever height the distance says.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 16 24 0x11 0x00
spriteSize 0 8 64
epilogue
} | run spritecolumn || exit 1
[ "$(pixel spritecolumn 23 87)" = "255,0,0" ] && [ "$(pixel spritecolumn 24 87)" = "0,0,0" ] \
&& [ "$(pixel spritecolumn 16 88)" = "0,0,0" ] \
&& result ok "the two axes scale apart" "eight wide and sixty four tall" \
|| result no "the two axes scale apart" "$(pixel spritecolumn 23 87) $(pixel spritecolumn 24 87) $(pixel spritecolumn 16 88)"
# Stretched art still comes out of the right tile. Two tiles across, drawn at four times the
# width: the halves have to stay halves rather than one of them winning.
{ prologue
spriteColours
solidTile 4 0x01; solidTile 5 0x02
spriteAt 0 4 0x00 16 24 0x21 0x00
spriteSize 0 64 8
epilogue
} | run spritewide || exit 1
[ "$(pixel spritewide 16 24)" = "255,0,0" ] && [ "$(pixel spritewide 47 24)" = "255,0,0" ] \
&& [ "$(pixel spritewide 48 24)" = "0,255,0" ] && [ "$(pixel spritewide 79 24)" = "0,255,0" ] \
&& result ok "a stretched group keeps its tiles" "each half of it is half of the result" \
|| result no "a stretched group keeps its tiles" "$(pixel spritewide 16 24) $(pixel spritewide 47 24) $(pixel spritewide 48 24) $(pixel spritewide 79 24)"
# ---- How far away it is ----
#
# The depth buffer is a byte a column. Half of this sprite has a nearer wall in front of it
# and half has a further one, which is the case NO ORDERING OF THE TABLE CAN EXPRESS - and
# the whole reason the buffer is per column rather than one number per sprite.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 16 24 0x11 0x00
spriteSize 0 0 0 0x05
for i in 16 17 18 19; do depthAt $i 0x03; done
for i in 20 21 22 23; do depthAt $i 0x09; done
epilogue
} | run spritedepth || exit 1
[ "$(pixel spritedepth 16 24)" = "0,0,0" ] && [ "$(pixel spritedepth 20 24)" = "255,0,0" ] \
&& result ok "a nearer column hides a sprite" "hidden in four columns and drawn in four" \
|| result no "a nearer column hides a sprite" "near $(pixel spritedepth 16 24), far $(pixel spritedepth 20 24)"
# A depth of nought is no depth test at all, which is what a cleared table says and what
# every sprite that is not in a pseudo-3D scene wants. The same buffer, the same wall.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 16 24 0x11 0x00
for i in 16 17 18 19; do depthAt $i 0x03; done
epilogue
} | run spritenodepth || exit 1
[ "$(pixel spritenodepth 16 24)" = "255,0,0" ] \
&& result ok "and a depth of nought never asks" "drawn straight over the nearer column" \
|| result no "and a depth of nought never asks" "got $(pixel spritenodepth 16 24)"
# And a column with nothing in it does not hide anything, which is what makes a buffer nobody
# has written the same as no buffer at all.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 16 24 0x11 0x00
spriteSize 0 0 0 0x05
epilogue
} | run spriteemptydepth || exit 1
[ "$(pixel spriteemptydepth 16 24)" = "255,0,0" ] \
&& result ok "an empty column hides nothing" "a buffer nobody wrote is no buffer" \
|| result no "an empty column hides nothing" "got $(pixel spriteemptydepth 16 24)"
# A target size is sixteen bits and the screen is not. Asking for sixty thousand pixels must
# draw the part that fits and take no longer than that part deserves - the loop is clipped
# before it runs rather than inside it.
{ prologue
spriteColours
solidTile 1 0x01
spriteAt 0 1 0x00 0 0 0x11 0x00
spriteSize 0 60000 60000
epilogue
} | run spritehuge || exit 1
[ "$(pixel spritehuge 0 0)" = "255,0,0" ] && [ "$(pixel spritehuge 319 199)" = "255,0,0" ] \
&& result ok "a size past the screen is clipped" "it filled the screen and stopped there" \
|| result no "a size past the screen is clipped" "$(pixel spritehuge 0 0) $(pixel spritehuge 319 199)"
# ---- A window, which does not scroll ----
#
# The map is scrolled five rows and three pixels, and the window cell has to come out at the
# same place it would with neither. That is the entire feature: a window cell is at a SCREEN
# position, where a map cell is at a position in a world the screen is looking at part of.
{ prologue
spriteColours
solidTile 1 0x01
pokeWindow 0 2 0x01 0x00
port 0x3D 0x01 # One row tall.
port 0x3E 0x00 # At the top.
port 0x34 0x05 # And the map five rows down and three pixels into a cell.
port 0x38 0x03
epilogue
} | run window || exit 1
[ "$(pixel window 16 0)" = "255,0,0" ] && [ "$(pixel window 23 7)" = "255,0,0" ] \
&& result ok "a window cell sits where the screen is" "the scroll registers did not move it" \
|| result no "a window cell sits where the screen is" "$(pixel window 16 0) and $(pixel window 23 7)"
[ "$(pixel window 24 0)" = "0,0,0" ] \
&& result ok "and stops where it ends" "one cell wide, and the next is not it" \
|| result no "and stops where it ends" "got $(pixel window 24 0)"
# ---- Where it starts is its own register ----
#
# A status bar along the bottom is as common as one along the top, and working the row out
# from the screen height is a sum every program would otherwise do again.
{ prologue
spriteColours
solidTile 1 0x01
pokeWindow 0 2 0x01 0x00
port 0x3D 0x01
port 0x3E 10 # Ten rows down, which is eighty pixels. Shell arithmetic, so
# ten and not 0d10 - that is the assembler's notation and this
# is a printf away from being an INIA.
epilogue
} | run windowat || exit 1
[ "$(pixel windowat 16 80)" = "255,0,0" ] && [ "$(pixel windowat 16 0)" = "0,0,0" ] \
&& result ok "and it starts where it is told" "row ten, and nothing at the top" \
|| result no "and it starts where it is told" "at 80 $(pixel windowat 16 80), at 0 $(pixel windowat 16 0)"
# ---- Over everything, sprites included ----
#
# A sprite that could cover the fuel gauge would be a bug in every game that had both.
{ prologue
spriteColours
solidTile 1 0x01; solidTile 2 0x02
pokeWindow 0 2 0x01 0x00
spriteAt 0 2 0x00 16 0 0x11 0x00
port 0x3D 0x01
epilogue
} | run windowover || exit 1
[ "$(pixel windowover 16 0)" = "255,0,0" ] \
&& result ok "a window covers a sprite" "the bar wins, which is what a bar is for" \
|| result no "a window covers a sprite" "got $(pixel windowover 16 0)"
# ---- And none of it happens until it is asked for ----
#
# Nought rows is no window, so a cleared screen has none and every program written before this
# existed means what it meant. The cell is written and the height is not set.
{ prologue
spriteColours
solidTile 1 0x01
pokeWindow 0 2 0x01 0x00
epilogue
} | run windowoff || exit 1
[ "$(pixel windowoff 16 0)" = "0,0,0" ] \
&& result ok "no height is no window" "written, and not drawn" \
|| result no "no height is no window" "got $(pixel windowoff 16 0)"
# ---- 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="$(commonest "$BUILD/flip.ppm")"
[ "$FLIPPED" = "50c050" ] \
&& result ok "a program shows the other screen" "green, which is the paper it filled with" \
|| result no "a program shows the other screen" "commonest colour $FLIPPED, not the fill"
# ---- 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, and Flip does exit while flipped - deliberately,
# because a program that FAULTED while flipped could not put it back either.
#
# What says so is a corner of the screen with nothing on it. The map Flip filled is one tile
# and one attribute in every cell, so a screen still showing it is that colour EVERYWHERE; a
# screen nought that came back is black where nobody has printed. Checking a corner rather
# than comparing whole pictures, because Flip's own line is meant to survive on this one and
# an equality check would call that a difference.
python3 -c "open('$BUILD/flipafter.keys','wb').write(b'Say a line to come back to\n' + b'Flip\n' + b'\x00'*3000 + b' ' + b'\x00'*3000 + b' ' + b'\x00'*3000)"
timeout 30 "$EMU" --fast --cycles 200000000 --keyboard "$BUILD/flipafter.keys" \
--screen "$BUILD/flipafter.ppm" --disk "$ROOT/Tests/build/disks/cosmos.img" \
--ram-disk 2048 "$BUILD/cosmos.bin" > "$BUILD/flipafter.out" 2>&1 || true
[ "$(commonest "$BUILD/flipafter.ppm")" = "000000" ] \
&& result ok "and the system puts the screen back" "black again, and not the filled screen" \
|| result no "and the system puts the screen back" "commonest colour $(commonest "$BUILD/flipafter.ppm")"
# ---- A sprite, from inside the system ----
#
# Sprite moves a ball across the shell's own text and writes NOT ONE BYTE of the map to do
# it. The ball is 52 pixels of scheme one's ink, drawn from a tile whose corners are index
# nought - so counting that exact colour finds the ball and nothing else, the shell printing
# in grey.
python3 -c "open('$BUILD/ball.keys','wb').write(b'Sprite\n' + b'\x00'*40000)"
timeout 30 "$EMU" --fast --cycles 60000000 --keyboard "$BUILD/ball.keys" \
--screen "$BUILD/ball.ppm" --disk "$ROOT/Tests/build/disks/cosmos.img" \
--ram-disk 2048 "$BUILD/cosmos.bin" > "$BUILD/ball.out" 2>&1 || true
[ "$(countColour "$BUILD/ball.ppm" d04038)" = "52" ] \
&& result ok "a program can put a sprite up" "52 pixels of ball, and a round one" \
|| result no "a program can put a sprite up" "$(countColour "$BUILD/ball.ppm" d04038) pixels, not 52"
# ---- And the system takes it down ----
#
# The sprite table is in the atlas at 0xC000, and the screen save walks the pages either side
# of it: to the end of the map, then the palette. So a sprite is not something the system can
# GIVE BACK, and Sprite deliberately does not clear its own - a program that faulted could
# not have either. What must not happen is a ball left sitting over the prompt, in front of
# everything, with nothing able to type it away.
python3 -c "open('$BUILD/ballgone.keys','wb').write(b'Sprite\n' + b'\x00'*600 + b' ' + b'\x00'*600)"
timeout 30 "$EMU" --fast --cycles 60000000 --keyboard "$BUILD/ballgone.keys" \
--screen "$BUILD/ballgone.ppm" --disk "$ROOT/Tests/build/disks/cosmos.img" \
--ram-disk 2048 "$BUILD/cosmos.bin" > "$BUILD/ballgone.out" 2>&1 || true
[ "$(countColour "$BUILD/ballgone.ppm" d04038)" = "0" ] \
&& result ok "and the system takes the sprite down" "not one pixel of it left over the shell" \
|| result no "and the system takes the sprite down" "$(countColour "$BUILD/ballgone.ppm" d04038) pixels still there"
# ---- A ball behind one pillar and in front of another ----
#
# Depth puts four pillars at four distances and walks a ball past them at a distance between
# two of them. Caught here MID-STRADDLE across the nearest one: the ball is 48 pixels wide and
# the pillar is 32, so parts of it show on both sides and none of it shows across the middle.
#
# What that proves is the thing an ordering cannot do. The ball is sprite NOUGHT and every
# pillar is numbered after it, so table order puts the ball in front of all four - and it is
# still hidden here, because the depth buffer is asked per column.
#
# The cycle count is tuned to catch it in that position. If CosmOS's size changes enough to
# shift the boot, the first check below fails saying the ball is not straddling anything,
# which is a request to re-tune rather than a bug in the device.
python3 -c "open('$BUILD/depth.keys','wb').write(b'Depth\n' + b'\x00'*40000)"
timeout 30 "$EMU" --fast --cycles 22000000 --keyboard "$BUILD/depth.keys" \
--screen "$BUILD/depth.ppm" --disk "$ROOT/Tests/build/disks/cosmos.img" \
--ram-disk 2048 "$BUILD/cosmos.bin" > "$BUILD/depth.out" 2>&1 || true
read -r BALLLEFT BALLOVER BALLRIGHT PILLAR <<EOT
$(python3 -c "
d = open('$BUILD/depth.ppm', 'rb').read()
px = d[d.index(b'255\n') + 4:]
ball, pillar, w = bytes.fromhex('50c050'), bytes.fromhex('d04038'), 640
def count(colour, lo, hi):
return sum(1 for o in range(0, len(px), 3)
if px[o:o + 3] == colour and lo <= (o // 3) % w <= hi)
print(count(ball, 0, 79), count(ball, 80, 111), count(ball, 112, 200), count(pillar, 80, 111))
" 2>/dev/null || echo "0 0 0 0")
EOT
[ "$BALLLEFT" -gt 0 ] && [ "$BALLRIGHT" -gt 0 ] \
&& result ok "the ball is straddling the near pillar" "showing on both sides of it" \
|| result no "the ball is straddling the near pillar" "left $BALLLEFT, right $BALLRIGHT - re-tune the cycle count"
[ "$BALLOVER" = "0" ] && [ "$PILLAR" -gt 0 ] \
&& result ok "and the near pillar hides the middle" "sprite nought, behind sprite one" \
|| result no "and the near pillar hides the middle" "$BALLOVER ball pixels across a pillar of $PILLAR"
# ---- Lunar Porter, flying ----
#
# The biggest program on the disk, and the one that uses the most of the machine at once: it
# takes the screen, changes the mode, redefines tiles, fills all 3,200 cells of the map from a
# terrain it generated, and moves a sprite over it every frame.
#
# A burn of the lifting thruster every forty eighth frame, which is exactly the gravity: one
# sixteenth of a pixel every sixth frame against eight every forty eighth. Exactly cancelling
# the ACCELERATION still leaves an average velocity, so it drifts upward slowly and this is
# caught early rather than left to settle.
#
# EARLY, and that is the point of 900 thousand cycles. Earlier again than it used to be: the
# window grew to two rows when the messages moved into it, and by 1.5 million the lander had
# climbed to row nine and was mostly BEHIND the status bar - which is the window doing exactly
# what it is meant to and left the check counting six pixels of a forty pixel lander. This program starts almost at once - the
# first guess at where to look was ten million cycles in, on the assumption that taking a
# screen was expensive, and by then the lander had flown 700 frames and left the picture. A
# capture near the start is worth far more than a tuned one: there is much less between it and
# the beginning that can move.
python3 -c "
keys = b'Lander\n'
for i in range(400):
keys += b'\x80' + b'\x00' * 47
open('$BUILD/lander.keys','wb').write(keys + b'\x00' * 8000)
"
timeout 30 "$EMU" --fast --cycles 900000 --keyboard "$BUILD/lander.keys" \
--screen "$BUILD/lander.ppm" --disk "$ROOT/Tests/build/disks/cosmos.img" \
--ram-disk 2048 "$BUILD/cosmos.bin" > "$BUILD/lander.out" 2>&1 || true
read -r MOON SKY SHIP SHIPLEFT SHIPRIGHT <<EOT
$(python3 -c "
d = open('$BUILD/lander.ppm', 'rb').read()
px = d[d.index(b'255\n') + 4:]
w = 320
moon, ship = bytes.fromhex('d8d8d8'), bytes.fromhex('d8c048')
at = [px[o:o + 3] for o in range(0, len(px), 3)]
shipAt = [i % w for i, c in enumerate(at) if c == ship]
print(sum(1 for c in at if c == moon), sum(1 for c in at if c == bytes(3)), len(shipAt),
min(shipAt) if shipAt else -1, max(shipAt) if shipAt else -1)
" 2>/dev/null || echo "0 0 0 -1 -1")
EOT
# A moon that filled the screen or left it empty would be a terrain generator that had run
# off one end of its clamp, which is the way that kind of loop usually fails.
[ "$MOON" -gt 10000 ] && [ "$SKY" -gt 10000 ] \
&& result ok "a moon with a sky over it" "$MOON pixels of ground and $SKY of sky" \
|| result no "a moon with a sky over it" "$MOON ground, $SKY sky"
# It never moves sideways: the world scrolls under it, so it is at the middle of a 320 pixel
# screen every frame of its life. Forty pixels is the whole of the shape, so none of it has
# been clipped or drawn twice.
[ "$SHIP" = "40" ] && [ "$SHIPLEFT" = "156" ] && [ "$SHIPRIGHT" = "163" ] \
&& result ok "and a lander in the middle of it" "all forty pixels, at the screen's centre" \
|| result no "and a lander in the middle of it" "$SHIP pixels, x $SHIPLEFT to $SHIPRIGHT"
# ---- The same lander, after the shell has scrolled ----
#
# THE SHELL SCROLLS, and its row origin is wherever the last command left it. The map is a
# ring 128 rows tall that the screen shows 25 of, so a moon drawn into rows nought to 24 while
# the screen is looking at row forty is a moon nobody can see - which came out as terrain that
# was missing, or half there, depending on how far down the prompt had got.
#
# EIGHTY returns before the program is started. Forty was the first try and it caught nothing,
# because the shell runs an eighty column screen which is FIFTY ROWS TALL - forty returns fill
# it and never scroll it, so the origin was still nought and the check passed against a
# version with the fix taken out. The screenful that matters is the one the shell is using,
# not the one the program is about to ask for. The moon has to come out exactly as it does from a fresh
# prompt: the rows a program WRITES and the rows the screen READS are two different things,
# and only one of them is under the program's control.
python3 -c "open('$BUILD/scrolled.keys','wb').write(b'\n' * 80 + b'Lander\n' + b'\x00' * 3000)"
python3 -c "open('$BUILD/scrolled.pad','wb').write(b'\\x00' * 40 + b'\\x08' * 400)"
timeout 30 "$EMU" --fast --cycles 1500000 --keyboard "$BUILD/scrolled.keys" \
--pad "$BUILD/scrolled.pad" --screen "$BUILD/scrolled.ppm" \
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
"$BUILD/cosmos.bin" > "$BUILD/scrolled.out" 2>&1 || true
SCROLLEDMOON="$(python3 -c "
d = open('$BUILD/scrolled.ppm', 'rb').read()
px = d[d.index(b'255\n') + 4:]
moon = bytes.fromhex('d8d8d8')
print(sum(1 for o in range(0, len(px), 3) if px[o:o + 3] == moon))
" 2>/dev/null || echo 0)"
[ "$SCROLLEDMOON" -gt 10000 ] \
&& result ok "a scrolled shell does not hide the moon" "$SCROLLEDMOON pixels of it, drawn where the screen looks" \
|| result no "a scrolled shell does not hide the moon" "$SCROLLEDMOON pixels of ground"
# ---- And the same lander, on a controller ----
#
# A pad reports what is HELD, so the thruster can be leaned on rather than pumped - which is
# the whole reason the device exists and the one thing the console cannot express. The
# recording holds nothing for forty frames and then holds Up.
#
# What is checked is that it is HIGHER LATER: two captures, the second further on, and the
# lander nearer the top in the second. A thruster that only fired once for a held button
# would let it fall between them instead, which is exactly what the console does.
python3 -c "open('$BUILD/lander.pad','wb').write(b'\x00' * 40 + b'\x08' * 400)"
for when in 800000 1600000; do
timeout 30 "$EMU" --fast --cycles $when --keyboard "$BUILD/lander.keys" \
--pad "$BUILD/lander.pad" --screen "$BUILD/held$when.ppm" \
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
"$BUILD/cosmos.bin" > "$BUILD/held.out" 2>&1 || true
done
read -r EARLY LATE <<EOT
$(python3 -c "
def top(path):
d = open(path, 'rb').read()
px = d[d.index(b'255\n') + 4:]
ship = bytes.fromhex('d8c048')
ys = [i // 320 for i in range(len(px) // 3) if px[i * 3:i * 3 + 3] == ship]
return min(ys) if ys else -1
print(top('$BUILD/held800000.ppm'), top('$BUILD/held1600000.ppm'))
" 2>/dev/null || echo "-1 -1")
EOT
[ "$EARLY" -gt 0 ] && [ "$LATE" -gt 0 ] && [ "$LATE" -lt "$EARLY" ] \
&& result ok "a held thruster keeps lifting" "row $EARLY early, row $LATE later" \
|| result no "a held thruster keeps lifting" "row $EARLY early, row $LATE later"
# ---- A base speaks in the window, not into the world ----
#
# The console draws into the map, so a message printed while flying is a message the lander
# then flies over - and printing scrolls, so every one of them moved the whole world up a row.
# The window is at a screen position and forty cells wide, and neither is true of it.
#
# The lander is set down gently on the pad it starts above, which loads cargo and says so, and
# the message is white - a colour nothing else on this screen uses. Then the throttle opens
# and the line goes with it, because a message that outlived the moment would be read as
# describing this one.
python3 -c "
f = (b'\x08' * 6 + b'\x00' * 10) * 60
f += b'\x10' * 10 + b'\x00' * 20
open('$BUILD/said.pad','wb').write(f + b'\x00' * 3000)
f += b'\x08' * 120
open('$BUILD/unsaid.pad','wb').write(f + b'\x00' * 2000)
"
for which in said unsaid; do
timeout 40 "$EMU" --fast --cycles 25000000 --keyboard "$BUILD/lander.keys" \
--pad "$BUILD/$which.pad" --screen "$BUILD/$which.ppm" \
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
"$BUILD/cosmos.bin" > "$BUILD/$which.out" 2>&1 || true
done
# ---- And the same landing flown on the SECOND controller ----
#
# A pad holding nothing on nought and the flight on one, so a program that read only the first
# controller would sit there and never arrive. That check used to live in a transcript and
# stopped saying anything the moment the messages moved off the console, which is what a test
# asserting a side effect rather than the thing itself is always one refactor away from.
python3 -c "open('$BUILD/idle.pad','wb').write(b'\x00' * 4000)"
timeout 40 "$EMU" --fast --cycles 25000000 --keyboard "$BUILD/lander.keys" \
--pad "$BUILD/idle.pad" --pad "$BUILD/said.pad" --screen "$BUILD/padone.ppm" \
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
"$BUILD/cosmos.bin" > "$BUILD/padone.out" 2>&1 || true
PADONE="$(countColour "$BUILD/padone.ppm" f0f0f0)"
[ "$PADONE" -gt 100 ] \
&& result ok "and flies on the second controller too" "$PADONE pixels of message, flown on pad one" \
|| result no "and flies on the second controller too" "$PADONE pixels of message"
SAID="$(countColour "$BUILD/said.ppm" f0f0f0)"
UNSAID="$(countColour "$BUILD/unsaid.ppm" f0f0f0)"
[ "$SAID" -gt 100 ] \
&& result ok "a base says so in the window" "$SAID pixels of message, and none in the map" \
|| result no "a base says so in the window" "$SAID pixels of message"
[ "$UNSAID" = "0" ] \
&& result ok "and opening the throttle wipes it" "the line went with the moment" \
|| result no "and opening the throttle wipes it" "$UNSAID pixels of it still there"
# ---- Orbit: going sideways is how you stop falling ----
#
# Two runs holding the lifting thruster, one of them also going sideways. Both climb, because
# thrust beats gravity - but the one with speed climbs FASTER, because it is feeling less of
# the moon. That is the whole mechanic: at four pixels a frame the surface is falling away
# underneath as fast as the lander is falling towards it.
#
# Read off the descent bar rather than the lander, because both are off the top of the screen
# by then and the bar is not - it is drawn at a screen position and says the vertical speed
# whatever the lander is doing.
#
# The bar was 41 pixels against 53 when this was written. What is checked is that the second
# is LONGER, which is the claim; the numbers themselves are tuning.
python3 -c "
open('$BUILD/up.pad','wb').write(b'\x08' * 4000)
open('$BUILD/upside.pad','wb').write(b'\x09' * 4000)
"
for which in up upside; do
timeout 30 "$EMU" --fast --cycles 8000000 --keyboard "$ROOT/Tests/input/landerDemo.keys" \
--pad "$BUILD/$which.pad" --screen "$BUILD/$which.ppm" \
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
"$BUILD/cosmos.bin" > "$BUILD/$which.out" 2>&1 || true
done
read -r STRAIGHT SIDEWAYS <<EOT
$(python3 -c "
def bar(path):
d = open(path, 'rb').read()
px = d[d.index(b'255\n') + 4:]
w = 320
return sum(1 for y in range(200)
if px[(y * w + 300) * 3:(y * w + 300) * 3 + 3].hex() in ('50c050', 'd04038'))
print(bar('$BUILD/up.ppm'), bar('$BUILD/upside.ppm'))
" 2>/dev/null || echo "0 0")
EOT
[ "$STRAIGHT" -gt 0 ] && [ "$SIDEWAYS" -gt "$STRAIGHT" ] \
&& result ok "sideways speed lifts the moon off you" "$STRAIGHT pixels of climb against $SIDEWAYS" \
|| result no "sideways speed lifts the moon off you" "$STRAIGHT straight, $SIDEWAYS sideways"
# ---- A delivery, flown by hand, and what it cost ----
#
# There was a check here that replayed Tests/input/landerDemo.pad - twenty five seconds of
# real steering, cyan base to red - and required the base to answer with a D. It was the only
# check that a cargo ever reached anywhere.
#
# ORBIT KILLED IT. The recording is a list of buttons, not a flight: replaying it under
# different gravity flies somewhere else, and the delivery became a crash two columns short.
# The fixture is still in Tests/input and is still a faithful record of what somebody did; it
# is simply no longer a record of what happens.
#
# THAT IS THE STANDING COST OF A FLOWN FIXTURE, and it is worse than the transcript tests
# dropped earlier: those broke when an output moved, and this breaks whenever a NUMBER moves.
# Every tuning change invalidates every recorded flight.
#
# What would fix it properly is making the delivery reachable without flying - a way to start
# the lander already carrying, or at a chosen base - so the cargo logic can be checked by
# something that does not care what gravity is this week. Until then the delivery path is
# exercised by playing the game.
# ---- The landing pads, carved and coloured ----
#
# A random walk does not leave flat ground and a lander wants some, so four pads are carved
# after the moon is made. They are marked in the picture by an ATTRIBUTE rather than a tile of
# their own: the same solid block in scheme six, which costs no art at all, because a nibble
# is added to every index in the tile and one block is grey moon or cyan pad depending on the
# byte beside it.
#
# Checked as a multiple of thirty two pixels, because a pad is four cells wide and one row
# tall - so whatever is visible of them, it comes in whole cells.
PADS="$(countColour "$BUILD/lander.ppm" 50c0c8)"
[ "$PADS" -gt 0 ] && [ $(( PADS % 8 )) = 0 ] \
&& result ok "the moon has pads carved into it" "$PADS pixels of them, in whole cells" \
|| result no "the moon has pads carved into it" "$PADS pixels, which is not whole cells of pad"
# ---- The fuel gauge, in the window ----
#
# A bar in the window layer, which is at a SCREEN position - so the moon turning underneath it
# does not move it, which is the whole reason the window exists and the reason this is not
# drawn in the map like the terrain is.
#
# Two captures with the thruster held throughout. The bar has to be SHORTER in the later one,
# because a thruster costs a unit of fuel every tick it fires: a gauge that did not shrink
# would be a tank that was not being spent.
python3 -c "open('$BUILD/burn.pad','wb').write(b'\x08' * 4000)"
for when in 1500000 6000000; do
timeout 30 "$EMU" --fast --cycles $when --keyboard "$BUILD/lander.keys" \
--pad "$BUILD/burn.pad" --screen "$BUILD/burn$when.ppm" \
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
"$BUILD/cosmos.bin" > "$BUILD/burn.out" 2>&1 || true
done
read -r GAUGEEARLY GAUGELATE <<EOT
$(python3 -c "
def gauge(path):
d = open(path, 'rb').read()
px = d[d.index(b'255\n') + 4:]
green = bytes.fromhex('50c050')
at = [i % 320 for i in range(len(px) // 3)
if px[i * 3:i * 3 + 3] == green and i // 320 == 3]
return max(at) if at else -1
print(gauge('$BUILD/burn1500000.ppm'), gauge('$BUILD/burn6000000.ppm'))
" 2>/dev/null || echo "-1 -1")
EOT
[ "$GAUGEEARLY" -gt 0 ] && [ "$GAUGELATE" -gt 0 ] && [ "$GAUGELATE" -lt "$GAUGEEARLY" ] \
&& result ok "a held thruster spends the tank" "the gauge ran to $GAUGEEARLY, then to $GAUGELATE" \
|| result no "a held thruster spends the tank" "$GAUGEEARLY early, $GAUGELATE later"
# ---- And the system takes the window down ----
#
# A window is a layer at a screen position that does not scroll, which is exactly what makes
# one left behind so unpleasant: it sits over the top of whatever comes next and cannot be
# scrolled off, cleared away or typed past. Lunar Porter left its fuel gauge up and the shell
# came back with FUEL across the top and the cursor underneath it.
#
# Taken away rather than given back, like the sprite table: nothing the shell draws is a
# window, so there is nothing to restore - and a program that faulted while one was up could
# not have taken it down itself. Lander deliberately does not, which is what leaves the
# system's guarantee as the thing under test.
# CLEARED AFTERWARDS, and looked at cell by cell, which is what makes this sharp. The gauge
# BAR disappears on its own whatever happens - it is drawn with a tile the screen save puts
# back - so counting its colour proved nothing and passed with the teardown deleted. What
# survives is the LABEL, in font tiles the shell needs anyway, sitting over the top row where
# clearing cannot reach it.
#
# With the window down a cleared screen reads "> " and a cursor: cells nought and two. With it
# up the same row reads F, U, E, L across cells nought to three. So cells one and three being
# empty is the whole difference, and it is 29 and 22 pixels of it rather than a threshold
# somebody has to believe.
python3 -c "open('$BUILD/quit.keys','wb').write(b'Lander\n' + b'\x00' * 400 + b'q' + b'\x00' * 200 + b'clear\n' + b'\x00' * 200)"
timeout 30 "$EMU" --fast --cycles 14000000 --keyboard "$BUILD/quit.keys" \
--pad "$BUILD/burn.pad" --screen "$BUILD/quit.ppm" \
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
"$BUILD/cosmos.bin" > "$BUILD/quit.out" 2>&1 || true
read -r CELLONE CELLTHREE <<EOT
$(python3 -c "
d = open('$BUILD/quit.ppm', 'rb').read()
px = d[d.index(b'255\n') + 4:]
w = 640
def cell(c):
return sum(1 for x in range(c * 8, c * 8 + 8) for y in range(8)
if px[(y * w + x) * 3:(y * w + x) * 3 + 3] != bytes(3))
print(cell(1), cell(3))
" 2>/dev/null || echo "-1 -1")
EOT
[ "$CELLONE" = "0" ] && [ "$CELLTHREE" = "0" ] \
&& result ok "and the system takes the window down" "a cleared screen is clear to the top" \
|| result no "and the system takes the window down" "cells one and three hold $CELLONE and $CELLTHREE pixels"
# ---- The drift bar, which is a scaled sprite doing a job ----
#
# A moon has no air, so a sideways drift never stops by itself and stopping one means
# cancelling the velocity exactly. That is not hard; it is hard BLIND. So one sprite's width
# is the drift: it grows right from the middle of the screen for a rightward one and left for
# a leftward one, and NOUGHT DRAWS NOTHING, so "stopped" is the state with no bar at all.
#
# The recording holds Right for eighty frames and then nothing. Checked in two places: with a
# drift, where the bar starts at the middle and runs right; and with none, where a sprite of
# no width is a sprite that is not drawn.
python3 -c "open('$BUILD/drift.pad','wb').write(b'\x00' * 20 + b'\x01' * 80 + b'\x00' * 400)"
timeout 30 "$EMU" --fast --cycles 1600000 --keyboard "$BUILD/lander.keys" \
--pad "$BUILD/drift.pad" --screen "$BUILD/drift.ppm" \
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
"$BUILD/cosmos.bin" > "$BUILD/drift.out" 2>&1 || true
read -r BARLEFT BARWIDE STILLWIDE <<EOT
$(python3 -c "
def bar(path):
# EITHER COLOUR. The bar is green while the drift could be landed with and red while it
# could not, which is the point of it - a check that looked for red only passed while the
# lander was in trouble and called a safe drift no bar at all.
d = open(path, 'rb').read()
px = d[d.index(b'255\n') + 4:]
lit = (bytes.fromhex('d04038'), bytes.fromhex('50c050'))
at = [i % 320 for i in range(len(px) // 3)
if px[i * 3:i * 3 + 3] in lit and i // 320 == 189]
return (min(at), len(at)) if at else (-1, 0)
drifting = bar('$BUILD/drift.ppm')
print(drifting[0], drifting[1], bar('$BUILD/lander.ppm')[1])
" 2>/dev/null || echo "-1 0 0")
EOT
[ "$BARLEFT" = "160" ] && [ "$BARWIDE" -gt 4 ] \
&& result ok "a drift draws a bar" "$BARWIDE pixels of it, running right from the middle" \
|| result no "a drift draws a bar" "starts at $BARLEFT, $BARWIDE wide"
# The lander recording holds only Up, so there is no sideways drift and nothing to draw. A
# sprite of no width is not drawn at all, which is what makes "stopped" visible as emptiness.
[ "$STILLWIDE" = "0" ] \
&& result ok "and no drift draws none" "a width of nought is a sprite that is not there" \
|| result no "and no drift draws none" "$STILLWIDE pixels of bar with nothing moving"
# ---- 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