The first application with things of its own, and the first customer for all three rungs before it at once. /Apps/Lander a launcher /Packages/app.Lander/Lander.sbx the program /Packages/app.Lander/splash.tune its tune Typing "Lander" starts the launcher, which needs no new rule: the shell runs what it reads, so a launcher beginning "#!" and Say.sbx beginning "SBEX" go down the same road and neither the shell nor the person has to know which kind of thing they started. It passes on what it was told with $args, and says #quiet, because a launcher is machinery and not narration. The game then asks where it came from and joins its tune's name to that, so nothing anywhere names /splash.tune. Every recorded Lander test still types just "Lander" and knows nothing about any of this, which is exactly the claim. ---- Why the directory is not in /Apps ---- A launcher and a directory of the same name cannot both be there; SBFS refuses the second. And /Apps is the directory the shell walks for every word it does not know and Tab walks for every first word, so doubling what is in it is a cost on the path that runs most. The "app." goes in FRONT rather than behind because Tab matches the start of a name: a prefix is a namespace and a suffix is a collision. Copy.app beside Copy.sbx makes "Copy" and Tab complete to the shared "Copy." and hand you a broken word. The launcher names drive 0, so a game started from a disk of your own is looked for where the game is rather than where you are - its lines run on your disk, which is what makes everything else in a script work. /lander.state stays at the root. A saved position belongs to whoever saved it and is found where they are standing; a tune belongs to the program and is found beside it. That pair is the whole distinction. ---- And what it cost ---- Two hundred thousand cycles, about twelve frames, between the machine starting and the game drawing: a script opened, a deeper path walked. Six video captures moved out by that much. The drift bar needed something else - its pad is counted from the MACHINE starting rather than the game, so the burn is now twelve frames shorter from the game's point of view, and the recording holds the button longer instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
3318 lines
169 KiB
Bash
Executable File
3318 lines
169 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 "
|
|
SKIP = b'\x20' # a key the game ignores, so it skips the splash and nothing else
|
|
keys = b'Lander\n' + SKIP
|
|
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 1100000 --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 "SKIP = b'\x20' # a key the game ignores, so it skips the splash and nothing else
|
|
open('$BUILD/scrolled.keys','wb').write(b'\n' * 80 + b'Lander\n' + SKIP + 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.
|
|
#
|
|
# ONE AND 1.8 MILLION, and they were 800 thousand and 1.6 million. The shell tries a typed
|
|
# word as the name of a file before it tries it with ".sbx" on the end, so starting Lander
|
|
# now walks four names where it walked two, and at 800 thousand the program had not drawn
|
|
# yet - which reads as row -1 and fails, correctly, for a reason that has nothing to do with
|
|
# thrusters. The gap between the two captures is what the check is made of, and it is the
|
|
# same gap.
|
|
python3 -c "open('$BUILD/lander.pad','wb').write(b'\x00' * 40 + b'\x08' * 400)"
|
|
for when in 1200000 2000000; 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/held1200000.ppm'), top('$BUILD/held2000000.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"
|
|
|
|
# ---- The ceiling, which is a pin and not an ending ----
|
|
#
|
|
# Climbing makes a sixteen bit height count down past nought and round to 65535, and a lander
|
|
# that kept going came back through the bottom and hit the ground FROM ABOVE. Two thousand
|
|
# pixels of climb is reachable with a full tank.
|
|
#
|
|
# It is pinned instead, and told so. Leaving upward is RECOVERABLE - gravity is always there
|
|
# and a lander with fuel can always come back - so ending the run would punish a state the
|
|
# player can fly out of.
|
|
#
|
|
# Nine hundred frames of thrust to reach it, then nothing: the warning has to be up while it
|
|
# is pinned and GONE once gravity has brought it home. Both halves, because a ceiling nobody
|
|
# can leave is worse than the wrap it replaced, and that is exactly what the first two
|
|
# versions of this did.
|
|
python3 -c "open('$BUILD/ceiling.pad','wb').write(b'\x08' * 900 + b'\x00' * 6000)"
|
|
for when in 14000000 22000000; do
|
|
timeout 60 "$EMU" --fast --cycles $when --keyboard "$ROOT/Tests/input/landerDemo.keys" \
|
|
--pad "$BUILD/ceiling.pad" --screen "$BUILD/ceiling$when.ppm" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/ceiling.out" 2>&1 || true
|
|
done
|
|
PINNED="$(countColour "$BUILD/ceiling14000000.ppm" f0f0f0)"
|
|
HOME="$(countColour "$BUILD/ceiling22000000.ppm" f0f0f0)"
|
|
[ "$PINNED" -gt 100 ] \
|
|
&& result ok "a ceiling stops the climb and says so" "$PINNED pixels of warning" \
|
|
|| result no "a ceiling stops the climb and says so" "$PINNED pixels of warning"
|
|
[ "$HOME" = "0" ] \
|
|
&& result ok "and gravity brings it back from there" "the warning went with the height" \
|
|
|| result no "and gravity brings it back from there" "$HOME pixels still up"
|
|
|
|
# ---- Orbit, which can be checked again now it can be placed ----
|
|
#
|
|
# Gravity minus the swing outwards. Below orbital speed the pull wins and the lander falls;
|
|
# above it the swing wins and it climbs; and falling buys sideways speed while climbing spends
|
|
# it. That is what makes a closed orbit rather than a one way trip.
|
|
#
|
|
# THERE WAS NO CHECK FOR THIS FOR A LONG TIME, and the reason is worth keeping. Reaching a
|
|
# given orbit through the controls takes a sustained burn while holding height, and the phase
|
|
# of that burn against the gravity tick - one frame in ten - decides whether the thruster is
|
|
# seen at all, so two pad files a frame apart fly differently. A version of this check did
|
|
# exist and passed against one disk and failed against another, which is a check measuring the
|
|
# boot time rather than the physics. It was deleted rather than left looking tested.
|
|
#
|
|
# A state file settles it. Placed at eighty sideways, which is sixteen over orbital, the
|
|
# lander climbs, turns over, falls, and climbs again - and the turning points are BROAD, tens
|
|
# of pixels across, so the samples below have nothing like the margin problem the old one did.
|
|
# The station is parked below the surface out of the way, because it laps every four seconds
|
|
# and would otherwise wander into the middle of the measurement.
|
|
python3 -c "
|
|
import struct
|
|
open('$BUILD/orbit.state','wb').write(struct.pack('<hhhhhhBBBB',
|
|
0x14A0, 0, 80, 0, 0x14A0, 2600, 255, 1, 0, 0))
|
|
"
|
|
# Its own keyboard file and a silent pad. $BUILD/lander.keys holds a key down every forty
|
|
# eight bytes for the held-thruster check, which would fly this orbit as well as measure it.
|
|
python3 -c "SKIP = b'\x20' # a key the game ignores, so it skips the splash and nothing else
|
|
open('$BUILD/fly.keys','wb').write(b'Lander\n' + SKIP + b'\x00' * 8000)"
|
|
python3 -c "open('$BUILD/none.pad','wb').write(b'\x00' * 60000)"
|
|
cp "$ROOT/Tests/build/disks/cosmos.img" "$BUILD/orbit.img"
|
|
"$ROOT/SplitDisk" put "$BUILD/orbit.img" "$BUILD/orbit.state" /lander.state > /dev/null
|
|
for when in 3200000 13200000 25200000 37200000; do
|
|
timeout 60 "$EMU" --fast --cycles $when --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/none.pad" --screen "$BUILD/orbit$when.ppm" \
|
|
--disk "$BUILD/orbit.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/orbit.out" 2>&1 || true
|
|
done
|
|
read -r LOWONE HIGHONE LOWTWO HIGHTWO <<EOT
|
|
$(python3 -c "
|
|
def height(path):
|
|
d = open(path, 'rb').read()
|
|
width = int(d[:40].split()[1])
|
|
px = d[d.index(b'255\n') + 4:]
|
|
ship = [i // width for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == bytes.fromhex('d8c048')]
|
|
return min(ship) if ship else -1
|
|
print(height('$BUILD/orbit3200000.ppm'), height('$BUILD/orbit13200000.ppm'),
|
|
height('$BUILD/orbit25200000.ppm'), height('$BUILD/orbit37200000.ppm'))
|
|
" 2>/dev/null || echo "-1 -1 -1 -1")
|
|
EOT
|
|
# A smaller row is higher up. Fifty pixels of change either way, against turning points that
|
|
# are flat to within ten - so this is measuring the turn and not the sampling.
|
|
[ "$HIGHONE" -lt "$(( LOWONE - 50 ))" ] && [ "$HIGHONE" -gt 0 ] \
|
|
&& result ok "a lander over orbital speed climbs" "row $LOWONE became row $HIGHONE" \
|
|
|| result no "a lander over orbital speed climbs" "row $LOWONE became row $HIGHONE"
|
|
# And turns over on its own, with nothing touched: the climb spends the speed that bought it.
|
|
[ "$LOWTWO" -gt "$(( HIGHONE + 50 ))" ] \
|
|
&& result ok "and then falls again, which is an apoapse" "row $HIGHONE became row $LOWTWO" \
|
|
|| result no "and then falls again, which is an apoapse" "row $HIGHONE became row $LOWTWO"
|
|
# Round again, and no lower than the first time: the exchange is a trade, not a leak.
|
|
[ "$HIGHTWO" -lt "$(( LOWTWO - 50 ))" ] && [ "$HIGHTWO" -le "$HIGHONE" ] \
|
|
&& result ok "and round again without decaying" "$HIGHONE, $LOWTWO, then $HIGHTWO" \
|
|
|| result no "and round again without decaying" "$HIGHONE, $LOWTWO, then $HIGHTWO"
|
|
|
|
# ---- 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 "SKIP = b'\x20' # a key the game ignores, so it skips the splash and nothing else
|
|
open('$BUILD/quit.keys','wb').write(b'Lander\n' + SKIP + 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 a hundred and twenty 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.
|
|
#
|
|
# IT WAS EIGHTY, AND THE PAD IS COUNTED FROM THE MACHINE STARTING rather than from the game
|
|
# starting. A launcher put twelve frames between those two, so the game saw twelve fewer
|
|
# frames of the burn and drew a narrower bar - which is a real reading of a real drift, and
|
|
# nothing to do with what this check is about. Longer, so that what is being asked about is
|
|
# whether a drift draws a bar rather than how long the pilot held the button.
|
|
python3 -c "open('$BUILD/drift.pad','wb').write(b'\x00' * 20 + b'\x01' * 120 + b'\x00' * 400)"
|
|
timeout 30 "$EMU" --fast --cycles 1800000 --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"
|
|
|
|
# ---- The altitude bar, and the mark that says where orbital speed is ----
|
|
#
|
|
# The orbit takes the lander OFF THE TOP OF THE SCREEN, which left an instrument panel that
|
|
# only worked while the ground was in sight. Two additions answer that: a bar up the left edge
|
|
# for how much sky is underneath, and a pair of marks on the drift bar showing where the
|
|
# sideways speed stops being a fall and starts being an orbit.
|
|
#
|
|
# BOTH HALVES OF THE BAR, because either alone is vacuous. A bar that was always full would
|
|
# pass "there is one while flying" and a bar that was never drawn would pass "there is none
|
|
# while landed" - it is the two together that say the thing is measuring something.
|
|
#
|
|
# Two flights, because no one flight holds both ends well. The climb reaches a bar of two
|
|
# hundred pixels and is nowhere near the edge of the claim; the descent lands, and a landed
|
|
# lander then WAITS to be told its message has been read, so the picture is frozen at the
|
|
# moment of touchdown and that end cannot drift with the boot time the way the deleted orbit
|
|
# check did. (Frozen, not merely still - the retro thruster check below had to press A to get
|
|
# past that wait before it could test anything at all.) One flight that did both spent four
|
|
# seconds airborne and read thirty pixels, a margin thin enough to be luck.
|
|
#
|
|
# The marks are checked BY POSITION rather than by counting, because position is the whole
|
|
# claim: 64 sixteenths is orbital speed, the bar is a pixel a sixteenth from the middle at
|
|
# 160, so the marks belong at 224 and at the two pixels before 96. A count would pass with
|
|
# them anywhere on the screen. Read off the landed frame, where nothing is drifting and the
|
|
# drift bar cannot be lying across them.
|
|
# ---- Its own keyboard file, and that is not fussiness ----
|
|
#
|
|
# $BUILD/lander.keys is not just the command that starts the program: it holds a key down
|
|
# every forty eight bytes, because the test that made it is checking that a HELD KEY keeps
|
|
# lifting. Borrowing it here meant the lander was being flown from the keyboard and the
|
|
# controller at once, and the gentle descent below arrived as a crash - which reads exactly
|
|
# like a broken altimeter and is nothing of the sort.
|
|
python3 -c "SKIP = b'\x20' # a key the game ignores, so it skips the splash and nothing else
|
|
open('$BUILD/fly.keys','wb').write(b'Lander\n' + SKIP + b'\x00' * 8000)"
|
|
python3 -c "open('$BUILD/climb.pad','wb').write(b'\x00' * 20 + b'\x09' * 300 + b'\x01' * 200 + b'\x00' * 6000)"
|
|
python3 -c "open('$BUILD/settle.pad','wb').write(b'\x00' * 150 + b'\x08' * 90 + b'\x00' * 8000)"
|
|
timeout 60 "$EMU" --fast --cycles 6000000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/climb.pad" --screen "$BUILD/climb.ppm" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/climb.out" 2>&1 || true
|
|
timeout 60 "$EMU" --fast --cycles 9000000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/settle.pad" --screen "$BUILD/settle.ppm" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/settle.out" 2>&1 || true
|
|
read -r FLYING RESTING MARKS <<EOT
|
|
$(python3 -c "
|
|
def magenta(path, low, high):
|
|
d = open(path, 'rb').read()
|
|
px = d[d.index(b'255\n') + 4:]
|
|
ink = bytes.fromhex('c860c0')
|
|
return [i % 320 for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == ink and low <= i % 320 <= high]
|
|
# The bar is at the left edge; the marks are out where the drift bar is.
|
|
print(len(magenta('$BUILD/climb.ppm', 0, 20)),
|
|
len(magenta('$BUILD/settle.ppm', 0, 20)),
|
|
','.join(str(x) for x in sorted(set(magenta('$BUILD/settle.ppm', 21, 319)))) or 'none')
|
|
" 2>/dev/null || echo "0 0 none")
|
|
EOT
|
|
[ "$FLYING" -gt 100 ] \
|
|
&& result ok "a lander above the ground draws an altitude bar" "$FLYING pixels of it" \
|
|
|| result no "a lander above the ground draws an altitude bar" "$FLYING pixels of it"
|
|
# Sitting on a pad is nought sky underneath, and NOUGHT DRAWS NOTHING. An altimeter that still
|
|
# showed a third of itself on the ground is one nobody could read the rest of.
|
|
[ "$RESTING" = "0" ] \
|
|
&& result ok "and one sitting on the ground draws none" "a bar of nought is no bar at all" \
|
|
|| result no "and one sitting on the ground draws none" "$RESTING pixels still up"
|
|
[ "$MARKS" = "94,95,224,225" ] \
|
|
&& result ok "orbital speed is marked on the drift bar" "64 pixels either side of the middle" \
|
|
|| result no "orbital speed is marked on the drift bar" "marks at $MARKS"
|
|
|
|
# ---- Two zoom levels, on a button ----
|
|
#
|
|
# Forty columns and eighty are the same map, the same 8x8 cells and the same engine - and the
|
|
# front end scales whatever it is handed up to the same window, so 320 by 200 and 640 by 400
|
|
# fill the same glass. THE MODES ARE ALREADY A ZOOM, and nothing in the video device had to
|
|
# change to get one. What had to change is every screen coordinate in Lander, because the
|
|
# middle of the screen is 160 on one and 320 on the other.
|
|
#
|
|
# So the check is that the whole panel MOVED WITH THE MIDDLE and none of it stayed behind: the
|
|
# lander at the centre of whichever screen it is on, and the orbital marks still 64 pixels
|
|
# either side of that centre. A check on the picture size alone would pass with the gauges
|
|
# still huddled in the top left corner.
|
|
#
|
|
# The third flight HOLDS the button for three hundred frames. A pad is level and not an event,
|
|
# so a view that swapped on the level would swap sixty times a second - and the way that shows
|
|
# is not only the mode it lands on but the TIME it costs, because each swap redraws the whole
|
|
# moon. Holding is checked to land in the same place as tapping, which a strobe cannot do.
|
|
python3 -c "open('$BUILD/narrow.pad','wb').write(b'\x00' * 8000)"
|
|
# A hundred frames before the button, because the program spends the first sixty or so
|
|
# drawing fifty rows of moon and is not reading a controller yet. A six frame tap at frame
|
|
# thirty used to work and stopped the day the moon got deeper, which reads exactly like a
|
|
# button that no longer does anything.
|
|
python3 -c "open('$BUILD/tap.pad','wb').write(b'\x00' * 100 + b'\x20' * 8 + b'\x00' * 8000)"
|
|
python3 -c "open('$BUILD/hold.pad','wb').write(b'\x00' * 100 + b'\x20' * 300 + b'\x00' * 8000)"
|
|
for view in narrow tap hold; do
|
|
timeout 60 "$EMU" --fast --cycles 3500000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/$view.pad" --screen "$BUILD/$view.ppm" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/$view.out" 2>&1 || true
|
|
done
|
|
read -r NARROW WIDE HELD NSHIP WSHIP NMARKS WMARKS TAPY HELDY <<EOT
|
|
$(python3 -c "
|
|
def look(path):
|
|
d = open(path, 'rb').read()
|
|
head = d[:40].split()
|
|
width = int(head[1])
|
|
px = d[d.index(b'255\n') + 4:]
|
|
def where(colour):
|
|
c = bytes.fromhex(colour)
|
|
return [(i % width, i // width) for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == c]
|
|
ship = where('d8c048')
|
|
marks = sorted(set(x for x, y in where('c860c0') if x > 25))
|
|
return (width,
|
|
'%d-%d' % (min(x for x, y in ship), max(x for x, y in ship)) if ship else 'none',
|
|
','.join(str(x) for x in marks) or 'none',
|
|
min(y for x, y in ship) if ship else -1)
|
|
n = look('$BUILD/narrow.ppm'); w = look('$BUILD/tap.ppm'); h = look('$BUILD/hold.ppm')
|
|
print(n[0], w[0], h[0], n[1], w[1], n[2], w[2], w[3], h[3])
|
|
" 2>/dev/null || echo "0 0 0 none none none none -1 -2")
|
|
EOT
|
|
[ "$NARROW" = "320" ] && [ "$WIDE" = "640" ] \
|
|
&& result ok "B swaps forty columns for eighty" "320 across, then 640 of the same map" \
|
|
|| result no "B swaps forty columns for eighty" "$NARROW then $WIDE"
|
|
# Forty pixels of lander in both, so none of it is clipped, and centred in both, so the whole
|
|
# panel moved rather than the picture merely getting bigger around it.
|
|
[ "$NSHIP" = "156-163" ] && [ "$WSHIP" = "316-323" ] \
|
|
&& result ok "and the lander is centred in either" "156 on the narrow one, 316 on the wide" \
|
|
|| result no "and the lander is centred in either" "$NSHIP then $WSHIP"
|
|
[ "$NMARKS" = "94,95,224,225" ] && [ "$WMARKS" = "254,255,384,385" ] \
|
|
&& result ok "and orbital speed is still 64 either side" "the marks moved with the middle" \
|
|
|| result no "and orbital speed is still 64 either side" "$NMARKS then $WMARKS"
|
|
# One press is one swap. A level triggered one would flip every frame and redraw the moon
|
|
# every frame with it, which costs about a frame and a half each time: the lander would be
|
|
# somewhere else entirely by now.
|
|
# ---- And z does it from a keyboard, with no controller plugged in at all ----
|
|
#
|
|
# The zoom key is tested ABOVE the pad test in the program, unlike the arrows: which way the
|
|
# lander is flown is a question a controller answers better, but how much of the moon is on
|
|
# the screen is not, and somebody without a pad would otherwise have no way to zoom at all.
|
|
#
|
|
# Forty zeroes before the z, because the console hands over ONE KEY A FRAME - a z two hundred
|
|
# bytes in is a z two hundred frames away, which is past the end of this capture and reads
|
|
# exactly like a key that does nothing.
|
|
python3 -c "SKIP = b'\x20' # a key the game ignores, so it skips the splash and nothing else
|
|
open('$BUILD/zoom.keys','wb').write(b'Lander\n' + SKIP + b'\x00' * 40 + b'z' + b'\x00' * 8000)"
|
|
timeout 60 "$EMU" --fast --cycles 2200000 --keyboard "$BUILD/zoom.keys" \
|
|
--screen "$BUILD/zoomkey.ppm" --disk "$ROOT/Tests/build/disks/cosmos.img" \
|
|
--ram-disk 2048 "$BUILD/cosmos.bin" > "$BUILD/zoomkey.out" 2>&1 || true
|
|
KEYED="$(python3 -c "
|
|
d = open('$BUILD/zoomkey.ppm', 'rb').read()
|
|
print(int(d[:40].split()[1]))
|
|
" 2>/dev/null || echo 0)"
|
|
[ "$KEYED" = "640" ] \
|
|
&& result ok "and z does it from the keyboard" "no controller, and the view still swapped" \
|
|
|| result no "and z does it from the keyboard" "$KEYED across"
|
|
[ "$TAPY" = "$HELDY" ] && [ "$HELD" = "640" ] \
|
|
&& result ok "and holding it swaps once, not sixty times a second" "held lands where tapped did" \
|
|
|| result no "and holding it swaps once, not sixty times a second" "tapped $TAPY, held $HELDY at $HELD across"
|
|
|
|
# ---- And the room the zoom buys goes to the sky, not to the rock ----
|
|
#
|
|
# The first version of this drew fifty rows DOWN from the world's origin, which put exactly
|
|
# the same sky on the screen as before with twice as much moon under it: 47 per cent moon
|
|
# zoomed in and 71 per cent zoomed out. A zoom that shows you more of the thing you cannot fly
|
|
# through is not worth a button. The rows go above instead, so the wide view starts twenty
|
|
# four rows over the origin and the ground sits near the bottom of it.
|
|
#
|
|
# THREE CHECKS, and the proportion alone is not one of them. Measured on its own it passes for
|
|
# a moon floating over a void: point the view at the world's origin again and the rows below
|
|
# the terrain are simply never drawn, which is black, which counts as sky. Both breaks that
|
|
# matter went straight through a proportion check and are caught by the two beside it - the
|
|
# ground has to REACH THE BOTTOM of the screen, and the sky above the world has to be empty
|
|
# rather than holding whatever the shell left in those rows.
|
|
#
|
|
# THE SHELL HAS TO BE SCROLLED DEEP for the second of those to mean anything, and that took
|
|
# finding out. The map is a ring of 128 rows and the wide view moves into rows 104 to 127; a
|
|
# shell that has only printed a few lines has never written up there, so leaving those rows
|
|
# alone looks exactly like clearing them. A hundred and eighteen returns first, which puts the
|
|
# prompt in that band, and then the difference appears: 136 pixels of somebody else's text
|
|
# hanging in the sky.
|
|
python3 -c "SKIP = b'\x20' # a key the game ignores, so it skips the splash and nothing else
|
|
open('$BUILD/deep.keys','wb').write(b'\n' * 118 + b'Lander\n' + SKIP + b'\x00' * 8000)"
|
|
timeout 60 "$EMU" --fast --cycles 8000000 --keyboard "$BUILD/deep.keys" \
|
|
--pad "$BUILD/tap.pad" --screen "$BUILD/deep.ppm" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/deep.out" 2>&1 || true
|
|
read -r NARROWMOON WIDEMOON NARROWFLOOR WIDEFLOOR WIDESKY <<EOT
|
|
$(python3 -c "
|
|
def look(path):
|
|
d = open(path, 'rb').read()
|
|
head = d[:40].split()
|
|
width, height = int(head[1]), int(head[2])
|
|
px = d[d.index(b'255\n') + 4:]
|
|
moon = bytes.fromhex('d8d8d8')
|
|
at = [(i % width, i // width) for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == moon]
|
|
rock = 100 * len(at) // (width * height)
|
|
floor = sum(1 for x, y in at if y == height - 1)
|
|
# Below the two row window and above the highest ground a terrain row can reach.
|
|
sky = sum(1 for x, y in at if 16 <= y < 260)
|
|
return rock, floor, sky, width
|
|
n = look('$BUILD/narrow.ppm'); w = look('$BUILD/hold.ppm')
|
|
print(n[0], w[0], '%d/%d' % (n[1], n[3]), '%d/%d' % (w[1], w[3]),
|
|
look('$BUILD/deep.ppm')[2])
|
|
" 2>/dev/null || echo "0 100 0/1 0/1 999")
|
|
EOT
|
|
[ "$WIDEMOON" -lt "$NARROWMOON" ] && [ "$WIDEMOON" -lt 35 ] \
|
|
&& result ok "and zooming out buys sky, not moon" "$NARROWMOON per cent rock became $WIDEMOON" \
|
|
|| result no "and zooming out buys sky, not moon" "$NARROWMOON per cent rock became $WIDEMOON"
|
|
# A whole scanline of moon along the bottom in both. Without it the terrain is a band with
|
|
# nothing underneath, which is what pointing the wide view at the origin actually produces.
|
|
[ "$NARROWFLOOR" = "320/320" ] && [ "$WIDEFLOOR" = "640/640" ] \
|
|
&& result ok "and the ground still reaches the bottom" "a full scanline of moon in both" \
|
|
|| result no "and the ground still reaches the bottom" "$NARROWFLOOR then $WIDEFLOOR"
|
|
# The rows above the world's origin are drawn as sky rather than left alone, so nothing the
|
|
# shell wrote up there is still sitting in the part of the map the wide view has moved into.
|
|
# Read off the deeply scrolled run, which is the only one that has anything up there to leave.
|
|
[ "$WIDESKY" = "0" ] \
|
|
&& result ok "and the sky over the world is empty" "nothing left in the rows the view moved into" \
|
|
|| result no "and the sky over the world is empty" "$WIDESKY pixels of moon up in the sky"
|
|
|
|
# ---- Which is the whole reason for the button ----
|
|
#
|
|
# A lander at the ceiling is OFF THE TOP of a forty column screen - that is where the orbit
|
|
# lives, and it is why the altitude bar had to exist at all. Zoomed out it is on the picture,
|
|
# because the wide view starts above the ceiling rather than at the world's origin. Same
|
|
# flight, same nine hundred frames of thruster; the only difference is the button.
|
|
python3 -c "open('$BUILD/ceilingin.pad','wb').write(b'\x00' * 20 + b'\x08' * 900 + b'\x00' * 8000)"
|
|
python3 -c "open('$BUILD/ceilingout.pad','wb').write(b'\x00' * 20 + b'\x08' * 900 + b'\x20' * 8 + b'\x00' * 8000)"
|
|
for view in ceilingin ceilingout; do
|
|
timeout 60 "$EMU" --fast --cycles 17000000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/$view.pad" --screen "$BUILD/$view.ppm" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/$view.out" 2>&1 || true
|
|
done
|
|
read -r INSHIP OUTSHIP <<EOT
|
|
$(python3 -c "
|
|
def shipRow(path):
|
|
d = open(path, 'rb').read()
|
|
width = int(d[:40].split()[1])
|
|
px = d[d.index(b'255\n') + 4:]
|
|
ship = bytes.fromhex('d8c048')
|
|
rows = [i // width for i in range(len(px) // 3) if px[i * 3:i * 3 + 3] == ship]
|
|
return min(rows) if rows else -1
|
|
print(shipRow('$BUILD/ceilingin.ppm'), shipRow('$BUILD/ceilingout.ppm'))
|
|
" 2>/dev/null || echo "-1 -1")
|
|
EOT
|
|
[ "$INSHIP" = "-1" ] && [ "$OUTSHIP" -gt 0 ] \
|
|
&& result ok "and a lander at the ceiling is only on screen zoomed out" "off the top, then at row $OUTSHIP" \
|
|
|| result no "and a lander at the ceiling is only on screen zoomed out" "row $INSHIP zoomed in, row $OUTSHIP out"
|
|
|
|
# ---- The retro thruster ----
|
|
#
|
|
# Arresting a rise used to take a sideways burn and a wait for the orbit to come round, which
|
|
# is how a rendezvous really is flown and is a lot to ask of somebody who has not flown one
|
|
# before. Down makes the correction directly, at HALF the strength of up - one sixteenth a
|
|
# tick against two, which is exactly gravity's own step, so it can hurry a descent and can
|
|
# never turn a landing approach into a crash faster than simply letting go would.
|
|
#
|
|
# The same four hundred frames of climb in both, so the two pictures are identical up to the
|
|
# moment the retro fires and the only difference measured is the thing being tested. Read off
|
|
# the ALTITUDE BAR rather than the lander, because at this point in the flight the lander is
|
|
# off the top of the screen - which is what that bar is for.
|
|
python3 -c "open('$BUILD/coast.pad','wb').write(b'\x08' * 400 + b'\x00' * 20000)"
|
|
python3 -c "open('$BUILD/retro.pad','wb').write(b'\x08' * 400 + b'\x04' * 600 + b'\x00' * 20000)"
|
|
# ---- The A press matters, and it took a while to find out why ----
|
|
#
|
|
# A lander that has just landed WAITS to be told the message has been read - waitKey, which
|
|
# spins until A or Start or a key. The keyboard fixture pads with NULs and a NUL is not a key,
|
|
# so without the press here the program sits in that loop for ever and the picture is frozen
|
|
# at the moment of landing. Every thruster held afterwards did nothing, which reads exactly
|
|
# like a working guard and is nothing of the sort: the first version of this check passed just
|
|
# as happily with the guard deleted.
|
|
python3 -c "open('$BUILD/sit.pad','wb').write(b'\x00' * 150 + b'\x08' * 90 + b'\x00' * 100 + b'\x10' * 8 + b'\x00' * 8000)"
|
|
python3 -c "open('$BUILD/sitdown.pad','wb').write(b'\x00' * 150 + b'\x08' * 90 + b'\x00' * 100 + b'\x10' * 8 + b'\x04' * 2000 + b'\x00' * 8000)"
|
|
for view in coast retro; do
|
|
timeout 60 "$EMU" --fast --cycles 8600000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/$view.pad" --screen "$BUILD/$view.ppm" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/$view.out" 2>&1 || true
|
|
done
|
|
for view in sit sitdown; do
|
|
timeout 60 "$EMU" --fast --cycles 14000000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/$view.pad" --screen "$BUILD/$view.ppm" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/$view.out" 2>&1 || true
|
|
done
|
|
read -r COASTALT RETROALT SITROW DOWNROW <<EOT
|
|
$(python3 -c "
|
|
def look(path):
|
|
d = open(path, 'rb').read()
|
|
width = int(d[:40].split()[1])
|
|
px = d[d.index(b'255\n') + 4:]
|
|
bar = sum(1 for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == bytes.fromhex('c860c0') and i % width < 25)
|
|
ship = [i // width for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == bytes.fromhex('d8c048')]
|
|
# The fuel gauge, which says whether the engine fired at all.
|
|
fuel = sum(1 for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == bytes.fromhex('50c050') and i // width < 16)
|
|
return bar // 3, (min(ship) if ship else -1), fuel
|
|
print(look('$BUILD/coast.ppm')[0], look('$BUILD/retro.ppm')[0],
|
|
'%d,%d' % look('$BUILD/sit.ppm')[1:], '%d,%d' % look('$BUILD/sitdown.ppm')[1:])
|
|
" 2>/dev/null || echo "0 0 -1 -2")
|
|
EOT
|
|
[ "$RETROALT" -lt "$COASTALT" ] \
|
|
&& result ok "the retro thruster hurries a descent" "$COASTALT of sky coasting, $RETROALT under thrust" \
|
|
|| result no "the retro thruster hurries a descent" "$COASTALT coasting, $RETROALT under thrust"
|
|
# And it does NOTHING on the ground. Without that guard a landed lander holding it sinks
|
|
# straight through the moon - touchdown has already had its say and will not speak again, so
|
|
# there is nothing underneath to stop it and no crash to say it happened.
|
|
# Both the row AND the fuel, because either alone is soft: an engine that fired and moved
|
|
# nothing would keep the row, and one that moved the lander without charging for it would keep
|
|
# the fuel. With the guard deleted this reads "off screen" and a fifth of the tank gone - it
|
|
# goes straight through the moon, because touchdown has already had its say and will not speak
|
|
# again, so there is nothing underneath to stop it and no crash to say it happened.
|
|
[ "$DOWNROW" = "$SITROW" ] && [ "$SITROW" != "-1,0" ] \
|
|
&& result ok "and does nothing to a lander on the ground" "$SITROW unchanged after two thousand frames of it" \
|
|
|| result no "and does nothing to a lander on the ground" "$SITROW sitting, $DOWNROW holding it down"
|
|
|
|
# ---- The orbiting station ----
|
|
#
|
|
# A body at orbital speed, which under the rules already here is all a circular orbit IS: the
|
|
# pull and the swing cancel at 64 sixteenths at any height, because gravity never falls off
|
|
# and the moon wraps. So the station has no physics of its own - a position, a constant, and
|
|
# the same fourteen bit wrap the lander uses.
|
|
#
|
|
# Four rows above the world's origin, which puts it OFF THE TOP of a forty column screen and
|
|
# inside a wide one. That is the point of it: somewhere to go that the zoom is needed to see.
|
|
python3 -c "open('$BUILD/hovernarrow.pad','wb').write(b'\x08' * 3000 + b'\x00' * 20000)"
|
|
python3 -c "
|
|
pad = bytearray(b'\x08' * 3000)
|
|
pad[100:108] = b'\x28' * 8 # Up and B together, so the climb is not interrupted.
|
|
open('$BUILD/hoverwide.pad','wb').write(bytes(pad) + b'\x00' * 20000)"
|
|
for when in 2200000 3200000 5200000 7200000; do
|
|
timeout 60 "$EMU" --fast --cycles $when --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/hoverwide.pad" --screen "$BUILD/station$when.ppm" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/station.out" 2>&1 || true
|
|
done
|
|
timeout 60 "$EMU" --fast --cycles 3200000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/hovernarrow.pad" --screen "$BUILD/stationnarrow.ppm" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/stationnarrow.out" 2>&1 || true
|
|
read -r ONNARROW SEEN X2 X3 X5 X7 <<EOT
|
|
$(python3 -c "
|
|
def station(path, band):
|
|
# Blue, and ABOVE THE TERRAIN, because one of the four landing pads is blue too and it is
|
|
# always somewhere on the picture. Nothing else is up in that band.
|
|
d = open(path, 'rb').read()
|
|
width = int(d[:40].split()[1])
|
|
px = d[d.index(b'255\n') + 4:]
|
|
blue = bytes.fromhex('5880e0')
|
|
at = [(i % width, i // width) for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == blue and i // width < band]
|
|
return len(at), (min(x for x, y in at) if at else -1)
|
|
narrow = station('$BUILD/stationnarrow.ppm', 70)
|
|
seen, x3 = station('$BUILD/station3200000.ppm', 200)
|
|
print(narrow[0], seen,
|
|
station('$BUILD/station2200000.ppm', 200)[1], x3,
|
|
station('$BUILD/station5200000.ppm', 200)[1],
|
|
station('$BUILD/station7200000.ppm', 200)[1])
|
|
" 2>/dev/null || echo "0 0 -1 -1 -1 -1")
|
|
EOT
|
|
# Forty pixels is the whole of the art, so none of it is clipped and it is the tile meant.
|
|
[ "$SEEN" = "40" ] \
|
|
&& result ok "a station is up there, zoomed out" "all forty pixels of it" \
|
|
|| result no "a station is up there, zoomed out" "$SEEN pixels of station"
|
|
[ "$ONNARROW" = "0" ] \
|
|
&& result ok "and off the top of a forty column screen" "which is what the zoom is for" \
|
|
|| result no "and off the top of a forty column screen" "$ONNARROW pixels of it in the narrow view"
|
|
# Sixty frames apart, at four pixels a frame, is about 240 - and it has to be MOVING RIGHT,
|
|
# because a station drawn from a position nothing advances would sit still and still pass a
|
|
# check that only asked whether it was there.
|
|
MOVED=$(( X3 - X2 ))
|
|
[ "$MOVED" -gt 200 ] && [ "$MOVED" -lt 280 ] \
|
|
&& result ok "and it goes round at orbital speed" "$MOVED pixels in sixty frames" \
|
|
|| result no "and it goes round at orbital speed" "$X2 then $X3, which is $MOVED"
|
|
# Off the far side of the moon and back on the other edge. The screen shows 640 of the moon's
|
|
# 1024 pixels, so there is a stretch where it is genuinely not on the picture at all.
|
|
[ "$X5" = "-1" ] && [ "$X7" -gt 0 ] \
|
|
&& result ok "and round the back of the moon and out again" "gone, then back at $X7" \
|
|
|| result no "and round the back of the moon and out again" "$X5 at five million, $X7 at seven"
|
|
|
|
# ---- Docking, from a state placed rather than flown to ----
|
|
#
|
|
# The same shape as touchdown: close enough, and slow enough RELATIVE TO THE STATION, or it is
|
|
# a wreck. Its speed is orbital speed, which is what the marks on the drift bar point at.
|
|
#
|
|
# ---- Placed, because this cannot be flown to ----
|
|
#
|
|
# Ninety six pad files were tried and every one either hit the moon or was run down on the way
|
|
# up, which is not the search's fault: climbing SPENDS sideways speed, so a lander cannot rise
|
|
# while matched and arrives slower than orbital every time. Reaching the station wants two
|
|
# burns and a phase.
|
|
#
|
|
# So Lander reads sixteen bytes from /lander.state if the disk has one, and starts from those
|
|
# instead. A disk without one is the game as it always was, which is every other flight in
|
|
# this file. The strike below used to be flown, on a climb whose window was nineteen frames
|
|
# wide - the sort of fixture that ends up measuring the boot time. It is placed now and exact.
|
|
python3 -c "
|
|
import struct
|
|
def state(across, down, vx, vy, stationX, stationDown, fuel, wide, landed):
|
|
return struct.pack('<hhhhhhBBBB', across, down, vx, vy, stationX, stationDown,
|
|
fuel, wide, landed, 0)
|
|
# Hanging at the world's origin with a part tank, to show a state is read at all.
|
|
open('$BUILD/place.state','wb').write(state(0x14A0, 0, 0, 0, 0x34A0, -1536, 100, 1, 1))
|
|
# Four pixels behind the station, at its height, and matched to its speed exactly.
|
|
open('$BUILD/dock.state','wb').write(state(0x34A0 - 64, -1536 + 64, 64, 0, 0x34A0, -1536, 100, 1, 0))
|
|
# In the same place and NOT matched: 64 sixteenths of relative speed, eight times the limit.
|
|
open('$BUILD/wreck.state','wb').write(state(0x34A0, -1536, 0, 0, 0x34A0, -1536, 100, 1, 0))
|
|
"
|
|
for which in place dock wreck; do
|
|
cp "$ROOT/Tests/build/disks/cosmos.img" "$BUILD/$which.img"
|
|
"$ROOT/SplitDisk" put "$BUILD/$which.img" "$BUILD/$which.state" /lander.state > /dev/null
|
|
done
|
|
# ---- The acknowledgement comes at frame 400, and that is not a round number by accident ----
|
|
#
|
|
# A dock, like a landing, waits to be told its message has been read. Reading a state file
|
|
# costs a disk read, so a placed run starts a good deal later than a plain one - the press was
|
|
# at frame 100 and stopped working the day the state file arrived, because by then the program
|
|
# had not reached the dock yet and the press went by unheard. Everything after it is sampled
|
|
# well clear of both ends.
|
|
python3 -c "open('$BUILD/ackonly.pad','wb').write(b'\x00' * 400 + b'\x10' * 8 + b'\x00' * 60000)"
|
|
# A dock that is legally within tolerance but well out of line - six pixels back and six up -
|
|
# so there is a visible distance to slide. Sampled twice: once while it is still closing, and
|
|
# once after. The slide runs for the thirty or so frames after the acknowledgement at frame
|
|
# 400, and the middle sample sits at about frame 419; if it ever fails, check that before
|
|
# believing the code is broken.
|
|
python3 -c "
|
|
import struct
|
|
open('$BUILD/slide.state','wb').write(struct.pack('<hhhhhhBBBB',
|
|
0x34A0 - 96, -1536 - 96, 64, 0, 0x34A0, -1536, 100, 1, 0, 0))
|
|
"
|
|
cp "$ROOT/Tests/build/disks/cosmos.img" "$BUILD/slide.img"
|
|
"$ROOT/SplitDisk" put "$BUILD/slide.img" "$BUILD/slide.state" /lander.state > /dev/null
|
|
for run in place:5000000 dock:7300000 dock2:9000000 wreck:5000000 slide:7000000 slide2:8000000; do
|
|
which="${run%%:*}"; when="${run##*:}"
|
|
timeout 60 "$EMU" --fast --cycles "$when" --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/ackonly.pad" --screen "$BUILD/$which.ppm" \
|
|
--disk "$BUILD/${which%2}.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/$which.out" 2>&1 || true
|
|
done
|
|
# And the same moment with no state file at all, which is the contrast that says the file did it.
|
|
timeout 60 "$EMU" --fast --cycles 5000000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/ackonly.pad" --screen "$BUILD/nostate.ppm" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/nostate.out" 2>&1 || true
|
|
read -r PLACEDY PLACEDFUEL PLAINFUEL DOCKFUEL DOCK2FUEL SLIDING SETTLED <<EOT
|
|
$(python3 -c "
|
|
def look(path):
|
|
d = open(path, 'rb').read()
|
|
width = int(d[:40].split()[1])
|
|
px = d[d.index(b'255\n') + 4:]
|
|
def where(colour):
|
|
c = bytes.fromhex(colour)
|
|
return [(i % width, i // width) for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == c]
|
|
ship = where('d8c048')
|
|
station = [p for p in where('5880e0') if p[1] < 250]
|
|
# The gauge is the top row of the window; the speed bars are green too, further down.
|
|
fuel = sum(1 for x, y in where('50c050') if y < 8) // 64
|
|
return (min(y for x, y in ship) if ship else -1, fuel,
|
|
'%d,%d' % (min(x for x, y in ship), min(y for x, y in ship)) if ship else 'none',
|
|
'%d,%d' % (min(x for x, y in station), min(y for x, y in station)) if station else 'none')
|
|
def offset(path):
|
|
# Where the station sits relative to the lander, which is what alignment means here.
|
|
d = open(path, 'rb').read()
|
|
width = int(d[:40].split()[1])
|
|
px = d[d.index(b'255\n') + 4:]
|
|
def where(colour):
|
|
c = bytes.fromhex(colour)
|
|
return [(i % width, i // width) for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == c]
|
|
ship = where('d8c048'); station = [q for q in where('5880e0') if q[1] < 250]
|
|
if not ship or not station:
|
|
return 'none'
|
|
return '%d,%d' % (min(x for x, y in station) - min(x for x, y in ship),
|
|
min(y for x, y in station) - min(y for x, y in ship))
|
|
p = look('$BUILD/place.ppm'); d = look('$BUILD/dock.ppm'); d2 = look('$BUILD/dock2.ppm')
|
|
print(p[0], p[1], look('$BUILD/nostate.ppm')[1], d[1], d2[1],
|
|
offset('$BUILD/slide.ppm'), offset('$BUILD/slide2.ppm'))
|
|
" 2>/dev/null || echo "-1 -1 -1 -1 -1 none none")
|
|
EOT
|
|
# Placed at the world's origin with a hundred units: row 192 is the origin in the wide view,
|
|
# and twelve cells of gauge is a hundred. The plain disk at the same moment has a full tank,
|
|
# which is what says the FILE moved these and not the flight.
|
|
[ "$PLACEDY" = "192" ] && [ "$PLACEDFUEL" = "12" ] && [ "$PLAINFUEL" != "12" ] \
|
|
&& result ok "a state file places the lander" "row 192 and $PLACEDFUEL cells, against $PLAINFUEL with no file" \
|
|
|| result no "a state file places the lander" "row $PLACEDY, $PLACEDFUEL cells, plain disk $PLAINFUEL"
|
|
# A hundred plus eighty is a hundred and eighty, which is twenty two cells - and it is still
|
|
# twenty two three thousand cycles later, so it was paid ONCE and not once a frame.
|
|
[ "$DOCKFUEL" = "22" ] && [ "$DOCK2FUEL" = "22" ] \
|
|
&& result ok "a matched approach docks and is paid" "a hundred became a hundred and eighty, once" \
|
|
|| result no "a matched approach docks and is paid" "$DOCKFUEL cells, then $DOCK2FUEL"
|
|
# ---- And it settles ALIGNED, which took finding ----
|
|
#
|
|
# Nought across and eight up is the station squarely over the lander, a tile apart. It used to
|
|
# come to rest four pixels out however carefully it was flown, because the lander is drawn from
|
|
# half a screen LESS HALF A TILE - which is what centres an eight pixel lander on the middle -
|
|
# and the station was drawn from half a screen exactly. Same origin for both now.
|
|
[ "$SETTLED" = "0,-8" ] \
|
|
&& result ok "and rides it, squarely under the port" "the station directly above, a tile up" \
|
|
|| result no "and rides it, squarely under the port" "settled at $SETTLED, wanted 0,-8"
|
|
# Slid, not snapped. A dock is allowed eight pixels out either way, so putting the lander
|
|
# exactly in place the instant it took hold moved it a whole tile in one frame - a jump, at
|
|
# the very moment the player was being told they had been careful.
|
|
#
|
|
# THIS WATCHES THE VERTICAL ONE. Sideways has less ground to cover - eight pixels against
|
|
# sixteen, since the lander also has to come down a tile - so it has finished by the time this
|
|
# sample is taken, and deleting its easing does not show up here. Where it does is the check
|
|
# above: sideways is the axis alignment is measured on, so a snap there still has to land
|
|
# squarely. The gradualness of it alone is not separately guarded.
|
|
[ "$SLIDING" != "$SETTLED" ] && [ "$SLIDING" != "none" ] \
|
|
&& result ok "and slides in rather than snapping" "$SLIDING while closing, $SETTLED after" \
|
|
|| result no "and slides in rather than snapping" "$SLIDING while closing, $SETTLED after"
|
|
grep -q "Wrecked against the station" "$BUILD/wreck.out" \
|
|
&& result ok "and the same approach unmatched is a wreck" "64 sixteenths, eight times the limit" \
|
|
|| result no "and the same approach unmatched is a wreck" "no wreck in the transcript"
|
|
|
|
# ---- Thruster flames ----
|
|
#
|
|
# Every other reading here is a number drawn as a bar: how fast sideways, how fast down, how
|
|
# much sky, how much tank. All of it says what is happening TO the lander and none of it says
|
|
# what the PILOT is doing, so somebody watching over a shoulder has to read gauges to work out
|
|
# that a thruster is even lit.
|
|
#
|
|
# A plume hangs off whichever side the engine is pushing from - under the lander to go up,
|
|
# over it to go down, and on the far side from the way it is being pushed sideways. Twenty
|
|
# four pixels of it, which is the whole tile, so the count says it is not clipped.
|
|
#
|
|
# Held rather than fired. The engine fires one frame in ten, because that is the tick gravity
|
|
# is applied on, and a flame that honest would be one frame of light six times a second - a
|
|
# fault lamp, not a rocket. The button being down is the thing being shown.
|
|
python3 -c "
|
|
import struct
|
|
# The same place every time, with an empty tank for the last of them. Down 512 sixteenths and
|
|
# not nought: the world's origin is the top of the screen, and the two row window is drawn OVER
|
|
# the map there, so a lander placed at the origin is behind the fuel gauge and its flame with
|
|
# it - which reads exactly like a flame that is not being drawn.
|
|
for name, fuel in (('flamefull', 255), ('flamedry', 0)):
|
|
open('$BUILD/%s.state' % name, 'wb').write(struct.pack('<hhhhhhBBBB',
|
|
0x14A0, 512, 0, 0, 0x14A0, 2600, fuel, 0, 0, 0))
|
|
"
|
|
for which in flamefull flamedry; do
|
|
cp "$ROOT/Tests/build/disks/cosmos.img" "$BUILD/$which.img"
|
|
"$ROOT/SplitDisk" put "$BUILD/$which.img" "$BUILD/$which.state" /lander.state > /dev/null
|
|
done
|
|
for held in none up down right left dry; do
|
|
case "$held" in
|
|
none) byte='\x00'; disk=flamefull ;;
|
|
up) byte='\x08'; disk=flamefull ;;
|
|
down) byte='\x04'; disk=flamefull ;;
|
|
right) byte='\x01'; disk=flamefull ;;
|
|
left) byte='\x02'; disk=flamefull ;;
|
|
dry) byte='\x08'; disk=flamedry ;;
|
|
esac
|
|
python3 -c "open('$BUILD/flame$held.pad','wb').write(b'$byte' * 40000)"
|
|
timeout 60 "$EMU" --fast --cycles 1500000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/flame$held.pad" --screen "$BUILD/flame$held.ppm" \
|
|
--disk "$BUILD/$disk.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/flame$held.out" 2>&1 || true
|
|
done
|
|
read -r FNONE FUP FDOWN FRIGHT FLEFT FDRY <<EOT
|
|
$(python3 -c "
|
|
def flame(path):
|
|
# Red, and IN A BOX ROUND THE LANDER. The speed bars go red when they say a landing would
|
|
# not be survived, and one of the four landing pads is red as well - both of them turned
|
|
# up inside a filter that only looked at the column.
|
|
d = open(path, 'rb').read()
|
|
width = int(d[:40].split()[1])
|
|
px = d[d.index(b'255\n') + 4:]
|
|
def where(colour):
|
|
c = bytes.fromhex(colour)
|
|
return [(i % width, i // width) for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == c]
|
|
ship = where('d8c048')
|
|
if not ship:
|
|
return 'noship'
|
|
sx, sy = min(x for x, y in ship), min(y for x, y in ship)
|
|
lit = [(x, y) for x, y in where('d04038')
|
|
if sx - 12 <= x <= sx + 20 and sy - 12 <= y <= sy + 20]
|
|
if not lit:
|
|
return '0'
|
|
# Which side of the lander it came out on, which is the half that matters.
|
|
fx, fy = min(x for x, y in lit), min(y for x, y in lit)
|
|
side = 'below' if fy > sy else 'above'
|
|
if fx < sx: side = 'left'
|
|
if fx > sx + 4: side = 'right'
|
|
return '%d%s' % (len(lit), side)
|
|
print(flame('$BUILD/flamenone.ppm'), flame('$BUILD/flameup.ppm'),
|
|
flame('$BUILD/flamedown.ppm'), flame('$BUILD/flameright.ppm'),
|
|
flame('$BUILD/flameleft.ppm'), flame('$BUILD/flamedry.ppm'))
|
|
" 2>/dev/null || echo "x x x x x x")
|
|
EOT
|
|
[ "$FNONE" = "0" ] && [ "$FUP" = "24below" ] && [ "$FDOWN" = "24above" ] \
|
|
&& result ok "a lifting thruster shows a flame under the lander" "none held draws none, up draws 24 below, down 24 above" \
|
|
|| result no "a lifting thruster shows a flame under the lander" "none $FNONE, up $FUP, down $FDOWN"
|
|
# Sideways is the one that is easy to get backwards: pushing RIGHT means the plume comes out
|
|
# of the LEFT of the lander, because that is the side the gas leaves from.
|
|
[ "$FRIGHT" = "24left" ] && [ "$FLEFT" = "24right" ] \
|
|
&& result ok "and comes out the far side going sideways" "right draws left, left draws right" \
|
|
|| result no "and comes out the far side going sideways" "right $FRIGHT, left $FLEFT"
|
|
# An empty tank draws nothing, because then the button really is doing nothing.
|
|
[ "$FDRY" = "0" ] \
|
|
&& result ok "and an empty tank draws none at all" "the button is doing nothing, and shows it" \
|
|
|| result no "and an empty tank draws none at all" "$FDRY with a dry tank"
|
|
|
|
# ---- Coming apart ----
|
|
#
|
|
# A crash used to be a line of text and a lander still sitting there in one piece: the verdict
|
|
# was the whole of it, and somebody watching a recording had to read the words to know what had
|
|
# happened. The lander goes now, and six pieces of it leave in a rough hexagon at its own
|
|
# colour for about a second.
|
|
#
|
|
# THE SPREAD is what is measured, not the count. Two of the six leave the top of the screen on
|
|
# the way, so the count drops from twenty four to sixteen - which is correct and would make an
|
|
# exact count a check that breaks the day the lander crashes somewhere else. How far apart they
|
|
# have got only ever goes up.
|
|
#
|
|
# The animation runs about 45 frames from the crash, which here lands between 1.05 and 1.85
|
|
# million cycles; the samples sit at 1.2 and 1.6. If this ever fails, check that window before
|
|
# believing the code is broken.
|
|
python3 -c "
|
|
import struct
|
|
# Well above the ground and coming down far too fast to survive it.
|
|
open('$BUILD/crash.state','wb').write(struct.pack('<hhhhhhBBBB',
|
|
0x14A0, 200, 0, 60, 0x14A0, 2600, 255, 0, 0, 0))
|
|
"
|
|
cp "$ROOT/Tests/build/disks/cosmos.img" "$BUILD/crash.img"
|
|
"$ROOT/SplitDisk" put "$BUILD/crash.img" "$BUILD/crash.state" /lander.state > /dev/null
|
|
for when in 1400000 1800000 2400000; do
|
|
timeout 60 "$EMU" --fast --cycles $when --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/none.pad" --screen "$BUILD/burst$when.ppm" \
|
|
--disk "$BUILD/crash.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/burst$when.out" 2>&1 || true
|
|
done
|
|
read -r EARLYBITS EARLYWIDE LATEBITS LATEWIDE AFTERBITS <<EOT
|
|
$(python3 -c "
|
|
def bits(path):
|
|
d = open(path, 'rb').read()
|
|
width = int(d[:40].split()[1])
|
|
px = d[d.index(b'255\n') + 4:]
|
|
at = [(i % width, i // width) for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == bytes.fromhex('d8c048')]
|
|
if not at:
|
|
return 0, 0
|
|
xs = [x for x, y in at]
|
|
return len(at), max(xs) - min(xs)
|
|
e = bits('$BUILD/burst1400000.ppm'); l = bits('$BUILD/burst1800000.ppm')
|
|
print(e[0], e[1], l[0], l[1], bits('$BUILD/burst2400000.ppm')[0])
|
|
" 2>/dev/null || echo "0 0 0 0 -1")
|
|
EOT
|
|
# Forty pixels in one eight wide block is the lander; anything else in its colour is what is
|
|
# left of it. Both samples have to have SOMETHING, or the next check compares two nothings.
|
|
[ "$EARLYBITS" -gt 0 ] && [ "$EARLYBITS" != "40" ] && [ "$LATEBITS" -gt 0 ] \
|
|
&& result ok "a crash breaks the lander into pieces" "$EARLYBITS pixels of it, not the forty it was" \
|
|
|| result no "a crash breaks the lander into pieces" "$EARLYBITS early, $LATEBITS later"
|
|
[ "$LATEWIDE" -gt "$EARLYWIDE" ] \
|
|
&& result ok "and they carry on outwards" "$EARLYWIDE pixels apart, then $LATEWIDE" \
|
|
|| result no "and they carry on outwards" "$EARLYWIDE pixels apart, then $LATEWIDE"
|
|
# And they are gone by the time the verdict is up, rather than hanging over the words.
|
|
[ "$AFTERBITS" = "0" ] && grep -q "Crashed" "$BUILD/burst2400000.out" \
|
|
&& result ok "and are gone before the verdict" "nothing left over the words" \
|
|
|| result no "and are gone before the verdict" "$AFTERBITS pixels still there"
|
|
|
|
# ---- And a bang to go with it ----
|
|
#
|
|
# The first sound this game makes, and the first time anything has driven the sound device
|
|
# while also doing something else - the only other customer is the patch editor, whose whole
|
|
# job is the device.
|
|
#
|
|
# A patch is twenty odd writes and a note is two, which is what the selector and value
|
|
# registers are for: the instrument is built at startup like the tiles are, and a crash only
|
|
# says "this channel, this note". Channel THREE, effects counting down from the top, so music
|
|
# can one day take nought and count up without the two having to negotiate.
|
|
#
|
|
# Checked against silence BEFORE it rather than against a quiet landing, because a landing is
|
|
# not quiet any more - it latches two notes of its own. The silence that still means something
|
|
# is the second before the crash: the machine does not hum, so anything there is the event.
|
|
timeout 60 "$EMU" --fast --cycles 3000000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/none.pad" --sound "$BUILD/bang.raw" \
|
|
--disk "$BUILD/crash.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/bang.out" 2>&1 || true
|
|
read -r BANGPEAK BANGLOUD BANGFADE HUSHPEAK <<EOT
|
|
$(python3 -c "
|
|
import struct
|
|
def look(path):
|
|
raw = open(path, 'rb').read()
|
|
s = struct.unpack('<%dh' % (len(raw) // 2), raw)
|
|
if not s:
|
|
return 0, 0, 0
|
|
peak = max(abs(v) for v in s)
|
|
loud = sum(1 for v in s if abs(v) > 500)
|
|
# Louder at the front than at the back, which is what a bang is and a hiss is not.
|
|
half = len(s) // 2
|
|
front = max((abs(v) for v in s[:half]), default=0)
|
|
back = max((abs(v) for v in s[half:]), default=0)
|
|
return peak, loud, (1 if front > back else 0)
|
|
b = look('$BUILD/bang.raw')
|
|
# The second before it, which is a lander falling and making no noise at all.
|
|
import struct
|
|
raw = open('$BUILD/bang.raw', 'rb').read()
|
|
s = struct.unpack('<%dh' % (len(raw) // 2), raw)
|
|
before = max((abs(v) for v in s[:48000]), default=0)
|
|
print(b[0], b[1], b[2], before)
|
|
" 2>/dev/null || echo "0 0 0 -1")
|
|
EOT
|
|
[ "$BANGPEAK" -gt 2000 ] && [ "$BANGLOUD" -gt 5000 ] && [ "$HUSHPEAK" = "0" ] \
|
|
&& result ok "a crash makes a noise out of silence" "peak $BANGPEAK, and nothing at all before it" \
|
|
|| result no "a crash makes a noise out of silence" "crash peak $BANGPEAK, $HUSHPEAK beforehand"
|
|
# ---- And it fades ----
|
|
#
|
|
# A byte of envelope is not seconds: it is squared and scaled to four of them, so the first
|
|
# decay written here was 200, which is two and a half - and over the eight tenths of a second
|
|
# the pieces are in the air that is not a bang fading, it is the FRONT THIRD of one. It read
|
|
# as a flat wash of noise and measured as one.
|
|
[ "$BANGFADE" = "1" ] \
|
|
&& result ok "and it is loudest at the front" "which is what makes it a bang" \
|
|
|| result no "and it is loudest at the front" "louder in the second half than the first"
|
|
|
|
# ---- The thruster: a bang at ignition and a rumble under it ----
|
|
#
|
|
# The rumble is the first HELD note this game plays, and a held note outlives whatever was
|
|
# holding it. Its gate goes down when a thruster lights and does not come up until every one
|
|
# of them is out - so what is checked is that it goes QUIET again, which is the half that can
|
|
# fail silently in the other direction.
|
|
#
|
|
# Placed high with a full tank so the burn can be let go of with plenty of air left: three
|
|
# clear seconds between the thruster going out and the ground arriving.
|
|
python3 -c "
|
|
import struct
|
|
open('$BUILD/high.state','wb').write(struct.pack('<hhhhhhBBBB',
|
|
0x14A0, -2000, 0, 0, 0x14A0, 2600, 255, 0, 0, 0))
|
|
"
|
|
cp "$ROOT/Tests/build/disks/cosmos.img" "$BUILD/high.img"
|
|
"$ROOT/SplitDisk" put "$BUILD/high.img" "$BUILD/high.state" /lander.state > /dev/null
|
|
python3 -c "open('$BUILD/hold.pad','wb').write(b'\x00' * 130 + b'\x08' * 60 + b'\x00' * 40000)"
|
|
timeout 60 "$EMU" --fast --cycles 12000000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/hold.pad" --sound "$BUILD/hold.raw" \
|
|
--disk "$BUILD/high.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/hold.out" 2>&1 || true
|
|
# ---- Every thruster that catches pops, not only the first ----
|
|
#
|
|
# A pilot already burning upwards who then adds a sideways thruster has lit an engine, and
|
|
# that is what an engine lighting sounds like. So what is watched is the SET of them: the bits
|
|
# on now that were not on before, which is an exclusive or and an and. "Was anything on" is a
|
|
# different and poorer question.
|
|
#
|
|
# Two flights of the same length: one thruster held throughout, against one that gains a
|
|
# second in the middle and loses it again. The rumble is identical in both - it is struck once
|
|
# and runs until everything is out - so the whole of the difference is the extra pop.
|
|
python3 -c "open('$BUILD/onethruster.pad','wb').write(b'\x00' * 130 + b'\x08' * 180 + b'\x00' * 20000)"
|
|
python3 -c "open('$BUILD/twothrusters.pad','wb').write(b'\x00' * 130 + b'\x08' * 60 + b'\x0A' * 60 + b'\x08' * 60 + b'\x00' * 20000)"
|
|
for view in onethruster twothrusters; do
|
|
timeout 60 "$EMU" --fast --cycles 12000000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/$view.pad" --sound "$BUILD/$view.raw" \
|
|
--disk "$BUILD/high.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/$view.out" 2>&1 || true
|
|
done
|
|
|
|
# ---- And landing with the thruster STILL DOWN ----
|
|
#
|
|
# The case the release above cannot reach. Braking into the ground ends the flying and then
|
|
# waits to be told the message has been read, so the frame that would have noticed the button
|
|
# coming up never runs - and a held note outlives the loop that was holding it. It roared under
|
|
# the verdict and went on roaring until the machine stopped.
|
|
python3 -c "open('$BUILD/brakeland.pad','wb').write(b'\x00' * 150 + b'\x08' * 90 + b'\x00' * 8000)"
|
|
timeout 60 "$EMU" --fast --cycles 8000000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/brakeland.pad" --sound "$BUILD/braked.raw" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/braked.out" 2>&1 || true
|
|
# And a docking, where the latch sounds on its own with nothing else going on.
|
|
timeout 60 "$EMU" --fast --cycles 8000000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/none.pad" --sound "$BUILD/latch.raw" \
|
|
--disk "$BUILD/dock.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/latch.out" 2>&1 || true
|
|
read -r BURNING AFTERWARDS LATCHLONG UNDERVERDICT ONELIT TWOLIT <<EOT
|
|
$(python3 -c "
|
|
import struct
|
|
def samples(path):
|
|
raw = open(path, 'rb').read()
|
|
return struct.unpack('<%dh' % (len(raw) // 2), raw)
|
|
def peak(s, a, b):
|
|
w = s[a:b]
|
|
return max((abs(v) for v in w), default=0)
|
|
h = samples('$BUILD/hold.raw')
|
|
b = samples('$BUILD/braked.raw')
|
|
l = samples('$BUILD/latch.raw')
|
|
loud = [i for i, x in enumerate(l) if abs(x) > 300]
|
|
span = (loud[-1] - loud[0]) if loud else 0
|
|
# During the burn, and a second and a half after it, which is still long before the ground.
|
|
# The burn, wide enough that a few frames of drift in when the program starts cannot move
|
|
# the event out of it.
|
|
one = peak(samples('$BUILD/onethruster.raw'), 100000, 260000)
|
|
two = peak(samples('$BUILD/twothrusters.raw'), 100000, 260000)
|
|
print(peak(h, 115200, 124800), peak(h, 192000, 288000), span, peak(b, 240000, 336000),
|
|
one, two)
|
|
" 2>/dev/null || echo "0 -1 0 -1 0 0")
|
|
EOT
|
|
[ "$BURNING" -gt 1000 ] \
|
|
&& result ok "a held thruster rumbles" "$BURNING while it burns" \
|
|
|| result no "a held thruster rumbles" "$BURNING while it burns"
|
|
# ---- And stops ----
|
|
#
|
|
# A gated note ends when its gate comes up and at no other time. Landing while the thruster was
|
|
# still down used to leave it roaring under the verdict for ever, because the frame that would
|
|
# have noticed the button coming up never ran - so anything that stops to wait now hushes it.
|
|
[ "$AFTERWARDS" = "0" ] \
|
|
&& result ok "and goes quiet when it stops" "silence a second and a half later" \
|
|
|| result no "and goes quiet when it stops" "still $AFTERWARDS long after the thruster went out"
|
|
# Two notes an eighth of a second apart, so the whole thing outlasts either of them. One note
|
|
# is about a fifth of a second; this is four tenths.
|
|
[ "$LATCHLONG" -gt 14400 ] \
|
|
&& result ok "and a dock latches on two notes" "$LATCHLONG samples, which is longer than one" \
|
|
|| result no "and a dock latches on two notes" "$LATCHLONG samples, which is about one note"
|
|
# Two seconds of the verdict being up, with nothing under it.
|
|
[ "$UNDERVERDICT" = "0" ] \
|
|
&& result ok "and nothing roars under the verdict" "landed on the thruster, and it stopped anyway" \
|
|
|| result no "and nothing roars under the verdict" "$UNDERVERDICT still sounding after landing"
|
|
[ "$TWOLIT" -gt "$(( ONELIT + 2000 ))" ] \
|
|
&& result ok "and a second thruster pops as well" "$ONELIT alone against $TWOLIT with two" \
|
|
|| result no "and a second thruster pops as well" "$ONELIT alone, $TWOLIT with two"
|
|
|
|
# ---- A quarter of a tank, said once ----
|
|
#
|
|
# ONCE is the whole design of it. A lander is at its most careful in the last few seconds
|
|
# before it touches, and something repeating in its ear through that is not a warning, it is a
|
|
# distraction. So it fires on the way DOWN through a quarter tank and then holds its peace,
|
|
# and filling up at a base allows it again.
|
|
#
|
|
# The gauge says the same thing the other way round: the sound is the moment it happened, the
|
|
# colour is how things stand. Both off the same number, so they cannot disagree.
|
|
python3 -c "
|
|
import struct
|
|
# Seventy of two hundred and fifty five, which is a few ticks of thruster above the quarter.
|
|
open('$BUILD/lowfuel.state','wb').write(struct.pack('<hhhhhhBBBB',
|
|
0x14A0, 0, 0, 0, 0x14A0, 2600, 70, 0, 0, 0))
|
|
"
|
|
cp "$ROOT/Tests/build/disks/cosmos.img" "$BUILD/lowfuel.img"
|
|
"$ROOT/SplitDisk" put "$BUILD/lowfuel.img" "$BUILD/lowfuel.state" /lander.state > /dev/null
|
|
# Three hundred frames of thruster and then nothing. Long enough to drain past a quarter
|
|
# several times over, and it STOPS - which is what makes the measurement below possible.
|
|
python3 -c "open('$BUILD/burn.pad','wb').write(b'\x08' * 300 + b'\x00' * 40000)"
|
|
for when in 1700000 2400000; do
|
|
timeout 60 "$EMU" --fast --cycles $when --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/burn.pad" --screen "$BUILD/fuel$when.ppm" \
|
|
--disk "$BUILD/lowfuel.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/fuel.out" 2>&1 || true
|
|
done
|
|
timeout 60 "$EMU" --fast --cycles 12000000 --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/burn.pad" --sound "$BUILD/warn.raw" \
|
|
--disk "$BUILD/lowfuel.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/warn.out" 2>&1 || true
|
|
read -r ABOVE BELOW WARNINGS <<EOT
|
|
$(python3 -c "
|
|
def gauge(path):
|
|
d = open(path, 'rb').read()
|
|
width = int(d[:40].split()[1])
|
|
px = d[d.index(b'255\n') + 4:]
|
|
def cells(colour):
|
|
c = bytes.fromhex(colour)
|
|
# The top row of the window, which is where the gauge is drawn.
|
|
return sum(1 for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == c and i // width < 8) // 64
|
|
return '%d,%d' % (cells('50c050'), cells('d04038'))
|
|
import struct
|
|
raw = open('$BUILD/warn.raw', 'rb').read()
|
|
s = struct.unpack('<%dh' % (len(raw) // 2), raw)
|
|
# ---- WHAT IS STILL SOUNDING once the thruster stops ----
|
|
#
|
|
# Two measurements failed before this one. Counting bursts cannot tell a beep from a nag: the
|
|
# warning would fire every tick the tank drops, far faster than the sound decays, so the beeps
|
|
# run together and a burst counter sees one long burst either way. Measuring the total length
|
|
# stopped working the day the thruster got a rumble of its own, which sounds for the whole burn
|
|
# whatever the warning does.
|
|
#
|
|
# So: burn, stop, and listen AFTER. Warned once, the beep went long ago and the rumble releases
|
|
# at once, so there is nothing. Nagging, the last beep was on the burn's final tick and is
|
|
# still fading. Measured, that is nought against five thousand six hundred.
|
|
after = max((abs(x) for x in s[250000:275000]), default=0)
|
|
print(gauge('$BUILD/fuel1700000.ppm'), gauge('$BUILD/fuel2400000.ppm'), after)
|
|
" 2>/dev/null || echo "none none -1")
|
|
EOT
|
|
# Green cells then red cells, and none of the other colour either time.
|
|
[ "$ABOVE" = "8,0" ] && [ "$BELOW" = "0,7" ] \
|
|
&& result ok "the fuel gauge goes red at a quarter" "eight green became seven red" \
|
|
|| result no "the fuel gauge goes red at a quarter" "$ABOVE above, $BELOW below"
|
|
[ "$WARNINGS" -lt 500 ] \
|
|
&& result ok "and the warning sounds once, not once a tick" "nothing left over when the burn ends" \
|
|
|| result no "and the warning sounds once, not once a tick" "$WARNINGS still sounding after the burn"
|
|
|
|
# ---- Dust on landing, and gas on letting go ----
|
|
#
|
|
# The same six pieces as the explosion, thrown differently: dust goes sideways and UP off the
|
|
# lander's feet, because that is where kicked dust goes and there is ground in the way of the
|
|
# rest of it; gas goes evenly in every direction, because nothing is in the way of a docking
|
|
# port. Neither happens on DOCKING - a dock is a catch, not a touchdown, and there is nothing
|
|
# under it to kick.
|
|
#
|
|
# ---- The gas is the one that must not stop the world ----
|
|
#
|
|
# It goes off on the frame a thruster is pressed. The explosion can afford to hold everything
|
|
# still, because there is nothing left to fly; freezing a quarter of a second exactly as the
|
|
# controls are used would be felt as them sticking. So the check watches the LANDER MOVE while
|
|
# the gas has gone, and how FAR it has got by then.
|
|
python3 -c "
|
|
import struct
|
|
open('$BUILD/undock.state','wb').write(struct.pack('<hhhhhhBBBB',
|
|
0x34A0 - 64, -1536 + 64, 64, 0, 0x34A0, -1536, 100, 1, 0, 0))
|
|
"
|
|
cp "$ROOT/Tests/build/disks/cosmos.img" "$BUILD/undock.img"
|
|
"$ROOT/SplitDisk" put "$BUILD/undock.img" "$BUILD/undock.state" /lander.state > /dev/null
|
|
# Acknowledge the dock at frame 400, then hold up from 600 to let go of it.
|
|
python3 -c "open('$BUILD/undock.pad','wb').write(b'\x00' * 400 + b'\x10' * 8 + b'\x00' * 192 + b'\x08' * 4000 + b'\x00' * 20000)"
|
|
for when in 9900000 10150000 10800000; do
|
|
timeout 60 "$EMU" --fast --cycles $when --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/undock.pad" --screen "$BUILD/gas$when.ppm" \
|
|
--disk "$BUILD/undock.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/gas.out" 2>&1 || true
|
|
done
|
|
# And a plain fall onto the pad it starts over, which lands rather than crashes.
|
|
# Eight frames later than they used to be: the landing latch plays its two notes before the
|
|
# dust goes up, and runPend waits for the second of them.
|
|
for when in 3100000 3250000 3500000; do
|
|
timeout 60 "$EMU" --fast --cycles $when --keyboard "$BUILD/fly.keys" \
|
|
--pad "$BUILD/none.pad" --screen "$BUILD/dust$when.ppm" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/dust.out" 2>&1 || true
|
|
done
|
|
read -r DUSTBEFORE DUSTDURING DUSTAFTER GASBEFORE GASDURING GASAFTER SHIPHELD SHIPFREE <<EOT
|
|
$(python3 -c "
|
|
def look(path):
|
|
d = open(path, 'rb').read()
|
|
width = int(d[:40].split()[1])
|
|
px = d[d.index(b'255\n') + 4:]
|
|
def where(colour):
|
|
c = bytes.fromhex(colour)
|
|
return [(i % width, i // width) for i in range(len(px) // 3)
|
|
if px[i * 3:i * 3 + 3] == c]
|
|
# White BELOW THE WINDOW. The window's own text is white and sits in the top two rows.
|
|
puff = len([1 for x, y in where('f0f0f0') if y > 20])
|
|
ship = where('d8c048')
|
|
return puff, (min(y for x, y in ship) if ship else -1)
|
|
print(look('$BUILD/dust3100000.ppm')[0], look('$BUILD/dust3250000.ppm')[0],
|
|
look('$BUILD/dust3500000.ppm')[0],
|
|
look('$BUILD/gas9900000.ppm')[0], look('$BUILD/gas10150000.ppm')[0],
|
|
look('$BUILD/gas10800000.ppm')[0],
|
|
look('$BUILD/gas10150000.ppm')[1], look('$BUILD/gas10800000.ppm')[1])
|
|
" 2>/dev/null || echo "-1 -1 -1 -1 -1 -1 -1 -1")
|
|
EOT
|
|
[ "$DUSTBEFORE" = "0" ] && [ "$DUSTDURING" -gt 0 ] && [ "$DUSTAFTER" = "0" ] \
|
|
&& result ok "a landing kicks up dust" "none, then $DUSTDURING pixels of it, then none" \
|
|
|| result no "a landing kicks up dust" "$DUSTBEFORE before, $DUSTDURING during, $DUSTAFTER after"
|
|
[ "$GASBEFORE" = "0" ] && [ "$GASDURING" -gt 0 ] && [ "$GASAFTER" = "0" ] \
|
|
&& result ok "and letting go of the station vents gas" "none, then $GASDURING pixels, then none" \
|
|
|| result no "and letting go of the station vents gas" "$GASBEFORE before, $GASDURING during, $GASAFTER after"
|
|
# ---- Which is the half that matters ----
|
|
#
|
|
# The lander has to be somewhere ELSE by the time the gas has gone. If letting go froze the
|
|
# world it would still be at the row it undocked from - and so would it if the lander re-docked
|
|
# on the next frame, which is what happened before a lander had to get clear of the station
|
|
# before it could take hold of it again: it took the fuel again, said so again, and sat waiting
|
|
# to be told the message had been read.
|
|
# HOW FAR it has got, not merely that it moved. Twelve rows is what a free run manages by
|
|
# then; a sixteen frame pause on letting go still leaves it climbing, just four rows short - so
|
|
# "it moved" passes for a stall that has been slept through and this does not.
|
|
[ "$SHIPFREE" -gt 0 ] && [ "$(( SHIPHELD - SHIPFREE ))" -ge 8 ] \
|
|
&& result ok "and the lander is well away, not merely moving" "row $SHIPHELD became row $SHIPFREE" \
|
|
|| result no "and the lander is well away, not merely moving" "row $SHIPHELD, then row $SHIPFREE"
|
|
|
|
# ---- 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
|
|
|
|
# ---- A tab moves the cursor to the next stop ----
|
|
#
|
|
# The console gave newline, carriage return and backspace meanings and dropped every other
|
|
# byte below the first glyph, so a tab left no mark on the screen at all - while the same
|
|
# byte went down the serial line, where a host terminal laid it out. A tab separated file
|
|
# read perfectly and displayed as its fields run together, which is exactly what Type and
|
|
# More did to an assembler symbol table.
|
|
#
|
|
# CHECKED AS A PICTURE AGAINST THE SPACES IT STANDS FOR, rather than by reading a cell. If a
|
|
# tab from column one reaches column eight, then "A" tab "B" and "A" seven spaces "B" put the
|
|
# same glyphs in the same cells and leave the cursor in the same place, so the two screens are
|
|
# the same bytes. That tests where the cursor landed without this suite having to learn how a
|
|
# glyph is stored.
|
|
run tabstop <<'ASM'
|
|
#Program
|
|
start:
|
|
INIA 0x41 ; 'A'
|
|
OUTA 0x00
|
|
INIA 0x09 ; Tab, which should reach column eight.
|
|
OUTA 0x00
|
|
INIA 0x42 ; 'B'
|
|
OUTA 0x00
|
|
HALT
|
|
ASM
|
|
run tabspaces <<'ASM'
|
|
#Program
|
|
start:
|
|
INIA 0x41 ; 'A'
|
|
OUTA 0x00
|
|
INIB 0d7 ; The seven spaces that get to the same place.
|
|
spaces:
|
|
INIA 0x20
|
|
OUTA 0x00
|
|
DECB
|
|
BNB spaces
|
|
INIA 0x42 ; 'B'
|
|
OUTA 0x00
|
|
HALT
|
|
ASM
|
|
if cmp -s "$BUILD/tabstop.ppm" "$BUILD/tabspaces.ppm"; then
|
|
result ok "a tab reaches the next stop" "the same screen as the spaces it stands for"
|
|
else
|
|
result no "a tab reaches the next stop" "the screen differs from the same line spaced out"
|
|
fi
|
|
|
|
# ---- And a tab that would leave the screen wraps, the way a character does ----
|
|
#
|
|
# The screen is forty columns and the stops are eight apart, so from column thirty three the
|
|
# next stop is forty, which is off the end. An ordinary character at the edge wraps, and a
|
|
# tab that stopped in the last column instead would be inventing a rule only tabs obey.
|
|
run tabwrap <<'ASM'
|
|
#Program
|
|
start:
|
|
INIB 0d33 ; Columns nought to thirty two, so the cursor is at thirty three.
|
|
fill:
|
|
INIA 0x41
|
|
OUTA 0x00
|
|
DECB
|
|
BNB fill
|
|
INIA 0x09 ; The next stop is forty, which is the edge.
|
|
OUTA 0x00
|
|
INIA 0x42
|
|
OUTA 0x00
|
|
HALT
|
|
ASM
|
|
run tabwrapped <<'ASM'
|
|
#Program
|
|
start:
|
|
INIB 0d33
|
|
fill:
|
|
INIA 0x41
|
|
OUTA 0x00
|
|
DECB
|
|
BNB fill
|
|
INIA 0x0A ; A new line, which is where the tab should have gone.
|
|
OUTA 0x00
|
|
INIA 0x42
|
|
OUTA 0x00
|
|
HALT
|
|
ASM
|
|
if cmp -s "$BUILD/tabwrap.ppm" "$BUILD/tabwrapped.ppm"; then
|
|
result ok "and one that runs off the edge wraps" "the same screen as a new line"
|
|
else
|
|
result no "and one that runs off the edge wraps" "it did not go to the next row"
|
|
fi
|
|
|
|
# ---- A tab does not rub out what it passes over ----
|
|
#
|
|
# The other way to make a tab visible is to write spaces to the next stop, and it is wrong
|
|
# for the reason a terminal does not do it: a cursor moved back to the start of a line and
|
|
# tabbed forward would erase the line it moved over. This writes a row, returns to the front
|
|
# of it, and tabs.
|
|
run tabkeeps <<'ASM'
|
|
#Program
|
|
start:
|
|
INIB 0d16
|
|
fill:
|
|
INIA 0x41 ; Sixteen letters.
|
|
OUTA 0x00
|
|
DECB
|
|
BNB fill
|
|
INIA 0x0D ; Back to the front of the line, without leaving it.
|
|
OUTA 0x00
|
|
INIA 0x09 ; And a tab across the letters already there.
|
|
OUTA 0x00
|
|
INIA 0x42 ; Which lands in column eight, on top of one of them.
|
|
OUTA 0x00
|
|
HALT
|
|
ASM
|
|
LETTERS="$(python3 - "$BUILD/tabkeeps.ppm" <<'PY2'
|
|
import sys
|
|
data = open(sys.argv[1], "rb").read()
|
|
fields = data.split(b"\n", 3)
|
|
width, height = (int(n) for n in fields[1].split())
|
|
body = fields[3]
|
|
# The first row of cells is eight pixels tall. A cell that still holds a letter has ink in
|
|
# it; one a tab blanked has none. Counting ink across the row says how many survived.
|
|
ink = 0
|
|
for cell in range(16):
|
|
lit = False
|
|
for y in range(8):
|
|
for x in range(cell * 8, cell * 8 + 8):
|
|
at = (y * width + x) * 3
|
|
if body[at:at + 3] != body[0:3]:
|
|
lit = True
|
|
if lit:
|
|
ink += 1
|
|
print(ink)
|
|
PY2
|
|
)"
|
|
[ "$LETTERS" = "16" ] && result ok "and does not rub out what it moves over" "all sixteen letters are still there" || result no "and does not rub out what it moves over" "$LETTERS of sixteen cells still have ink"
|
|
|
|
# ---- Running a program twice is the same as running it once ----
|
|
#
|
|
# RUN DOES NOT RELOAD, and that is right: run's job is to run the program that is loaded,
|
|
# which is why it is a separate word from load and why typing a program's name does both. It
|
|
# follows that the numbers the assembler wrote into a Data Segment are LOAD-time values, true
|
|
# once - and a program that wants them true at every start has to say so itself.
|
|
#
|
|
# Lander did not. A second run began with Flying already nought, so the loop ended before it
|
|
# started and the program handed the machine straight back. Worse and quieter: the terrain
|
|
# seed is a written number, so a fresh load always walks out the same moon - but nextRandom
|
|
# moves it, and a second run generated a different moon, which was nobody's decision.
|
|
#
|
|
# THE CHECK IS THE WHOLE SCREEN, because that is the only thing that knows about all of it at
|
|
# once. A hundred frames in, run once and run twice have to be the same picture: the same
|
|
# moon, the same lander in the same place, the same gauges. Any state that survives a run and
|
|
# should not shows up here without anybody having to have thought of it in advance.
|
|
python3 -c "
|
|
SKIP = b'\x20' # a key the game ignores, so it skips the splash and nothing else
|
|
open('$BUILD/once.keys','wb').write(b'Lander\n' + SKIP + b'\x00'*100)
|
|
open('$BUILD/twice.keys','wb').write(b'Lander\n' + SKIP + b'\x00'*100 + b'q' + b'\x00'*20 + b'run\n' + SKIP + b'\x00'*100)
|
|
open('$BUILD/still.pad','wb').write(b'\x00' * 20000)"
|
|
for WHEN in once twice; do
|
|
timeout 60 "$EMU" --fast --cycles 24000000 --keyboard "$BUILD/$WHEN.keys" \
|
|
--pad "$BUILD/still.pad" --screen "$BUILD/run$WHEN.ppm" \
|
|
--disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \
|
|
"$BUILD/cosmos.bin" > "$BUILD/run$WHEN.out" 2>&1 || true
|
|
done
|
|
if [ ! -f "$BUILD/runonce.ppm" ] || [ ! -f "$BUILD/runtwice.ppm" ]; then
|
|
result no "a program run twice starts where it started" "no picture came out"
|
|
elif cmp -s "$BUILD/runonce.ppm" "$BUILD/runtwice.ppm"; then
|
|
result ok "a program run twice starts where it started" "the same screen a hundred frames in"
|
|
else
|
|
DIFFERS="$(python3 -c "
|
|
a = open('$BUILD/runonce.ppm','rb').read()
|
|
b = open('$BUILD/runtwice.ppm','rb').read()
|
|
print(sum(1 for x, y in zip(a, b) if x != y))")"
|
|
result no "a program run twice starts where it started" "$DIFFERS bytes of the picture differ"
|
|
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
|