// 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. // The two banks. Which one an address is in is a property of the address and never of the // mode: tiles and the palette are always in the atlas, the map and a bitmap always in the // screen. That is what makes the split cost nothing to think about at a call site. static uint8_t videoAtlas[VIDEO_MEMORY_BYTES]; static uint8_t videoScreen[VIDEO_SCREEN_COUNT][VIDEO_MEMORY_BYTES]; // ---- Where the background was empty ---- // // One byte a pixel, set while the map or the bitmap is drawn and read while the sprites are. // A sprite marked "behind" needs to know whether the thing already at a pixel was a picture // or a gap, and by the time it is drawn the pixel holds a colour rather than the index it // came from - the palette is not one to one, so two different indices can be the same // colour and asking the picture would get it wrong. // // Host memory, and it costs the machine nothing: it is scratch the device uses inside one // frame, exactly like the pixel buffer beside it. static uint8_t backgroundEmpty[VIDEO_MAX_WIDTH * VIDEO_MAX_HEIGHT]; // ---- Where a tile's art is ---- // // The scheme nibble and the page bits live in the same byte and are asked of it in the same // breath, in the two places a tile is drawn from: a map cell and a sprite. One function, so // that the two cannot drift apart - which they would, because the sprite pass was written // three days after the map pass and neither is where the other is looked at. static const uint8_t *tileArt(uint8_t attribute, uint8_t tile) { const int page = (attribute & VIDEO_ATTRIBUTE_PAGE) >> VIDEO_ATTRIBUTE_SHIFT; return videoAtlas + VIDEO_TILE_BASE + page * VIDEO_TILE_PAGE_BYTES + tile * VIDEO_TILE_BYTES; } // Which screen is being shown. The console draws into THIS one rather than into a screen of // its own, so text goes where whoever is looking is looking - which matters most when the // text is a fault message printed over a game that had flipped. static uint8_t displayed = 0; 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; // The column origin, and the pixel remainder for each axis. Kept apart from the row origin // above rather than folded into it, because they are read at different moments: the origins // decide which cell a program's writes land in, and the fine offsets decide nothing at all // except where the finished picture sits. See videoPutCell. static uint8_t scrollColumn; static uint8_t fineX, fineY; 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 of a scheme mean ink and // paper. // // ONLY THE GLYPHS THE ROM HAS. Tile memory used to be cleared first, on the grounds that // a glyph the font does not have should be blank rather than whatever was there - which // was fine while this happened at reset and nothing else, and is wrong now that a program // can ask for it. A program that defined a tile of its own above the font and then wanted // its text back would have lost the tile to get it. for (int glyph = 0; glyph < CONSOLE_FONT_GLYPHS && glyph < VIDEO_TILE_COUNT; glyph++) { uint8_t *tile = videoAtlas + 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; } } } } void videoLoadPalette(void) { uint8_t *palette = videoAtlas + 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; // ---- Where the caller means, not where the map begins ---- // // Both origins, because a caller says "row three, column five OF THE SCREEN" and the // screen is a window onto the map. The row origin has always been applied here - it is // what makes the console's scrollback free - and the column origin has to be for the // same reason, or text lands in the wrong cell the moment anything scrolls sideways. // // THE FINE OFFSETS ARE NOT APPLIED and must not be. They move the finished picture by // less than a cell, and there is no such thing as less than a cell to write into. const int mapRow = (scroll + screenRow) % VIDEO_MAP_ROWS; const int mapColumn = (scrollColumn + column) % VIDEO_MAP_COLUMNS; uint8_t *cell = videoScreen[displayed] + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE + mapColumn * 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(videoScreen[displayed] + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE, 0, VIDEO_MAP_STRIDE); } void videoReset(void) { memset(videoAtlas, 0, sizeof(videoAtlas)); memset(videoScreen, 0, sizeof(videoScreen)); displayed = 0; mode = VIDEO_MODE_40x25; scroll = 0; scrollColumn = 0; fineX = 0; fineY = 0; renderedWidth = 0; renderedHeight = 0; lastFrame = videoNow; frameWaiting = 0; frameInterrupts = 0; clearInterrupt(PORT_VIDEO); // A machine wakes up able to show text, and it does so by COPYING from the character // generator into ordinary video memory - which a program may overwrite the moment it // wants the screen for something else, and can ask back afterwards. videoLoadFont(); videoLoadPalette(); } uint8_t *videoMemory(uint8_t port, uint32_t *capacity) { *capacity = VIDEO_MEMORY_BYTES; if (port == VIDEO_STATUS) { return videoAtlas; } if (port == VIDEO_SCREEN0) { return videoScreen[0]; } if (port == VIDEO_SCREEN1) { return videoScreen[1]; } // Every other port in the block owns no memory. Saying so is what stops a bank being // registered onto one of them and pointing at nothing. return NULL; } 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_DISPLAY: // A screen that does not exist is not taken, for the same reason a mode that // does not exist is not: whoever asked still has the screen they had, and // stopping the machine over it would be a poor trade. if (value < VIDEO_SCREEN_COUNT) { displayed = 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; case VIDEO_SCROLL_COLUMN: // The same ring the other way. A map row is 256 bytes and a cell is two, so // there are 128 columns whatever the mode shows. scrollColumn = (uint8_t)(value % VIDEO_MAP_COLUMNS); break; case VIDEO_FINE_X: // The low three bits and nothing else. Eight is not one cell along, it is zero // again - see the note by the port numbers about why this does not carry. fineX = (uint8_t)(value & VIDEO_FINE_MASK); break; case VIDEO_FINE_Y: fineY = (uint8_t)(value & VIDEO_FINE_MASK); break; case VIDEO_COMMAND: // ---- Asking the character generator for its contents ---- // // Written, and it happens at once - the same shape as the console's Command port // and the controller's, rather than a bit in a register that otherwise holds // state. There is nothing to read back: what a copy did is visible in the memory // it copied into. // // IN BITMAP MODE THE TILES ARE THE PICTURE, so asking for the font there draws // glyphs across the top of it. That is not a special case being ignored; it is // what the memory means in that mode, and a caller that wants text has to be in // a mode that has some. if (value & VIDEO_COMMAND_FONT) { videoLoadFont(); } if (value & VIDEO_COMMAND_PALETTE) { videoLoadPalette(); } 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_DISPLAY: return displayed; 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; case VIDEO_SCROLL_COLUMN: return scrollColumn; case VIDEO_FINE_X: return fineX; case VIDEO_FINE_Y: return fineY; case VIDEO_COMMAND: // Write only, like the console's. A device that does something when told does // not take instructions and hand out state through the same hole. return 0; // 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; } } // ---- The sprites, over whatever is already there ---- // // BACKWARDS THROUGH THE TABLE, so that where two overlap the lower number comes out on top: // it is drawn last and writes over. Every entry is looked at, because the ones that draw // nothing say so in a byte and skipping them costs one test. static void drawSprites(uint8_t *pixels, int width, int height) { const uint8_t *palette = videoAtlas + VIDEO_PALETTE_BASE; for (int n = VIDEO_SPRITE_COUNT - 1; n >= 0; n--) { const uint8_t *entry = videoAtlas + VIDEO_SPRITE_BASE + n * VIDEO_SPRITE_BYTES; const int wide = (entry[VIDEO_SPRITE_SIZE] >> 4) & 0x0F; const int tall = entry[VIDEO_SPRITE_SIZE] & 0x0F; if (wide == 0 || tall == 0) { continue; } // Signed, and low byte first like everything else this machine writes to a device. const int left = (int16_t)(uint16_t)(entry[VIDEO_SPRITE_X] | (entry[VIDEO_SPRITE_X + 1] << 8)); const int top = (int16_t)(uint16_t)(entry[VIDEO_SPRITE_Y] | (entry[VIDEO_SPRITE_Y + 1] << 8)); const uint8_t bank = (uint8_t)((entry[VIDEO_SPRITE_ATTRIBUTE] & VIDEO_ATTRIBUTE_SCHEME) << 4); const uint8_t flags = entry[VIDEO_SPRITE_FLAGS]; const int mirrored = (flags & VIDEO_SPRITE_HFLIP) != 0; const int inverted = (flags & VIDEO_SPRITE_VFLIP) != 0; const int behind = (flags & VIDEO_SPRITE_BEHIND) != 0; for (int downTile = 0; downTile < tall; downTile++) { for (int acrossTile = 0; acrossTile < wide; acrossTile++) { // ---- Flipping moves the tiles as well as the pixels ---- // // A mirrored sprite is not each of its tiles mirrored in place; the tile at // the left end has to come out at the right end too, or a thing made of more // than one tile turns inside out instead of round. const int readAcross = mirrored ? (wide - 1 - acrossTile) : acrossTile; const int readDown = inverted ? (tall - 1 - downTile) : downTile; // In reading order from the first, and wrapping, because a byte plus a byte // is a byte and the tile number is one. const uint8_t tile = (uint8_t)(entry[VIDEO_SPRITE_TILE] + readDown * wide + readAcross); const uint8_t *art = tileArt(entry[VIDEO_SPRITE_ATTRIBUTE], tile); for (int y = 0; y < VIDEO_CELL_PIXELS; y++) { const int atY = top + downTile * VIDEO_CELL_PIXELS + y; if (atY < 0 || atY >= height) { continue; } const int fromY = inverted ? (VIDEO_CELL_PIXELS - 1 - y) : y; for (int x = 0; x < VIDEO_CELL_PIXELS; x++) { const int atX = left + acrossTile * VIDEO_CELL_PIXELS + x; if (atX < 0 || atX >= width) { continue; } const int fromX = mirrored ? (VIDEO_CELL_PIXELS - 1 - x) : x; const uint8_t pixel = art[fromY * VIDEO_CELL_PIXELS + fromX]; // Nought is not a colour here, it is the absence of one, and it is // tested before the attribute is added so that it stays the same // hole in all sixteen schemes. if (pixel == 0) { continue; } if (behind && !backgroundEmpty[atY * width + atX]) { continue; } const uint8_t index = (uint8_t)(pixel + bank); const uint8_t *colour = palette + index * VIDEO_PALETTE_BYTES; uint8_t *out = pixels + (atY * width + atX) * 3; out[0] = colour[0]; out[1] = colour[1]; out[2] = colour[2]; } } } } } } 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 = videoAtlas + VIDEO_PALETTE_BASE; const uint8_t *from = videoScreen[displayed] + 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]; backgroundEmpty[at] = (from[at] == 0); } renderedWidth = VIDEO_BITMAP_WIDTH; renderedHeight = VIDEO_BITMAP_HEIGHT; // Over a picture as much as over a map. A bitmap is what a program draws once and // leaves; sprites are what moves on top of it, and there is no reason the mode that // cannot afford to redraw itself should be the one that cannot have them. drawSprites(pixels, VIDEO_BITMAP_WIDTH, VIDEO_BITMAP_HEIGHT); return; } const int columns = columnsFor(mode); const int rows = rowsFor(mode); const int width = columns * VIDEO_CELL_PIXELS; const int height = rows * VIDEO_CELL_PIXELS; // ---- One more row and one more column than fit ---- // // With a fine offset the screen no longer starts on a cell boundary, so the first cell // of each axis is partly above or left of the picture and one extra is needed at the far // end to fill what that uncovered. Both are drawn and clipped, which is why every write // below is guarded rather than trusted: the two edge cells are the only ones that can // fall outside, but they fall outside on every frame that is not cell aligned. 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 = videoScreen[displayed] + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE; for (int column = 0; column <= columns; column++) { const int mapColumn = (scrollColumn + column) % VIDEO_MAP_COLUMNS; const uint8_t tile = cells[mapColumn * VIDEO_CELL_BYTES]; uint8_t attribute = cells[mapColumn * 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 & VIDEO_ATTRIBUTE_SCHEME) << 4); const uint8_t *art = tileArt(attribute, tile); for (int y = 0; y < VIDEO_CELL_PIXELS; y++) { // Where this row of the cell lands once the view has been slid up by the // fine offset. Negative means it is the part of the top cell that is off // the screen, which is the whole point of drawing it. const int atY = row * VIDEO_CELL_PIXELS + y - fineY; if (atY < 0 || atY >= height) { continue; } for (int x = 0; x < VIDEO_CELL_PIXELS; x++) { const int atX = column * VIDEO_CELL_PIXELS + x - fineX; if (atX < 0 || atX >= width) { continue; } // 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 was = art[y * VIDEO_CELL_PIXELS + x]; const uint8_t index = (uint8_t)(was + bank); const uint8_t *entry = videoAtlas + VIDEO_PALETTE_BASE + index * VIDEO_PALETTE_BYTES; uint8_t *out = pixels + (atY * width + atX) * 3; out[0] = entry[0]; out[1] = entry[1]; out[2] = entry[2]; // Before the nibble, so that a cell drawn in scheme five is empty in the // same places as the same cell drawn in scheme nought. backgroundEmpty[atY * width + atX] = (was == 0); } } } } // ---- And then the things that move ---- // // After the map and not woven into it, because a sprite is not tied to a cell: one can // sit across four of them, and a pass that drew each cell and then whatever overlapped it // would have to draw parts of the same sprite four times and get the order right between // them. Over the finished picture there is no order to get wrong. drawSprites(pixels, width, height); 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; }