Three things a person looking at a real screen found in five minutes, none of which the headless tests could have seen. THE PICTURE FILLED A QUARTER OF THE WINDOW. The window opened at the largest screen the device can make, doubled, and then drew a 320 by 200 mode at that same doubling - so three quarters of it was bezel. It now takes the largest whole-number scale that fits: the two modes are exactly a factor of two apart and the window opens at twice the larger, so both fill it exactly, at four and at two. Changing mode changes how sharp the screen is rather than how big it is. Whole numbers on purpose. A 320 by 200 picture stretched by 2.7 has some rows twice as tall as their neighbours, which on eight pixel glyphs is the difference between text and mush. THE WINDOW WOULD NOT RESIZE. It does now, and the picture rescales to whatever it becomes, still in whole pixels and still centred. How big somebody wants a screen is not the machine's business. AND BLACK WAS NOT BLACK. Both the paper and the bezel were tinted towards green, on the theory that a phosphor never was neutral. On a real screen that reads as a fault rather than as character: a background that is nearly black looks like a background that failed to be black. Paper is black now and ink is a neutral grey, because a default should be the unsurprising thing - anything with a point of view about colour is 254 palette entries away and belongs to a program. The bezel is a clearly lighter grey, so what is left over when the window's shape does not match the picture's looks like a bezel rather than like more screen. The two checks that name the waking colours name the new ones. That they had to change is the check working: they say what they depend on rather than assuming it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
211 lines
9.4 KiB
C
211 lines
9.4 KiB
C
// video.c
|
|
// The Voyager's video device.
|
|
// Written by Anachronaut
|
|
|
|
#include "video.h"
|
|
#include "font.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); }
|
|
|
|
// ---- The two colours a machine wakes up with ----
|
|
//
|
|
// Only two, and the rest of the palette left at zero. A program that wants colour sets it,
|
|
// and a machine that guessed sixteen entries on its behalf would be sixteen entries it had
|
|
// to overwrite. What it must not do is wake up unable to show text at all.
|
|
//
|
|
// 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. A default should be the unsurprising thing; anything with a point of view
|
|
// about colour is 254 entries away and belongs to a program.
|
|
static const uint8_t defaultInk[3] = { 0xD8, 0xD8, 0xD8 };
|
|
static const uint8_t defaultPaper[3] = { 0x00, 0x00, 0x00 };
|
|
|
|
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;
|
|
memcpy(palette + 0 * VIDEO_PALETTE_BYTES, defaultPaper, 3);
|
|
memcpy(palette + 1 * VIDEO_PALETTE_BYTES, defaultInk, 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;
|
|
// 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_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;
|
|
}
|