A window: a layer that does not scroll

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
This commit is contained in:
Anachronaut
2026-09-03 00:19:09 -04:00
co-authored by Claude Opus 5
parent e3eccd17a8
commit 17c8da111f
4 changed files with 194 additions and 0 deletions
+63
View File
@@ -46,6 +46,10 @@ static const uint8_t *tileArt(uint8_t attribute, uint8_t tile) {
// 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.
@@ -219,6 +223,8 @@ 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;
@@ -263,6 +269,12 @@ uint8_t videoWrite(uint8_t value, uint8_t port) {
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
@@ -330,6 +342,8 @@ 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) {
@@ -475,6 +489,54 @@ static void drawSprites(uint8_t *pixels, int width, int height) {
}
}
// ---- 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 ----
@@ -584,6 +646,7 @@ void videoRender(void) {
// 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;
+34
View File
@@ -257,6 +257,40 @@
#define VIDEO_PALETTE_BYTES 4
#define VIDEO_PALETTE_SIZE 256
// ---- The window: a layer that does not scroll ----
//
// A status bar over a playfield. 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 is a score
// that slides away, and one printed into the rows the view happens to be showing is one that
// jumps a pixel at a time as the fine offset changes.
//
// ITS OWN MEMORY, and that is the whole argument. The first design drew the top rows of the
// MAP without the scroll applied, which needs no new memory and costs 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 whole point of a status bar is that
// it is not somewhere in the level.
//
// 0xC000 to 0xFFFF in the screen bank, which the map does not reach: it ends at 0xBFFF. Sixty
// four rows of the same 256 byte cells, so a window row is a page like a map row is, and a
// cell in it means exactly what a cell in the map means - same tiles, same pages, same
// colour schemes.
//
// PER SCREEN, because it is in the screen bank: flipping to the other buffer flips the status
// bar with it, which is what a double buffered game wants and would be surprising the other
// way round.
#define VIDEO_WINDOW_BASE 0xC000
#define VIDEO_WINDOW_ROWS 64
// How many screen rows tall it is, and which screen row it starts at. NOUGHT IS OFF, so a
// machine that wakes up with a cleared screen has no window and every program written before
// this one means what it meant.
//
// A start row is worth a register because a status bar at the bottom is as common as one at
// the top, and working it out from the screen height is a thing every program would have to
// do again.
#define VIDEO_WINDOW_HEIGHT 0x3D
#define VIDEO_WINDOW_AT 0x3E
// ---- Modes ----
//
// Both are 8x8 cells over the same engine; only how many of them differ. The pixel count