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
+105 -1
View File
@@ -17,6 +17,18 @@
static uint8_t videoAtlas[VIDEO_MEMORY_BYTES];
static uint8_t videoScreen[VIDEO_SCREEN_COUNT][VIDEO_MEMORY_BYTES];
// ---- Where the background was empty ----
//
// One byte a pixel, set while the map or the bitmap is drawn and read while the sprites are.
// A sprite marked "behind" needs to know whether the thing already at a pixel was a picture
// or a gap, and by the time it is drawn the pixel holds a colour rather than the index it
// came from - the palette is not one to one, so two different indices can be the same
// colour and asking the picture would get it wrong.
//
// Host memory, and it costs the machine nothing: it is scratch the device uses inside one
// frame, exactly like the pixel buffer beside it.
static uint8_t backgroundEmpty[VIDEO_MAX_WIDTH * VIDEO_MAX_HEIGHT];
// Which screen is being shown. The console draws into THIS one rather than into a screen of
// its own, so text goes where whoever is looking is looking - which matters most when the
// text is a fault message printed over a game that had flipped.
@@ -346,6 +358,81 @@ uint8_t videoRead(uint8_t port) {
}
}
// ---- The sprites, over whatever is already there ----
//
// BACKWARDS THROUGH THE TABLE, so that where two overlap the lower number comes out on top:
// it is drawn last and writes over. Every entry is looked at, because the ones that draw
// nothing say so in a byte and skipping them costs one test.
static void drawSprites(uint8_t *pixels, int width, int height) {
const uint8_t *palette = videoAtlas + VIDEO_PALETTE_BASE;
for (int n = VIDEO_SPRITE_COUNT - 1; n >= 0; n--) {
const uint8_t *entry = videoAtlas + VIDEO_SPRITE_BASE + n * VIDEO_SPRITE_BYTES;
const int wide = (entry[VIDEO_SPRITE_SIZE] >> 4) & 0x0F;
const int tall = entry[VIDEO_SPRITE_SIZE] & 0x0F;
if (wide == 0 || tall == 0) {
continue;
}
// Signed, and low byte first like everything else this machine writes to a device.
const int left = (int16_t)(uint16_t)(entry[VIDEO_SPRITE_X]
| (entry[VIDEO_SPRITE_X + 1] << 8));
const int top = (int16_t)(uint16_t)(entry[VIDEO_SPRITE_Y]
| (entry[VIDEO_SPRITE_Y + 1] << 8));
const uint8_t bank = (uint8_t)((entry[VIDEO_SPRITE_ATTRIBUTE] & 0x0F) << 4);
const uint8_t flags = entry[VIDEO_SPRITE_FLAGS];
const int mirrored = (flags & VIDEO_SPRITE_HFLIP) != 0;
const int inverted = (flags & VIDEO_SPRITE_VFLIP) != 0;
const int behind = (flags & VIDEO_SPRITE_BEHIND) != 0;
for (int downTile = 0; downTile < tall; downTile++) {
for (int acrossTile = 0; acrossTile < wide; acrossTile++) {
// ---- Flipping moves the tiles as well as the pixels ----
//
// A mirrored sprite is not each of its tiles mirrored in place; the tile at
// the left end has to come out at the right end too, or a thing made of more
// than one tile turns inside out instead of round.
const int readAcross = mirrored ? (wide - 1 - acrossTile) : acrossTile;
const int readDown = inverted ? (tall - 1 - downTile) : downTile;
// In reading order from the first, and wrapping, because a byte plus a byte
// is a byte and the tile number is one.
const uint8_t tile = (uint8_t)(entry[VIDEO_SPRITE_TILE]
+ readDown * wide + readAcross);
const uint8_t *art = videoAtlas + VIDEO_TILE_BASE + tile * VIDEO_TILE_BYTES;
for (int y = 0; y < VIDEO_CELL_PIXELS; y++) {
const int atY = top + downTile * VIDEO_CELL_PIXELS + y;
if (atY < 0 || atY >= height) {
continue;
}
const int fromY = inverted ? (VIDEO_CELL_PIXELS - 1 - y) : y;
for (int x = 0; x < VIDEO_CELL_PIXELS; x++) {
const int atX = left + acrossTile * VIDEO_CELL_PIXELS + x;
if (atX < 0 || atX >= width) {
continue;
}
const int fromX = mirrored ? (VIDEO_CELL_PIXELS - 1 - x) : x;
const uint8_t pixel = art[fromY * VIDEO_CELL_PIXELS + fromX];
// Nought is not a colour here, it is the absence of one, and it is
// tested before the attribute is added so that it stays the same
// hole in all sixteen schemes.
if (pixel == 0) {
continue;
}
if (behind && !backgroundEmpty[atY * width + atX]) {
continue;
}
const uint8_t index = (uint8_t)(pixel + bank);
const uint8_t *colour = palette + index * VIDEO_PALETTE_BYTES;
uint8_t *out = pixels + (atY * width + atX) * 3;
out[0] = colour[0];
out[1] = colour[1];
out[2] = colour[2];
}
}
}
}
}
}
void videoRender(void) {
if (mode == VIDEO_MODE_BITMAP) {
// ---- A byte a pixel, and nothing in the way ----
@@ -361,9 +448,14 @@ void videoRender(void) {
*out++ = entry[0];
*out++ = entry[1];
*out++ = entry[2];
backgroundEmpty[at] = (from[at] == 0);
}
renderedWidth = VIDEO_BITMAP_WIDTH;
renderedHeight = VIDEO_BITMAP_HEIGHT;
// Over a picture as much as over a map. A bitmap is what a program draws once and
// leaves; sprites are what moves on top of it, and there is no reason the mode that
// cannot afford to redraw itself should be the one that cannot have them.
drawSprites(pixels, VIDEO_BITMAP_WIDTH, VIDEO_BITMAP_HEIGHT);
return;
}
@@ -428,17 +520,29 @@ void videoRender(void) {
// Wrapping, because a byte plus a byte is a byte. A tile using the
// high end of the palette with a nibble set comes round the bottom,
// which is what an adder does and what the manual says it does.
const uint8_t index = (uint8_t)(art[y * VIDEO_CELL_PIXELS + x] + bank);
const uint8_t was = art[y * VIDEO_CELL_PIXELS + x];
const uint8_t index = (uint8_t)(was + bank);
const uint8_t *entry = videoAtlas + VIDEO_PALETTE_BASE
+ index * VIDEO_PALETTE_BYTES;
uint8_t *out = pixels + (atY * width + atX) * 3;
out[0] = entry[0];
out[1] = entry[1];
out[2] = entry[2];
// Before the nibble, so that a cell drawn in scheme five is empty in the
// same places as the same cell drawn in scheme nought.
backgroundEmpty[atY * width + atX] = (was == 0);
}
}
}
}
// ---- And then the things that move ----
//
// After the map and not woven into it, because a sprite is not tied to a cell: one can
// sit across four of them, and a pass that drew each cell and then whatever overlapped it
// would have to draw parts of the same sprite four times and get the order right between
// them. Over the finished picture there is no order to get wrong.
drawSprites(pixels, width, height);
renderedWidth = width;
renderedHeight = rows * VIDEO_CELL_PIXELS;
}