Everything drawn on this machine was in a cell. Something between two cells meant rewriting both; something moving a pixel at a time meant rewriting them sixty times a second, which is affordable for one thing and not for twenty. A sprite is put at a pixel and the device draws it over whatever is behind, so moving it costs two bytes. MADE OF TILES, which is the decision the rest follows from: m by n taken in reading order from one index, so there is no second pixel format, no second kind of memory, and nothing a sprite can show that the map cannot. A 16 by 16 character is four tiles and the background can name the same four. 256 entries of 8 bytes at 0xC000 in the atlas - eight so the entry address is a shift, the same no-multiply argument as the palette's four. Position is signed and sixteen bits, because 640 by 400 does not fit in a byte and a sprite has to be able to sit half off the left rather than appearing whole at the edge. A PIXEL OF ZERO IS NOT DRAWN, or every sprite is a rectangle. Tested before the attribute is added, so a hole belongs to the art and not to the colour scheme. The same rule the other way round is what "behind" means: drawn only where the background pixel was zero, so a thing walks behind a pillar and in front of the floor in one frame. All of them draw, every frame, so they cannot flicker. Real machines dropped them per scanline because they had a fixed number of shift registers; this has a loop. The limit is the size of the table, which is a constant rather than a property of what is on screen. And the system takes them down at exit. The sprite table sits in the gap the screen save walks around - to the end of the map, then the palette - and that is right, because nothing the shell draws is a sprite: there is nothing to give back, only something to take away. Otherwise a program that put a ball up and left would leave it over the prompt, in front of everything, with nothing able to type it away. Sprite.asm deliberately leaves its own, because a program that faulted could not have cleared it. Every check here was re-broken and failed: transparency, reading order, draw order, priority, and size. Size needed breaking twice - the first attempt did not compile, and a silent build failure had left the old binary passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
575 lines
27 KiB
C
575 lines
27 KiB
C
// video.c
|
|
// The Voyager's video device.
|
|
// Written by Anachronaut
|
|
|
|
#include "video.h"
|
|
#include "font.h"
|
|
#include "io.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.
|
|
// 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];
|
|
|
|
// 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] & 0x0F) << 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 = videoAtlas + VIDEO_TILE_BASE + tile * VIDEO_TILE_BYTES;
|
|
|
|
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 & 0x0F) << 4);
|
|
const uint8_t *art = videoAtlas + VIDEO_TILE_BASE + tile * VIDEO_TILE_BYTES;
|
|
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;
|
|
}
|