// video.c // The Voyager's video device. // Written by Anachronaut #include "video.h" #include "font.h" #include "io.h" #include #include // 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; // 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 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. // // 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; // 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) { 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 = videoRAM + VIDEO_PALETTE_BASE; const uint8_t *from = videoRAM + 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]; } renderedWidth = VIDEO_BITMAP_WIDTH; renderedHeight = VIDEO_BITMAP_HEIGHT; return; } 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; }