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. // text is a fault message printed over a game that had flipped.
static uint8_t displayed = 0; 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; static uint8_t mode;
// Which map row is drawn at the top. THE MAP IS A RING: rendering row r reads map row // 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. // (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(videoAtlas, 0, sizeof(videoAtlas));
memset(videoScreen, 0, sizeof(videoScreen)); memset(videoScreen, 0, sizeof(videoScreen));
displayed = 0; displayed = 0;
windowHeight = 0;
windowAt = 0;
mode = VIDEO_MODE_40x25; mode = VIDEO_MODE_40x25;
scroll = 0; scroll = 0;
scrollColumn = 0; scrollColumn = 0;
@@ -263,6 +269,12 @@ uint8_t videoWrite(uint8_t value, uint8_t port) {
mode = value; mode = value;
} }
break; break;
case VIDEO_WINDOW_HEIGHT:
windowHeight = value;
break;
case VIDEO_WINDOW_AT:
windowAt = value;
break;
case VIDEO_DISPLAY: case VIDEO_DISPLAY:
// A screen that does not exist is not taken, for the same reason a mode that // 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 // 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) { switch (port) {
case VIDEO_DISPLAY: case VIDEO_DISPLAY:
return displayed; return displayed;
case VIDEO_WINDOW_HEIGHT: return windowHeight;
case VIDEO_WINDOW_AT: return windowAt;
case VIDEO_STATUS: { case VIDEO_STATUS: {
uint8_t status = 0; uint8_t status = 0;
if (frameWaiting) { 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) { void videoRender(void) {
if (mode == VIDEO_MODE_BITMAP) { if (mode == VIDEO_MODE_BITMAP) {
// ---- A byte a pixel, and nothing in the way ---- // ---- 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 // 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. // them. Over the finished picture there is no order to get wrong.
drawSprites(pixels, width, height); drawSprites(pixels, width, height);
drawWindow(pixels, width, height, columns);
renderedWidth = width; renderedWidth = width;
renderedHeight = rows * VIDEO_CELL_PIXELS; renderedHeight = rows * VIDEO_CELL_PIXELS;
+34
View File
@@ -257,6 +257,40 @@
#define VIDEO_PALETTE_BYTES 4 #define VIDEO_PALETTE_BYTES 4
#define VIDEO_PALETTE_SIZE 256 #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 ---- // ---- Modes ----
// //
// Both are 8x8 cells over the same engine; only how many of them differ. The pixel count // Both are 8x8 cells over the same engine; only how many of them differ. The pixel count
+19
View File
@@ -631,6 +631,7 @@ Two rather than one because the two halves of a screen are written at completely
| Atlas | 0xFC00 - 0xFFFF | The palette. 256 entries of four bytes: red, green, blue, and one unused - and tiles 240 to 255 of page 3. | | Atlas | 0xFC00 - 0xFFFF | The palette. 256 entries of four bytes: red, green, blue, and one unused - and tiles 240 to 255 of page 3. |
| Screen | 0x0000 - 0x3FFF | Free in a tile mode. | | Screen | 0x0000 - 0x3FFF | Free in a tile mode. |
| Screen | 0x4000 - 0xBFFF | The map. 128 rows of 256 bytes. | | Screen | 0x4000 - 0xBFFF | The map. 128 rows of 256 bytes. |
| Screen | 0xC000 - 0xFFFF | The window. 64 rows of cells that do not scroll. |
| Screen | 0x0000 - 0xF9FF | In bitmap mode, the picture instead: 64,000 bytes, one to a pixel. | | Screen | 0x0000 - 0xF9FF | In bitmap mode, the picture instead: 64,000 bytes, one to a pixel. |
**There are two screen banks and one atlas**, laid out identically, and the device shows one screen at a time. See Two Screens below. **There are two screen banks and one atlas**, laid out identically, and the device shows one screen at a time. See Two Screens below.
@@ -732,6 +733,22 @@ The buffer belongs to the program. It is not cleared between frames, because a p
`Programs/CosmOS/Apps/Sprite.asm` moves one across the shell's own text without writing a byte of the map. `Programs/CosmOS/Apps/Sprite.asm` moves one across the shell's own text without writing a byte of the map.
### The 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 is a score that 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 screen rows tall it is and **0x3E** is which row it starts at. Zero tall is no window, so a cleared screen has none and every program written before it existed 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.
Its cells are at **0xC000 in the screen bank**, 64 rows of 256 bytes, and a cell means exactly what a map cell means - same tiles, same pages, same colour schemes. **A window cell is at a screen position**, where a map cell is at a position in a world the screen is looking at part of; keeping those two coordinate systems apart is the whole of the feature.
**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, which needs no new memory at all - 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.
Being in the screen bank means it is **per screen**: 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.
**It is drawn over everything, sprites included.** A sprite that could cover the fuel gauge would be a bug in every game that had both.
It is a tile-mode layer. In bitmap mode there is nothing to draw it from - the picture is using that memory - so a bitmap program that wants something pinned to the screen uses sprites, which are in screen coordinates for the same reason.
### Cells: ### Cells:
Two bytes. The first says which tile, the second how to colour it. Two bytes. The first says which tile, the second how to colour it.
@@ -763,6 +780,8 @@ Bits 6 and 7 are still reserved and should be left at zero.
| 0x3A | Owns screen 0. Not read or written. | | 0x3A | Owns screen 0. Not read or written. |
| 0x3B | Owns screen 1. Not read or written. | | 0x3B | Owns screen 1. Not read or written. |
| 0x3C | Display. Which of the two screens is being shown. | | 0x3C | Display. Which of the two screens is being shown. |
| 0x3D | Window height, in rows. Zero is no window. |
| 0x3E | Window start, which screen row it begins at. |
| Mode | Screen | Cells | | Mode | Screen | Cells |
| --- | --- | --- | | --- | --- | --- |
+78
View File
@@ -202,6 +202,15 @@ pageTile() {
pokeAtlasRun "$(( $1 * 0x4000 + $2 * 64 ))" "$3" 64 pokeAtlasRun "$(( $1 * 0x4000 + $2 * 64 ))" "$3" 64
} }
# One cell of the window layer, which is 0xC000 in the screen bank and a page a row, exactly
# as the map is - the same cells, and never anywhere near the map's own rows.
pokeWindow() {
# pokeWindow <row> <column> <tile> <attribute>
local at=$(( 0xC000 + $1 * 256 + $2 * 2 ))
pokeScreen "$at" "$3"
pokeScreen "$(( at + 1 ))" "$4"
}
# The top half one index and the bottom half another, which is how a tile gets a hole in it: # The top half one index and the bottom half another, which is how a tile gets a hole in it:
# index nought is what a sprite does not draw. # index nought is what a sprite does not draw.
halfTile() { halfTile() {
@@ -745,6 +754,75 @@ echo "Checking what the video device draws."
&& result ok "a size past the screen is clipped" "it filled the screen and stopped there" \ && result ok "a size past the screen is clipped" "it filled the screen and stopped there" \
|| result no "a size past the screen is clipped" "$(pixel spritehuge 0 0) $(pixel spritehuge 319 199)" || result no "a size past the screen is clipped" "$(pixel spritehuge 0 0) $(pixel spritehuge 319 199)"
# ---- A window, which does not scroll ----
#
# The map is scrolled five rows and three pixels, and the window cell has to come out at the
# same place it would with neither. That is the entire feature: a window cell is at a SCREEN
# position, where a map cell is at a position in a world the screen is looking at part of.
{ prologue
spriteColours
solidTile 1 0x01
pokeWindow 0 2 0x01 0x00
port 0x3D 0x01 # One row tall.
port 0x3E 0x00 # At the top.
port 0x34 0x05 # And the map five rows down and three pixels into a cell.
port 0x38 0x03
epilogue
} | run window || exit 1
[ "$(pixel window 16 0)" = "255,0,0" ] && [ "$(pixel window 23 7)" = "255,0,0" ] \
&& result ok "a window cell sits where the screen is" "the scroll registers did not move it" \
|| result no "a window cell sits where the screen is" "$(pixel window 16 0) and $(pixel window 23 7)"
[ "$(pixel window 24 0)" = "0,0,0" ] \
&& result ok "and stops where it ends" "one cell wide, and the next is not it" \
|| result no "and stops where it ends" "got $(pixel window 24 0)"
# ---- Where it starts is its own register ----
#
# A status bar along the bottom is as common as one along the top, and working the row out
# from the screen height is a sum every program would otherwise do again.
{ prologue
spriteColours
solidTile 1 0x01
pokeWindow 0 2 0x01 0x00
port 0x3D 0x01
port 0x3E 10 # Ten rows down, which is eighty pixels. Shell arithmetic, so
# ten and not 0d10 - that is the assembler's notation and this
# is a printf away from being an INIA.
epilogue
} | run windowat || exit 1
[ "$(pixel windowat 16 80)" = "255,0,0" ] && [ "$(pixel windowat 16 0)" = "0,0,0" ] \
&& result ok "and it starts where it is told" "row ten, and nothing at the top" \
|| result no "and it starts where it is told" "at 80 $(pixel windowat 16 80), at 0 $(pixel windowat 16 0)"
# ---- Over everything, sprites included ----
#
# A sprite that could cover the fuel gauge would be a bug in every game that had both.
{ prologue
spriteColours
solidTile 1 0x01; solidTile 2 0x02
pokeWindow 0 2 0x01 0x00
spriteAt 0 2 0x00 16 0 0x11 0x00
port 0x3D 0x01
epilogue
} | run windowover || exit 1
[ "$(pixel windowover 16 0)" = "255,0,0" ] \
&& result ok "a window covers a sprite" "the bar wins, which is what a bar is for" \
|| result no "a window covers a sprite" "got $(pixel windowover 16 0)"
# ---- And none of it happens until it is asked for ----
#
# Nought rows is no window, so a cleared screen has none and every program written before this
# existed means what it meant. The cell is written and the height is not set.
{ prologue
spriteColours
solidTile 1 0x01
pokeWindow 0 2 0x01 0x00
epilogue
} | run windowoff || exit 1
[ "$(pixel windowoff 16 0)" = "0,0,0" ] \
&& result ok "no height is no window" "written, and not drawn" \
|| result no "no height is no window" "got $(pixel windowoff 16 0)"
# ---- Two screens, and the flip between them ---- # ---- Two screens, and the flip between them ----
# #
# One red cell in each screen, in different rows: row one of the screen being shown, and the # One red cell in each screen, in different rows: row one of the screen being shown, and the