The map moves and this does not, which the map alone cannot express. The scroll registers move ALL of it, so a score printed into the map slides away, and one printed into whichever rows the view happens to be showing jumps a pixel at a time as the fine offset changes. Port 0x3D is how many rows tall and 0x3E is which row it starts at. Nought tall is no window, so a cleared screen has none and every program written before this means what it meant. A start row is a register because a status bar along the bottom is as common as one along the top. IT HAS ITS OWN MEMORY, and that is the argument for it. The cheaper design draws the top rows of the MAP without the scroll applied - no new memory, one register - and makes those rows part of the playfield's ring, so a game that scrolls vertically has to route its world around its own scoreboard for ever. The point of a status bar is that it is not somewhere in the level. Lunar Porter does not scroll vertically today and will the moment an orbit is a thing you can reach. 0xC000 in the screen bank, which the map does not reach: it ends at 0xBFFF. Same cells, same tiles, same pages, same schemes. Being in the screen bank makes it per screen, so flipping the buffer flips the status bar with it - what a double buffered game wants, and surprising the other way round. Drawn over everything, sprites included. A sprite that could cover the fuel gauge would be a bug in every game that had both. Tile modes only. In bitmap mode the picture is using that memory, so a bitmap program pins things to the screen with sprites, which are in screen coordinates for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
680 lines
32 KiB
C
680 lines
32 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];
|
|
|
|
// ---- 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;
|
|
|
|
// How tall the window is and where it starts. Nought tall is no window.
|
|
static uint8_t windowHeight = 0;
|
|
static uint8_t windowAt = 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;
|
|
windowHeight = 0;
|
|
windowAt = 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_WINDOW_HEIGHT:
|
|
windowHeight = value;
|
|
break;
|
|
case VIDEO_WINDOW_AT:
|
|
windowAt = 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_WINDOW_HEIGHT: return windowHeight;
|
|
case VIDEO_WINDOW_AT: return windowAt;
|
|
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;
|
|
const uint8_t *depths = videoAtlas + VIDEO_DEPTH_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;
|
|
}
|
|
// How big the art is, and how big it is being asked to look. Nought means the one
|
|
// is the other, which is what every sprite written before scaling existed says.
|
|
const int naturalWide = wide * VIDEO_CELL_PIXELS;
|
|
const int naturalTall = tall * VIDEO_CELL_PIXELS;
|
|
int drawWide = entry[VIDEO_SPRITE_WIDTH] | (entry[VIDEO_SPRITE_WIDTH + 1] << 8);
|
|
int drawTall = entry[VIDEO_SPRITE_HEIGHT] | (entry[VIDEO_SPRITE_HEIGHT + 1] << 8);
|
|
if (drawWide == 0) { drawWide = naturalWide; }
|
|
if (drawTall == 0) { drawTall = naturalTall; }
|
|
|
|
// 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;
|
|
const uint8_t depth = entry[VIDEO_SPRITE_DEPTH];
|
|
|
|
// ---- Only the part that lands on the screen is walked ----
|
|
//
|
|
// Clipped BEFORE the loop rather than inside it, which used to be enough and is not
|
|
// any more: a target size is sixteen bits, so a sprite asked to be 60,000 pixels
|
|
// tall would otherwise be sixty thousand turns of a loop that drew eight rows.
|
|
const int fromDown = (top < 0) ? -top : 0;
|
|
const int toDown = (drawTall < height - top) ? drawTall : height - top;
|
|
const int fromAcross = (left < 0) ? -left : 0;
|
|
const int toAcross = (drawWide < width - left) ? drawWide : width - left;
|
|
|
|
// ---- Walked over where it is GOING, not over where it came from ----
|
|
//
|
|
// Every destination pixel asks which source pixel it is showing, which is what makes
|
|
// a stretch and a squash the same operation and needs no accumulator carried between
|
|
// rows. It also makes flipping fall out: turning the source coordinate round mirrors
|
|
// the tile ORDER and the pixels inside each tile in one step, where drawing tile by
|
|
// tile had to be told to do both.
|
|
for (int down = fromDown; down < toDown; down++) {
|
|
const int atY = top + down;
|
|
int sourceDown = (int)((long)down * naturalTall / drawTall);
|
|
if (inverted) {
|
|
sourceDown = naturalTall - 1 - sourceDown;
|
|
}
|
|
for (int across = fromAcross; across < toAcross; across++) {
|
|
const int atX = left + across;
|
|
int sourceAcross = (int)((long)across * naturalWide / drawWide);
|
|
if (mirrored) {
|
|
sourceAcross = naturalWide - 1 - sourceAcross;
|
|
}
|
|
// Which tile of the group, in reading order, and where inside it. 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]
|
|
+ (sourceDown / VIDEO_CELL_PIXELS) * wide
|
|
+ (sourceAcross / VIDEO_CELL_PIXELS));
|
|
const uint8_t *art = tileArt(entry[VIDEO_SPRITE_ATTRIBUTE], tile);
|
|
const uint8_t pixel = art[(sourceDown % VIDEO_CELL_PIXELS) * VIDEO_CELL_PIXELS
|
|
+ (sourceAcross % VIDEO_CELL_PIXELS)];
|
|
// 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;
|
|
}
|
|
// ---- And whether anything nearer is already in this column ----
|
|
//
|
|
// Per column, which is the whole reason this is a buffer and not a number:
|
|
// a billboard is in front of the wall at one end of itself and behind it at
|
|
// the other, and no ordering of the table can say that.
|
|
if (depth != 0) {
|
|
const uint8_t there = depths[atX];
|
|
if (there != 0 && depth >= there) {
|
|
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];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- The window, over everything ----
|
|
//
|
|
// Drawn last, after the sprites, because a status bar is the thing nothing gets in front of.
|
|
// A sprite that could cover the fuel gauge would be a bug in every game that had both.
|
|
//
|
|
// NO SCROLL AND NO FINE OFFSET. That is the entire feature: a window cell is at a screen
|
|
// position, full stop, where a map cell is at a position in a world the screen is looking at
|
|
// part of. The two coordinate systems are what the register block keeps apart.
|
|
static void drawWindow(uint8_t *pixels, int width, int height, int columns) {
|
|
if (windowHeight == 0) {
|
|
return;
|
|
}
|
|
const uint8_t *palette = videoAtlas + VIDEO_PALETTE_BASE;
|
|
for (int row = 0; row < windowHeight && row < VIDEO_WINDOW_ROWS; row++) {
|
|
const int atRow = windowAt + row;
|
|
if (atRow < 0 || atRow * VIDEO_CELL_PIXELS >= height) {
|
|
continue;
|
|
}
|
|
const uint8_t *cells = videoScreen[displayed] + VIDEO_WINDOW_BASE
|
|
+ row * 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];
|
|
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++) {
|
|
const int atY = atRow * VIDEO_CELL_PIXELS + y;
|
|
if (atY >= height) {
|
|
break;
|
|
}
|
|
for (int x = 0; x < VIDEO_CELL_PIXELS; x++) {
|
|
const int atX = column * VIDEO_CELL_PIXELS + x;
|
|
if (atX >= width) {
|
|
break;
|
|
}
|
|
const uint8_t index =
|
|
(uint8_t)(art[y * VIDEO_CELL_PIXELS + x] + bank);
|
|
const uint8_t *entry = palette + index * VIDEO_PALETTE_BYTES;
|
|
uint8_t *out = pixels + (atY * width + atX) * 3;
|
|
out[0] = entry[0];
|
|
out[1] = entry[1];
|
|
out[2] = entry[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);
|
|
drawWindow(pixels, width, height, columns);
|
|
|
|
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;
|
|
}
|