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;
}
+75
View File
@@ -89,6 +89,81 @@
#define VIDEO_BITMAP_WIDTH 320
#define VIDEO_BITMAP_HEIGHT 200
// ---- Sprites ----
//
// Things that move without the map moving. A map cell is where it is, and a program that
// wanted something between two cells had to redraw both of them; a sprite is put at a PIXEL
// and the device draws it over whatever is behind.
//
// MADE OF TILES, which is the decision the rest follows from. A sprite is m by n tiles taken
// in reading order from one index, so it needs no pixel format of its own, no second kind of
// memory, and no way for its art to be anything the map could not also show. A 16 by 16
// character is four tiles and a program that wants it in the background too just names the
// same four.
//
// The table is 256 entries of 8 bytes. Eight so that entry n begins at n times eight, which
// is a shift - the same no-multiply argument that makes a palette entry four bytes and a map
// row a page. It sits above the tiles with the whole of 0x4000 to 0xBFFF still clear beneath
// it, which is two more 16K pages of tiles if they are ever wanted.
#define VIDEO_SPRITE_BASE 0xC000
#define VIDEO_SPRITE_COUNT 256
#define VIDEO_SPRITE_BYTES 8
// Byte 0 is the top left tile, byte 1 the attribute, which means what a map cell's attribute
// means: its low nibble times sixteen is added to every index in the art.
#define VIDEO_SPRITE_TILE 0
#define VIDEO_SPRITE_ATTRIBUTE 1
// Bytes 2 to 5, low byte first, and SIGNED - a screen is 640 by 400 in the larger mode, so
// neither axis fits in a byte, and a sprite has to be able to sit half off the left or the
// top rather than appearing whole at the edge.
#define VIDEO_SPRITE_X 2
#define VIDEO_SPRITE_Y 4
// Byte 6: how many tiles across in the high nibble, how many down in the low. Fifteen each
// way, so 120 by 120 pixels.
//
// A SPRITE OF NO WIDTH OR NO HEIGHT DRAWS NOTHING, and that is the off switch. It saves a
// flag, it is per sprite rather than a global the whole table shares, and it means the table
// is already off when the machine starts, since the atlas wakes up cleared.
//
// Deliberately the OPPOSITE of what a length of zero means to the memory controller, where
// it means the whole 64K. The reason is the same both times: moving no bytes is a useless
// thing to ask for, so zero was free to mean something else there - and drawing no sprite is
// the commonest state in this table, so zero has to mean nothing here.
#define VIDEO_SPRITE_SIZE 6
// Byte 7.
#define VIDEO_SPRITE_FLAGS 7
#define VIDEO_SPRITE_HFLIP 0x01
#define VIDEO_SPRITE_VFLIP 0x02
// Drawn only where the background had nothing, so a thing can walk behind a pillar. See
// below for what "nothing" means.
#define VIDEO_SPRITE_BEHIND 0x04
// ---- What a sprite does not cover ----
//
// A PIXEL OF ZERO IS NOT DRAWN. Without that every sprite is a rectangle, and there is no
// other candidate: the font already uses index 0 for paper, so it is the value art in this
// machine has always left empty.
//
// Tested BEFORE the attribute is added, so it is a property of the art and not of the colour
// scheme it is being shown in. A sprite drawn in indices 1 to 15 is transparent in the same
// places in all sixteen schemes, which is the whole point of the additive nibble.
//
// The same rule read the other way is what "behind" means: a sprite marked behind draws only
// where the BACKGROUND pixel was zero. One rule, applied to whichever layer is in front.
//
// ---- How many at once ----
//
// All of them. Every entry in the table is drawn every frame, so sprites cannot flicker.
// Real machines dropped them per scanline because they had a fixed number of shift registers
// and a fixed time to fill them; this has a loop. The limit is how many entries there are,
// which is a constant a program can count on rather than a property of what it happens to be
// drawing this frame.
//
// Where they overlap, THE LOWER NUMBER IS IN FRONT.
// ---- The palette ----
//
// Four bytes an entry rather than three, for the same reason a map row is a page: entry n