The console is now a display controller as well as a port: it owns a font, keeps a cursor, handles newline, carriage return, backspace and wrapping, and scrolls. That is an ordinary kind of chip - it is what a video terminal's character generator did - and it is the reason this rung needed no changes to CosmOS at all. CosmOS already writes bytes to port 0x00. It writes to BOTH the screen and standard output, which is deliberate. A machine with a screen and a serial line is an ordinary machine, the emulator's standard output is that serial line, and one console drives both. It is also what keeps all 165 recorded results passing under Voyager, and what makes --screen work on the plain SplitBit: there is one console and it drives everything it has. Scrolling moves the video device's origin and no memory. The row arriving at the bottom is cleared because the map is a ring and it holds what was there 128 rows ago; the rows going off the top are not, and that is a hundred rows of scrollback nothing had to keep. The test reads the register back rather than looking at the screen, because a console blitting rows instead would look identical and cost twelve percent of a frame for every line printed. The font is vendored from Hatchet-GPU with a note saying where it came from, since that repository is not part of this one. 135 glyphs in ASCII order, which is the thing that makes it worth keeping - PETSCII's whole inconvenience was that its order was not ASCII's, so a machine using it needed a translation table in front of every string. Here the machine subtracts 32. It is stored one bit a pixel and expanded into tile memory at reset: 1,088 bytes against 16 kilobytes. Voyager gets a keyboard. A window has no standard input, and a machine blocking on it inside a frame would stop drawing and stop answering, so a front end with a window installs a hook that the console calls while it has nothing: it keeps the window alive and hands back a key. The hook has to tell "nobody has typed yet", which happens sixty times a second, apart from "the window has gone", which is the end of input - one value for both would have made the first keystroke look like a closed machine. In line mode the console echoes what it is given, because there is no terminal behind a window to do it and that was always the terminal's job. Tests/video.sh grew from 14 checks to 26, half of them about the console rather than the device: those programs ask the video device for nothing and write bytes to port 0x00 like every SplitBit program always has. Verified by breaking two things - removing the scroll failed exactly the two checks about scrolling, and removing the cursor advance failed exactly the three that depend on it. Two video checks had quietly depended on palette entry 0 being black, which stopped being true the moment a machine woke up able to show text. They now set what they are about to look at, and a new check pins the waking state itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
205 lines
9.0 KiB
C
205 lines
9.0 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.
|
|
static const uint8_t defaultInk[3] = { 0xDC, 0xE6, 0xDC };
|
|
static const uint8_t defaultPaper[3] = { 0x10, 0x14, 0x12 };
|
|
|
|
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;
|
|
}
|