V3. The screen interrupts at each frame on hardware vector 0x30, and WAIT finally has something worth sleeping on. THERE WAS NO CLOCK. Every program that wanted to happen at a certain speed counted instructions and hoped, which is why Snake's pause silently halved the day a cycle stopped being an instruction and became a memory access - the program was right and the thing it was counting changed underneath it. A screen finishing sixty times a second is a real beat, and it is counted in the MACHINE'S cycles rather than the host's, so the same program sees the same number of frames in the same number of cycles however fast anything really ran. That is what makes a frame something a test can count and a recorded result can hold. Status bit 0 goes up when a frame has gone by and reading the status port puts it down, so a program with no handler can watch for it instead. Control bit 0 asks to be interrupted, and is OFF when the machine starts: an interrupt with nothing installed to catch it is a fault, so a screen that began interrupting the moment it was switched on would take down every program written before frames existed. More than one frame can pass between two looks, and the flag and the line are each one thing, so several still mean one of each. A missed frame is missed. Programs/Examples/frames.asm prints a dot a frame for a second: 1,000,324 cycles, and 996,460 of them spent asleep. That split is the thing worth seeing - a program that polled instead would print the same sixty dots, take the same second, and spend every cycle of it on the bus. Its header explains why waiting is not spinning and why a machine with a beat can stop guessing at one. Six checks in Tests/video.sh, and two of them are about the clock rather than the output, because the output cannot tell the difference. That the machine slept through nearly all of ten frames, and that polling three frames actually took three frames - a status flag that stayed up once set would print exactly the same character and look perfectly correct. Breaking the frame interrupt on purpose left a machine asleep for ever and hung the whole suite, which is a worse way to be told than a failing check. Tests/video.sh bounds its runs at ten seconds now, the way Tests/run.sh always has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
314 lines
14 KiB
C
314 lines
14 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.
|
|
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; }
|
|
|
|
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 below mean ink and
|
|
// paper. Glyphs the font does not have are left blank rather than left as whatever was
|
|
// in tile memory.
|
|
memset(videoRAM + VIDEO_TILE_BASE, 0, (size_t)VIDEO_TILE_COUNT * VIDEO_TILE_BYTES);
|
|
for (int glyph = 0; glyph < CONSOLE_FONT_GLYPHS && glyph < VIDEO_TILE_COUNT; glyph++) {
|
|
uint8_t *tile = videoRAM + 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;
|
|
}
|
|
}
|
|
}
|
|
uint8_t *palette = videoRAM + 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;
|
|
const int mapRow = (scroll + screenRow) % VIDEO_MAP_ROWS;
|
|
uint8_t *cell = videoRAM + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE
|
|
+ column * 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(videoRAM + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE, 0, VIDEO_MAP_STRIDE);
|
|
}
|
|
|
|
void videoReset(void) {
|
|
memset(videoRAM, 0, sizeof(videoRAM));
|
|
mode = VIDEO_MODE_40x25;
|
|
scroll = 0;
|
|
renderedWidth = 0;
|
|
renderedHeight = 0;
|
|
lastFrame = videoNow;
|
|
frameWaiting = 0;
|
|
frameInterrupts = 0;
|
|
clearInterrupt(PORT_VIDEO);
|
|
// A machine wakes up able to show text. Everything here is ordinary video memory that a
|
|
// program may overwrite the moment it wants the screen for something else.
|
|
videoLoadFont();
|
|
}
|
|
|
|
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_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;
|
|
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_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.
|
|
frameWaiting = 0;
|
|
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;
|
|
// 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];
|
|
uint8_t attribute = cells[column * 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 = 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;
|
|
}
|