Sprites: things that move without the screen moving

Everything drawn on this machine was in a cell. Something between two
cells meant rewriting both; something moving a pixel at a time meant
rewriting them sixty times a second, which is affordable for one thing
and not for twenty. A sprite is put at a pixel and the device draws it
over whatever is behind, so moving it costs two bytes.

MADE OF TILES, which is the decision the rest follows from: m by n taken
in reading order from one index, so there is no second pixel format, no
second kind of memory, and nothing a sprite can show that the map cannot.
A 16 by 16 character is four tiles and the background can name the same
four.

256 entries of 8 bytes at 0xC000 in the atlas - eight so the entry
address is a shift, the same no-multiply argument as the palette's four.
Position is signed and sixteen bits, because 640 by 400 does not fit in a
byte and a sprite has to be able to sit half off the left rather than
appearing whole at the edge.

A PIXEL OF ZERO IS NOT DRAWN, or every sprite is a rectangle. Tested
before the attribute is added, so a hole belongs to the art and not to
the colour scheme. The same rule the other way round is what "behind"
means: drawn only where the background pixel was zero, so a thing walks
behind a pillar and in front of the floor in one frame.

All of them draw, every frame, so they cannot flicker. Real machines
dropped them per scanline because they had a fixed number of shift
registers; this has a loop. The limit is the size of the table, which is
a constant rather than a property of what is on screen.

And the system takes them down at exit. The sprite table sits in the gap
the screen save walks around - to the end of the map, then the palette -
and that is right, because nothing the shell draws is a sprite: there is
nothing to give back, only something to take away. Otherwise a program
that put a ball up and left would leave it over the prompt, in front of
everything, with nothing able to type it away. Sprite.asm deliberately
leaves its own, because a program that faulted could not have cleared it.

Every check here was re-broken and failed: transparency, reading order,
draw order, priority, and size. Size needed breaking twice - the first
attempt did not compile, and a silent build failure had left the old
binary passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
Anachronaut
2026-09-02 11:42:11 -04:00
co-authored by Claude Opus 5
parent eee95ef0ce
commit a916103a7f
21 changed files with 801 additions and 14 deletions
+224 -1
View File
@@ -100,13 +100,26 @@ print(counts.most_common(1)[0][0].hex())
" "$1" 2>/dev/null || echo none
}
# How many pixels of one exact colour a picture has. What a sprite is counted by: it is a
# shape rather than a screenful, so the commonest colour says nothing about it.
countColour() {
# countColour <ppm> <rrggbb>
python3 -c "
import sys
d = open(sys.argv[1], 'rb').read()
px = d[d.index(b'255\n') + 4:]
want = bytes.fromhex(sys.argv[2])
print(sum(1 for o in range(0, len(px), 3) if px[o:o + 3] == want))
" "$1" "$2" 2>/dev/null || echo -1
}
pokeTo() {
# pokeTo <bank> <address> <byte>
printf ' INIA 0d%d\n OUTA 0xE3\n INIA 0x%02X\n OUTA 0xE4\n INIA 0x%02X\n OUTA 0xE5\n INIA 0x%02X\n OUTA 0xE9\n' \
"$1" $(( ($2 >> 8) & 0xFF )) $(( $2 & 0xFF )) $(( $3 & 0xFF ))
}
pokeAtlas() { pokeTo 3 "$1" "$2"; } # Tiles and the palette.
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.
@@ -121,6 +134,48 @@ pokeAtlasRun() {
for (( i = 0; i < $3; i++ )); do printf ' OUTA 0xE9\n'; done
}
# One entry of the sprite table, which is eight bytes at 0xC000 plus eight 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.
spriteAt() {
# spriteAt <n> <tile> <attribute> <x> <y> <size> <flags>
local base=$(( 0xC000 + $1 * 8 ))
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"
}
# Every pixel of one tile the same index. Tile n begins at n times 64.
solidTile() {
# solidTile <tile> <index>
pokeAtlasRun "$(( $1 * 64 ))" "$2" 64
}
# The top half one index and the bottom half another, which is how a tile gets a hole in it:
# index nought is what a sprite does not draw.
halfTile() {
# halfTile <tile> <top> <bottom>
pokeAtlasRun "$(( $1 * 64 ))" "$2" 32
pokeAtlasRun "$(( $1 * 64 + 32 ))" "$3" 32
}
# Four colours to tell tiles apart by, and the pair a background cell in scheme one uses.
spriteColours() {
pokeAtlas 0xFC04 0xFF; pokeAtlas 0xFC05 0x00; pokeAtlas 0xFC06 0x00 # 1 red
pokeAtlas 0xFC08 0x00; pokeAtlas 0xFC09 0xFF; pokeAtlas 0xFC0A 0x00 # 2 green
pokeAtlas 0xFC0C 0x00; pokeAtlas 0xFC0D 0x00; pokeAtlas 0xFC0E 0xFF # 3 blue
pokeAtlas 0xFC10 0xFF; pokeAtlas 0xFC11 0xFF; pokeAtlas 0xFC12 0x00 # 4 yellow
pokeAtlas 0xFC40 0x00; pokeAtlas 0xFC41 0x00; pokeAtlas 0xFC42 0x00 # 16 black paper
pokeAtlas 0xFC44 0x00; pokeAtlas 0xFC45 0xFF; pokeAtlas 0xFC46 0xFF # 17 cyan ink
}
port() {
# port <port> <byte>
printf ' INIA 0x%02X\n OUTA 0x%02X\n' $(( $2 & 0xFF )) $(( $1 & 0xFF ))
@@ -344,6 +399,145 @@ echo "Checking what the video device draws."
&& result ok "the map is a ring" "row 0 follows row 127" \
|| result no "the map is a ring" "got $(pixel ring 0 8)"
# ---- 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)"
# ---- 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
@@ -1054,6 +1248,35 @@ timeout 30 "$EMU" --fast --cycles 200000000 --keyboard "$BUILD/flipafter.keys" \
&& 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"
# ---- 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