Files
SplitBit-Emulator/Source/Emulator/video.c
T
AnachronautandClaude Opus 5 a916103a7f 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
2026-09-02 11:42:11 -04:00

575 lines
27 KiB
C

// video.c
// The Voyager's video device.
// Written by Anachronaut
#include "video.h"
#include "font.h"
#include "io.h"
#include <stdio.h>
#include <string.h>
// The bank the device brings. Registered by whoever enumerates the hardware, reached only
// through the memory controller, and never by the CPU directly - the same arrangement the
// disk's buffer has always had.
// The two banks. Which one an address is in is a property of the address and never of the
// mode: tiles and the palette are always in the atlas, the map and a bitmap always in the
// screen. That is what makes the split cost nothing to think about at a call site.
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.
static uint8_t displayed = 0;
static uint8_t mode;
// Which map row is drawn at the top. THE MAP IS A RING: rendering row r reads map row
// (scroll + r) wrapped, so scrolling a screen moves this byte and moves no memory at all.
//
// That is worth more than it looks. Blitting a 40 by 25 screen up one line is 1,920 bytes
// inside one bank, which is 1,920 cycles even with the controller widened - twelve percent
// of a frame, every line. A program printing one page would spend six frames shuffling
// memory. Here it costs one port write, and the rows that scrolled off are still there,
// which is where the console gets scrollback it never had.
static uint8_t scroll;
// The column origin, and the pixel remainder for each axis. Kept apart from the row origin
// above rather than folded into it, because they are read at different moments: the origins
// decide which cell a program's writes land in, and the fine offsets decide nothing at all
// except where the finished picture sits. See videoPutCell.
static uint8_t scrollColumn;
static uint8_t fineX, fineY;
static uint8_t pixels[VIDEO_MAX_WIDTH * VIDEO_MAX_HEIGHT * 3];
static int renderedWidth = 0;
static int renderedHeight = 0;
// Zero in bitmap mode, where there are no characters. Everything that draws one checks, so
// this is the single place the answer lives rather than a mode test in each of them.
static int columnsFor(uint8_t m) {
if (m == VIDEO_MODE_BITMAP) return 0;
return m == VIDEO_MODE_80x50 ? 80 : 40;
}
static int rowsFor(uint8_t m) {
if (m == VIDEO_MODE_BITMAP) return 0;
return m == VIDEO_MODE_80x50 ? 50 : 25;
}
int videoTextRows(void) { return rowsFor(mode); }
int videoColumns(void) { return columnsFor(mode); }
int videoRows(void) { return rowsFor(mode); }
// ---- Sixteen schemes a machine wakes up with ----
//
// A glyph is drawn in palette indices 0 and 1, paper and ink, and a cell's attribute nibble
// adds sixteen to both. So bank n colours text with entries n*16 and n*16+1, and SIXTEEN
// BANKS IS SIXTEEN INK AND PAPER PAIRS - a text attribute system that costs one nibble and
// no hardware at all.
//
// The arrangement is a convention rather than a rule of the machine, and it is chosen so
// that HIGHLIGHTING IS ONE BIT. Banks 0 to 7 are colours on black; banks 8 to 15 are the
// same colours as paper with black ink. Attribute XOR 8 therefore turns any of them inside
// out, which is what a cursor and a selected line both want, and a program that disagrees
// writes its own palette over the top.
//
// Bank 0 is grey on black, which is what the machine has always woken up as.
//
// BLACK IS BLACK AND GREY IS GREY. These were tinted towards green to begin with, on the
// theory that a phosphor never was neutral, and on a real screen it read as a fault rather
// than as character - a background that is nearly black looks like a background that failed
// to be black.
static const uint8_t defaultInks[8][3] = {
{ 0xD8, 0xD8, 0xD8 }, // grey, which is what plain text has always been
{ 0xD0, 0x40, 0x38 }, // red
{ 0x50, 0xC0, 0x50 }, // green
{ 0xD8, 0xC0, 0x48 }, // yellow
{ 0x58, 0x80, 0xE0 }, // blue
{ 0xC8, 0x60, 0xC0 }, // magenta
{ 0x50, 0xC0, 0xC8 }, // cyan
{ 0xF0, 0xF0, 0xF0 }, // white
};
static const uint8_t defaultPaper[3] = { 0x00, 0x00, 0x00 };
// Where the cursor is, whether it is wanted, and what the clock says - which is what makes
// it blink without anything having to remember when it last did.
static int cursorAtRow = 0;
static int cursorAtColumn = 0;
static int cursorVisible = 0;
static unsigned long videoNow = 0;
// When the last frame boundary went by, whether one has gone by unnoticed, and whether the
// screen is meant to say so out loud.
static unsigned long lastFrame = 0;
static int frameWaiting = 0;
static int frameInterrupts = 0;
void videoSetCursor(int row, int column, int visible) {
cursorAtRow = row;
cursorAtColumn = column;
cursorVisible = visible;
}
void videoTick(unsigned long now) {
videoNow = now;
// ---- Caught up rather than counted ----
//
// A loop, because more than one frame can go by between two looks: the machine runs in
// batches, and a slow host or a --fast run can cover several frames before anything asks.
// The flag and the line are each ONE THING, so several frames at once still mean one of
// each - a missed frame is missed, which is what missing one is.
while (now - lastFrame >= VIDEO_FRAME_CYCLES) {
lastFrame += VIDEO_FRAME_CYCLES;
frameWaiting = 1;
if (frameInterrupts) {
raiseInterrupt(PORT_VIDEO);
}
}
}
void videoLoadFont(void) {
// One bit a pixel becomes one byte a pixel: index 1 where the font has a dot and 0 where
// it does not, which is what makes the two palette entries of a scheme mean ink and
// paper.
//
// ONLY THE GLYPHS THE ROM HAS. Tile memory used to be cleared first, on the grounds that
// a glyph the font does not have should be blank rather than whatever was there - which
// was fine while this happened at reset and nothing else, and is wrong now that a program
// can ask for it. A program that defined a tile of its own above the font and then wanted
// its text back would have lost the tile to get it.
for (int glyph = 0; glyph < CONSOLE_FONT_GLYPHS && glyph < VIDEO_TILE_COUNT; glyph++) {
uint8_t *tile = videoAtlas + VIDEO_TILE_BASE + glyph * VIDEO_TILE_BYTES;
for (int y = 0; y < CONSOLE_FONT_BYTES; y++) {
const unsigned char row = consoleFont[glyph * CONSOLE_FONT_BYTES + y];
for (int x = 0; x < VIDEO_CELL_PIXELS; x++) {
tile[y * VIDEO_CELL_PIXELS + x] = (row & (0x80u >> x)) ? 1 : 0;
}
}
}
}
void videoLoadPalette(void) {
uint8_t *palette = videoAtlas + VIDEO_PALETTE_BASE;
for (int bank = 0; bank < 8; bank++) {
// Colour on black, and then the same colour as paper with black ink, sixteen banks
// apart so that one bit turns either into the other.
memcpy(palette + (bank * 16 + 0) * VIDEO_PALETTE_BYTES, defaultPaper, 3);
memcpy(palette + (bank * 16 + 1) * VIDEO_PALETTE_BYTES, defaultInks[bank], 3);
memcpy(palette + ((bank + 8) * 16 + 0) * VIDEO_PALETTE_BYTES, defaultInks[bank], 3);
memcpy(palette + ((bank + 8) * 16 + 1) * VIDEO_PALETTE_BYTES, defaultPaper, 3);
}
}
void videoPutCell(int screenRow, int column, uint8_t tile, uint8_t attribute) {
if (screenRow < 0 || screenRow >= rowsFor(mode)) return;
if (column < 0 || column >= columnsFor(mode)) return;
// ---- Where the caller means, not where the map begins ----
//
// Both origins, because a caller says "row three, column five OF THE SCREEN" and the
// screen is a window onto the map. The row origin has always been applied here - it is
// what makes the console's scrollback free - and the column origin has to be for the
// same reason, or text lands in the wrong cell the moment anything scrolls sideways.
//
// THE FINE OFFSETS ARE NOT APPLIED and must not be. They move the finished picture by
// less than a cell, and there is no such thing as less than a cell to write into.
const int mapRow = (scroll + screenRow) % VIDEO_MAP_ROWS;
const int mapColumn = (scrollColumn + column) % VIDEO_MAP_COLUMNS;
uint8_t *cell = videoScreen[displayed] + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE
+ mapColumn * VIDEO_CELL_BYTES;
cell[0] = tile;
cell[1] = attribute;
}
void videoScrollUp(void) {
scroll = (uint8_t)((scroll + 1) % VIDEO_MAP_ROWS);
// The row now at the bottom held whatever was there a ring ago, so it is cleared. The
// rows that went off the top are NOT cleared, which is the whole of the scrollback: a
// hundred rows of what has already been said, still sitting in the map.
const int bottom = rowsFor(mode) - 1;
const int mapRow = (scroll + bottom) % VIDEO_MAP_ROWS;
memset(videoScreen[displayed] + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE, 0,
VIDEO_MAP_STRIDE);
}
void videoReset(void) {
memset(videoAtlas, 0, sizeof(videoAtlas));
memset(videoScreen, 0, sizeof(videoScreen));
displayed = 0;
mode = VIDEO_MODE_40x25;
scroll = 0;
scrollColumn = 0;
fineX = 0;
fineY = 0;
renderedWidth = 0;
renderedHeight = 0;
lastFrame = videoNow;
frameWaiting = 0;
frameInterrupts = 0;
clearInterrupt(PORT_VIDEO);
// A machine wakes up able to show text, and it does so by COPYING from the character
// generator into ordinary video memory - which a program may overwrite the moment it
// wants the screen for something else, and can ask back afterwards.
videoLoadFont();
videoLoadPalette();
}
uint8_t *videoMemory(uint8_t port, uint32_t *capacity) {
*capacity = VIDEO_MEMORY_BYTES;
if (port == VIDEO_STATUS) {
return videoAtlas;
}
if (port == VIDEO_SCREEN0) {
return videoScreen[0];
}
if (port == VIDEO_SCREEN1) {
return videoScreen[1];
}
// Every other port in the block owns no memory. Saying so is what stops a bank being
// registered onto one of them and pointing at nothing.
return NULL;
}
uint8_t videoWrite(uint8_t value, uint8_t port) {
switch (port) {
case VIDEO_MODE:
// A mode that does not exist is not taken. Refusing outright would be the other
// choice, but a screen is not the place to stop the machine: a program that
// asked for something impossible still has the screen it had.
if (value < VIDEO_MODE_COUNT) {
mode = value;
}
break;
case VIDEO_DISPLAY:
// A screen that does not exist is not taken, for the same reason a mode that
// does not exist is not: whoever asked still has the screen they had, and
// stopping the machine over it would be a poor trade.
if (value < VIDEO_SCREEN_COUNT) {
displayed = value;
}
break;
case VIDEO_CONTROL:
frameInterrupts = (value & VIDEO_CONTROL_FRAME) != 0;
if (!frameInterrupts) {
// Asking to stop being interrupted takes down whatever was already asked
// for. A request that outlived the setting that made it would arrive at a
// program which had just said it did not want it - the same reasoning the
// console's interrupt bit is written under.
clearInterrupt(PORT_VIDEO);
}
break;
case VIDEO_SCROLL:
// Wrapped rather than clipped, because the map is a ring and every byte names a
// row that exists.
scroll = (uint8_t)(value % VIDEO_MAP_ROWS);
break;
case VIDEO_SCROLL_COLUMN:
// The same ring the other way. A map row is 256 bytes and a cell is two, so
// there are 128 columns whatever the mode shows.
scrollColumn = (uint8_t)(value % VIDEO_MAP_COLUMNS);
break;
case VIDEO_FINE_X:
// The low three bits and nothing else. Eight is not one cell along, it is zero
// again - see the note by the port numbers about why this does not carry.
fineX = (uint8_t)(value & VIDEO_FINE_MASK);
break;
case VIDEO_FINE_Y:
fineY = (uint8_t)(value & VIDEO_FINE_MASK);
break;
case VIDEO_COMMAND:
// ---- Asking the character generator for its contents ----
//
// Written, and it happens at once - the same shape as the console's Command port
// and the controller's, rather than a bit in a register that otherwise holds
// state. There is nothing to read back: what a copy did is visible in the memory
// it copied into.
//
// IN BITMAP MODE THE TILES ARE THE PICTURE, so asking for the font there draws
// glyphs across the top of it. That is not a special case being ignored; it is
// what the memory means in that mode, and a caller that wants text has to be in
// a mode that has some.
if (value & VIDEO_COMMAND_FONT) {
videoLoadFont();
}
if (value & VIDEO_COMMAND_PALETTE) {
videoLoadPalette();
}
break;
default:
// Everything else is read only or not there yet. Writing does nothing rather
// than refusing: a port block reserved for later should be quiet, not fatal.
break;
}
return 0;
}
uint8_t videoRead(uint8_t port) {
switch (port) {
case VIDEO_DISPLAY:
return displayed;
case VIDEO_STATUS: {
uint8_t status = 0;
if (frameWaiting) {
status |= VIDEO_STATUS_FRAME;
}
if (frameInterrupts) {
status |= VIDEO_STATUS_INTERRUPT;
}
// Looking is what answers it. A frame that has been noticed is not still
// waiting to be, and a program polling in a loop would otherwise see the first
// frame for ever.
//
// The line goes with the flag, and for the stronger reason: a program that polls
// this port is not going to be the one that answers an interrupt, so a line left
// standing here is one nothing will ever take down.
frameWaiting = 0;
clearInterrupt(PORT_VIDEO);
return status;
}
case VIDEO_CONTROL:
// Write only. Everything it sets is reported by the status port, and one fact
// wants one place to live.
return 0;
case VIDEO_MODE: return mode;
case VIDEO_SCROLL_COLUMN: return scrollColumn;
case VIDEO_FINE_X: return fineX;
case VIDEO_FINE_Y: return fineY;
case VIDEO_COMMAND:
// Write only, like the console's. A device that does something when told does
// not take instructions and hand out state through the same hole.
return 0;
// Asked rather than assumed. A program that wants to know how wide the screen is
// should be able to find out, the same way it asks the console what mode it is in.
case VIDEO_COLUMNS: return (uint8_t)columnsFor(mode);
case VIDEO_ROWS: return (uint8_t)rowsFor(mode);
case VIDEO_SCROLL: return scroll;
default: return 0;
}
}
// ---- 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 ----
//
// No tile to look up and no attribute to add: the byte IS the palette index. Which
// is the whole difference between the two kinds of screen - a tile mode costs the
// CPU the number of cells that changed, and this costs it the number of pixels.
const uint8_t *palette = videoAtlas + VIDEO_PALETTE_BASE;
const uint8_t *from = videoScreen[displayed] + VIDEO_BITMAP_BASE;
uint8_t *out = pixels;
for (int at = 0; at < VIDEO_BITMAP_WIDTH * VIDEO_BITMAP_HEIGHT; at++) {
const uint8_t *entry = palette + from[at] * VIDEO_PALETTE_BYTES;
*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;
}
const int columns = columnsFor(mode);
const int rows = rowsFor(mode);
const int width = columns * VIDEO_CELL_PIXELS;
const int height = rows * VIDEO_CELL_PIXELS;
// ---- One more row and one more column than fit ----
//
// With a fine offset the screen no longer starts on a cell boundary, so the first cell
// of each axis is partly above or left of the picture and one extra is needed at the far
// end to fill what that uncovered. Both are drawn and clipped, which is why every write
// below is guarded rather than trusted: the two edge cells are the only ones that can
// fall outside, but they fall outside on every frame that is not cell aligned.
for (int row = 0; row <= rows; row++) {
// The ring. Rows that scrolled off the top are still in the map, which is what
// makes scrollback free rather than something the console has to keep itself.
const int mapRow = (scroll + row) % VIDEO_MAP_ROWS;
const uint8_t *cells = videoScreen[displayed] + VIDEO_MAP_BASE
+ mapRow * VIDEO_MAP_STRIDE;
for (int column = 0; column <= columns; column++) {
const int mapColumn = (scrollColumn + column) % VIDEO_MAP_COLUMNS;
const uint8_t tile = cells[mapColumn * VIDEO_CELL_BYTES];
uint8_t attribute = cells[mapColumn * VIDEO_CELL_BYTES + 1];
// ---- The cursor, turned inside out ----
//
// Not a glyph of its own, because a block drawn over a cell hides what is in it
// and a person editing a line wants to see the character they are standing on.
// XOR 8 swaps a bank for its reverse, which is what the default palette is laid
// out to make possible.
//
// The phase comes from the machine's clock, so a screen saved at a given cycle
// count is the same screen every time.
if (cursorVisible && row == cursorAtRow && column == cursorAtColumn
&& ((videoNow / VIDEO_BLINK_CYCLES) & 1) == 0) {
attribute ^= 0x08;
}
// ---- The additive nibble ----
//
// The low nibble of the attribute is added to every palette index in the tile,
// sixteen at a time. A tile drawn in indices 0 to 15 therefore appears in any
// of sixteen colour schemes without a second copy of it in tile memory, and a
// tile that wants all 256 colours simply leaves the nibble at zero and gets
// them. One adder in hardware, and neither use costs the other anything.
const uint8_t bank = (uint8_t)((attribute & 0x0F) << 4);
const uint8_t *art = videoAtlas + VIDEO_TILE_BASE + tile * VIDEO_TILE_BYTES;
for (int y = 0; y < VIDEO_CELL_PIXELS; y++) {
// Where this row of the cell lands once the view has been slid up by the
// fine offset. Negative means it is the part of the top cell that is off
// the screen, which is the whole point of drawing it.
const int atY = row * VIDEO_CELL_PIXELS + y - fineY;
if (atY < 0 || atY >= height) {
continue;
}
for (int x = 0; x < VIDEO_CELL_PIXELS; x++) {
const int atX = column * VIDEO_CELL_PIXELS + x - fineX;
if (atX < 0 || atX >= width) {
continue;
}
// 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 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;
}
const uint8_t *videoPixels(int *width, int *height) {
*width = renderedWidth;
*height = renderedHeight;
return pixels;
}
// A binary PPM, because it is the smallest format that needs no library to write and no
// library to read - which matters when the thing reading it is a test script.
int videoWriteImage(const char *path) {
videoRender();
FILE *file = fopen(path, "wb");
if (file == NULL) {
fprintf(stderr, "Error: Couldn't write the screen to: %s\n", path);
return 1;
}
fprintf(file, "P6\n%d %d\n255\n", renderedWidth, renderedHeight);
size_t bytes = (size_t)renderedWidth * (size_t)renderedHeight * 3;
size_t written = fwrite(pixels, 1, bytes, file);
fclose(file);
if (written != bytes) {
fprintf(stderr, "Error: The screen was not written whole to: %s\n", path);
return 1;
}
return 0;
}