// video.c // The Voyager's video device. // Written by Anachronaut #include "video.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; 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; } void videoReset(void) { memset(videoRAM, 0, sizeof(videoRAM)); mode = VIDEO_MODE_40x25; scroll = 0; renderedWidth = 0; renderedHeight = 0; } 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; }