#!/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 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 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 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
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
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 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 [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 pokeAtlas "$(( 0xD000 + $1 ))" "$2" } # Every pixel of one tile the same index. Tile n begins at n times 64. solidTile() { # solidTile 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 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 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 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 printf ' INIA 0x%02X\n OUTA 0x%02X\n' $(( $2 & 0xFF )) $(( $1 & 0xFF )) } show() { # show - sends a port's value to the console, so a test can read a register. # INA reads straight into A, so there is nothing to move first. printf ' INA 0x%02X\n OUTA 0x00\n' $(( $1 & 0xFF )) } epilogue() { printf ' HALT\n#Vectors\n Boot start\n' } # One byte to the console, by its number. emit() { printf ' INIA 0d%d\n OUTA 0x00\n' "$1" } # 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/.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 </dev/null || echo "0 0 0 0") EOT [ "$BALLLEFT" -gt 0 ] && [ "$BALLRIGHT" -gt 0 ] \ && result ok "the ball is straddling the near pillar" "showing on both sides of it" \ || result no "the ball is straddling the near pillar" "left $BALLLEFT, right $BALLRIGHT - re-tune the cycle count" [ "$BALLOVER" = "0" ] && [ "$PILLAR" -gt 0 ] \ && result ok "and the near pillar hides the middle" "sprite nought, behind sprite one" \ || result no "and the near pillar hides the middle" "$BALLOVER ball pixels across a pillar of $PILLAR" # ---- Lunar Porter, flying ---- # # The biggest program on the disk, and the one that uses the most of the machine at once: it # takes the screen, changes the mode, redefines tiles, fills all 3,200 cells of the map from a # terrain it generated, and moves a sprite over it every frame. # # A burn of the lifting thruster every forty eighth frame, which is exactly the gravity: one # sixteenth of a pixel every sixth frame against eight every forty eighth. Exactly cancelling # the ACCELERATION still leaves an average velocity, so it drifts upward slowly and this is # caught early rather than left to settle. # # EARLY, and that is the point of 900 thousand cycles. Earlier again than it used to be: the # window grew to two rows when the messages moved into it, and by 1.5 million the lander had # climbed to row nine and was mostly BEHIND the status bar - which is the window doing exactly # what it is meant to and left the check counting six pixels of a forty pixel lander. This program starts almost at once - the # first guess at where to look was ten million cycles in, on the assumption that taking a # screen was expensive, and by then the lander had flown 700 frames and left the picture. A # capture near the start is worth far more than a tuned one: there is much less between it and # the beginning that can move. python3 -c " keys = b'Lander\n' for i in range(400): keys += b'\x80' + b'\x00' * 47 open('$BUILD/lander.keys','wb').write(keys + b'\x00' * 8000) " timeout 30 "$EMU" --fast --cycles 900000 --keyboard "$BUILD/lander.keys" \ --screen "$BUILD/lander.ppm" --disk "$ROOT/Tests/build/disks/cosmos.img" \ --ram-disk 2048 "$BUILD/cosmos.bin" > "$BUILD/lander.out" 2>&1 || true read -r MOON SKY SHIP SHIPLEFT SHIPRIGHT </dev/null || echo "0 0 0 -1 -1") EOT # A moon that filled the screen or left it empty would be a terrain generator that had run # off one end of its clamp, which is the way that kind of loop usually fails. [ "$MOON" -gt 10000 ] && [ "$SKY" -gt 10000 ] \ && result ok "a moon with a sky over it" "$MOON pixels of ground and $SKY of sky" \ || result no "a moon with a sky over it" "$MOON ground, $SKY sky" # It never moves sideways: the world scrolls under it, so it is at the middle of a 320 pixel # screen every frame of its life. Forty pixels is the whole of the shape, so none of it has # been clipped or drawn twice. [ "$SHIP" = "40" ] && [ "$SHIPLEFT" = "156" ] && [ "$SHIPRIGHT" = "163" ] \ && result ok "and a lander in the middle of it" "all forty pixels, at the screen's centre" \ || result no "and a lander in the middle of it" "$SHIP pixels, x $SHIPLEFT to $SHIPRIGHT" # ---- The same lander, after the shell has scrolled ---- # # THE SHELL SCROLLS, and its row origin is wherever the last command left it. The map is a # ring 128 rows tall that the screen shows 25 of, so a moon drawn into rows nought to 24 while # the screen is looking at row forty is a moon nobody can see - which came out as terrain that # was missing, or half there, depending on how far down the prompt had got. # # EIGHTY returns before the program is started. Forty was the first try and it caught nothing, # because the shell runs an eighty column screen which is FIFTY ROWS TALL - forty returns fill # it and never scroll it, so the origin was still nought and the check passed against a # version with the fix taken out. The screenful that matters is the one the shell is using, # not the one the program is about to ask for. The moon has to come out exactly as it does from a fresh # prompt: the rows a program WRITES and the rows the screen READS are two different things, # and only one of them is under the program's control. python3 -c "open('$BUILD/scrolled.keys','wb').write(b'\n' * 80 + b'Lander\n' + b'\x00' * 3000)" python3 -c "open('$BUILD/scrolled.pad','wb').write(b'\\x00' * 40 + b'\\x08' * 400)" timeout 30 "$EMU" --fast --cycles 1500000 --keyboard "$BUILD/scrolled.keys" \ --pad "$BUILD/scrolled.pad" --screen "$BUILD/scrolled.ppm" \ --disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \ "$BUILD/cosmos.bin" > "$BUILD/scrolled.out" 2>&1 || true SCROLLEDMOON="$(python3 -c " d = open('$BUILD/scrolled.ppm', 'rb').read() px = d[d.index(b'255\n') + 4:] moon = bytes.fromhex('d8d8d8') print(sum(1 for o in range(0, len(px), 3) if px[o:o + 3] == moon)) " 2>/dev/null || echo 0)" [ "$SCROLLEDMOON" -gt 10000 ] \ && result ok "a scrolled shell does not hide the moon" "$SCROLLEDMOON pixels of it, drawn where the screen looks" \ || result no "a scrolled shell does not hide the moon" "$SCROLLEDMOON pixels of ground" # ---- And the same lander, on a controller ---- # # A pad reports what is HELD, so the thruster can be leaned on rather than pumped - which is # the whole reason the device exists and the one thing the console cannot express. The # recording holds nothing for forty frames and then holds Up. # # What is checked is that it is HIGHER LATER: two captures, the second further on, and the # lander nearer the top in the second. A thruster that only fired once for a held button # would let it fall between them instead, which is exactly what the console does. python3 -c "open('$BUILD/lander.pad','wb').write(b'\x00' * 40 + b'\x08' * 400)" for when in 800000 1600000; do timeout 30 "$EMU" --fast --cycles $when --keyboard "$BUILD/lander.keys" \ --pad "$BUILD/lander.pad" --screen "$BUILD/held$when.ppm" \ --disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \ "$BUILD/cosmos.bin" > "$BUILD/held.out" 2>&1 || true done read -r EARLY LATE </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(' /dev/null for when in 3000000 13000000 25000000 37000000; 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 </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 </dev/null || echo "-1 -1") EOT [ "$GAUGEEARLY" -gt 0 ] && [ "$GAUGELATE" -gt 0 ] && [ "$GAUGELATE" -lt "$GAUGEEARLY" ] \ && result ok "a held thruster spends the tank" "the gauge ran to $GAUGEEARLY, then to $GAUGELATE" \ || result no "a held thruster spends the tank" "$GAUGEEARLY early, $GAUGELATE later" # ---- And the system takes the window down ---- # # A window is a layer at a screen position that does not scroll, which is exactly what makes # one left behind so unpleasant: it sits over the top of whatever comes next and cannot be # scrolled off, cleared away or typed past. Lunar Porter left its fuel gauge up and the shell # came back with FUEL across the top and the cursor underneath it. # # Taken away rather than given back, like the sprite table: nothing the shell draws is a # window, so there is nothing to restore - and a program that faulted while one was up could # not have taken it down itself. Lander deliberately does not, which is what leaves the # system's guarantee as the thing under test. # CLEARED AFTERWARDS, and looked at cell by cell, which is what makes this sharp. The gauge # BAR disappears on its own whatever happens - it is drawn with a tile the screen save puts # back - so counting its colour proved nothing and passed with the teardown deleted. What # survives is the LABEL, in font tiles the shell needs anyway, sitting over the top row where # clearing cannot reach it. # # With the window down a cleared screen reads "> " and a cursor: cells nought and two. With it # up the same row reads F, U, E, L across cells nought to three. So cells one and three being # empty is the whole difference, and it is 29 and 22 pixels of it rather than a threshold # somebody has to believe. python3 -c "open('$BUILD/quit.keys','wb').write(b'Lander\n' + b'\x00' * 400 + b'q' + b'\x00' * 200 + b'clear\n' + b'\x00' * 200)" timeout 30 "$EMU" --fast --cycles 14000000 --keyboard "$BUILD/quit.keys" \ --pad "$BUILD/burn.pad" --screen "$BUILD/quit.ppm" \ --disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \ "$BUILD/cosmos.bin" > "$BUILD/quit.out" 2>&1 || true read -r CELLONE CELLTHREE </dev/null || echo "-1 -1") EOT [ "$CELLONE" = "0" ] && [ "$CELLTHREE" = "0" ] \ && result ok "and the system takes the window down" "a cleared screen is clear to the top" \ || result no "and the system takes the window down" "cells one and three hold $CELLONE and $CELLTHREE pixels" # ---- The drift bar, which is a scaled sprite doing a job ---- # # A moon has no air, so a sideways drift never stops by itself and stopping one means # cancelling the velocity exactly. That is not hard; it is hard BLIND. So one sprite's width # is the drift: it grows right from the middle of the screen for a rightward one and left for # a leftward one, and NOUGHT DRAWS NOTHING, so "stopped" is the state with no bar at all. # # The recording holds Right for eighty frames and then nothing. Checked in two places: with a # drift, where the bar starts at the middle and runs right; and with none, where a sprite of # no width is a sprite that is not drawn. python3 -c "open('$BUILD/drift.pad','wb').write(b'\x00' * 20 + b'\x01' * 80 + b'\x00' * 400)" timeout 30 "$EMU" --fast --cycles 1600000 --keyboard "$BUILD/lander.keys" \ --pad "$BUILD/drift.pad" --screen "$BUILD/drift.ppm" \ --disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \ "$BUILD/cosmos.bin" > "$BUILD/drift.out" 2>&1 || true read -r BARLEFT BARWIDE STILLWIDE </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 "open('$BUILD/fly.keys','wb').write(b'Lander\n' + 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 </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 < 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 "open('$BUILD/zoom.keys','wb').write(b'Lander\n' + 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 "open('$BUILD/deep.keys','wb').write(b'\n' * 118 + b'Lander\n' + 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 </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 </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 </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 2000000 3000000 5000000 7000000; 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 3000000 --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 </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(' /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(' /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 </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(' /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 < 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(' /dev/null for when in 1200000 1600000 2200000; 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 </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/burst2200000.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, which is the useful half: a landing in the same conditions makes no # sound at all, so this is measuring the crash and not the machine humming. 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 timeout 60 "$EMU" --fast --cycles 3000000 --keyboard "$BUILD/fly.keys" \ --pad "$BUILD/none.pad" --sound "$BUILD/hush.raw" \ --disk "$ROOT/Tests/build/disks/cosmos.img" --ram-disk 2048 \ "$BUILD/cosmos.bin" > "$BUILD/hush.out" 2>&1 || true read -r BANGPEAK BANGLOUD BANGFADE HUSHPEAK < 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') print(b[0], b[1], b[2], look('$BUILD/hush.raw')[0]) " 2>/dev/null || echo "0 0 0 -1") EOT [ "$BANGPEAK" -gt 2000 ] && [ "$BANGLOUD" -gt 5000 ] && [ "$HUSHPEAK" = "0" ] \ && result ok "a crash makes a noise, and a landing does not" "peak $BANGPEAK against silence" \ || result no "a crash makes a noise, and a landing does not" "crash peak $BANGPEAK, landing peak $HUSHPEAK" # ---- 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" # ---- 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(' /dev/null python3 -c "open('$BUILD/burn.pad','wb').write(b'\x08' * 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 < 300] span = (loud[-1] - loud[0]) if loud else 0 print(gauge('$BUILD/fuel1700000.ppm'), gauge('$BUILD/fuel2400000.ppm'), span) " 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" # Twelve seconds of holding the thruster, which empties the tank entirely and keeps it empty. # The warning is under a second of that, or it is a nag rather than a warning. [ "$WARNINGS" -gt 0 ] && [ "$WARNINGS" -lt 48000 ] \ && result ok "and the warning sounds once, not once a tick" "$WARNINGS samples of it in twelve seconds of running dry" \ || result no "and the warning sounds once, not once a tick" "$WARNINGS samples of warning" # ---- 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(' /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. for when in 2700000 2820000 3100000; 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 < 20]) ship = where('d8c048') return puff, (min(y for x, y in ship) if ship else -1) print(look('$BUILD/dust2700000.ppm')[0], look('$BUILD/dust2820000.ppm')[0], look('$BUILD/dust3100000.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 echo if [ "$FAIL" -eq 0 ]; then echo "All $PASS video checks passed." exit 0 fi echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}" exit 1