Files
SplitBit-Emulator/Source/Emulator/video.c
T
AnachronautandClaude Opus 5 83623a3df3 Give the Voyager a screen
A tile engine on ports 0x30 to 0x3F, bringing one bank of video memory registered the way
the disk's buffer is. The CPU writes cell indices and the device turns them into pixels,
which is the whole reason a screen is affordable at a megahertz: a frame is 16,667 cycles,
a full 320 by 200 picture is 64,000 bytes, and a 40 by 25 map is 2,000. A program that
changes two cells writes four bytes. The cost of a screen becomes the number of cells that
changed rather than the number of pixels on it.

Which makes colour depth free, so the tiles are eight bits: an 8 by 8 cell is 64 pixels and
each picks independently out of 256 colours, with no per-cell limit of the kind that made a
Spectrum two and C64 multicolour four. The low nibble of a cell's attribute is ADDED to
every index in its tile, sixteen at a time, so a tile drawn in 0 to 15 appears in any of
sixteen schemes without a second copy in tile memory - and a tile wanting all 256 leaves the
nibble at zero and gets them. Neither use costs the other anything.

Two decisions are arithmetic rather than taste, and both come from the machine having no
multiply. A map row is a page whether the mode fills it or not, so a cell address is the row
number as the high byte and the doubled column as the low byte with no arithmetic at all;
otherwise every cursor move on a 40 column screen would cost a row-times-40 in software. And
a palette entry is four bytes rather than three, so entry n is at n times four, a shift.

THE MAP IS A RING and the Scroll register says which of its 128 rows is on top. Scrolling
moves a register and no memory: blitting a 40 by 25 screen up one line is 1,920 bytes inside
one bank, which is twelve percent of a frame even with the controller widened, and a program
printing one page would spend six frames shuffling memory. It is now one port write - and
the rows that scrolled off are still there, which is where a terminal gets scrollback it
never had.

The device is part of the machine rather than part of the window. It renders into a buffer
that is a pure function of video memory, so the same program draws the same picture with
nobody watching; Voyager puts that buffer on the glass and decides nothing. Both binaries
take --screen, which saves a PPM when the machine stops, and that is what makes a screen
checkable on a host with no display at all.

Tests/video.sh checks fourteen named behaviours rather than comparing a recorded image,
because 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. Verified by
breaking three things in turn: the additive nibble failed exactly one check, the scroll
origin exactly two, and moving every cell one pixel sideways exactly the four about
placement.

Tests/docs.sh could not count past nine, which is how a suite of ten scripts reported
itself as wrong for the wrong reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-28 22:11:13 -04:00

150 lines
6.2 KiB
C

// video.c
// The Voyager's video device.
// Written by Anachronaut
#include "video.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.
static uint8_t videoRAM[VIDEO_MEMORY_BYTES];
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;
static uint8_t pixels[VIDEO_MAX_WIDTH * VIDEO_MAX_HEIGHT * 3];
static int renderedWidth = 0;
static int renderedHeight = 0;
static int columnsFor(uint8_t m) { return m == VIDEO_MODE_80x50 ? 80 : 40; }
static int rowsFor(uint8_t m) { return m == VIDEO_MODE_80x50 ? 50 : 25; }
void videoReset(void) {
memset(videoRAM, 0, sizeof(videoRAM));
mode = VIDEO_MODE_40x25;
scroll = 0;
renderedWidth = 0;
renderedHeight = 0;
}
uint8_t *videoMemory(uint32_t *capacity) {
*capacity = VIDEO_MEMORY_BYTES;
return videoRAM;
}
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_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;
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) {
// Reserved for the frame interrupt, which is the next rung. Zero until then.
case VIDEO_STATUS: return 0;
case VIDEO_MODE: return mode;
// 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;
}
}
void videoRender(void) {
const int columns = columnsFor(mode);
const int rows = rowsFor(mode);
const int width = columns * VIDEO_CELL_PIXELS;
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 = videoRAM + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE;
for (int column = 0; column < columns; column++) {
const uint8_t tile = cells[column * VIDEO_CELL_BYTES];
const uint8_t attribute = cells[column * VIDEO_CELL_BYTES + 1];
// ---- 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 = videoRAM + VIDEO_TILE_BASE + tile * VIDEO_TILE_BYTES;
for (int y = 0; y < VIDEO_CELL_PIXELS; y++) {
uint8_t *out = pixels + ((row * VIDEO_CELL_PIXELS + y) * width
+ column * VIDEO_CELL_PIXELS) * 3;
for (int x = 0; x < VIDEO_CELL_PIXELS; x++) {
// 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 *entry = videoRAM + VIDEO_PALETTE_BASE
+ index * VIDEO_PALETTE_BYTES;
*out++ = entry[0];
*out++ = entry[1];
*out++ = entry[2];
}
}
}
}
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;
}