The screen is two banks: an atlas and a screen

Tiles and colours are written when a program loads; the map is written
whenever anything moves. Sharing one 64K bank made them compete for room
neither needed all of, and had a worse consequence than being cramped: a
bitmap covers the whole bank, so entering bitmap mode destroyed the font.
A program could not draw a picture and then say anything about it.

Split, each gets a whole bank. The atlas holds the tiles and the palette,
the screen holds the map or a bitmap, and a picture now costs the map and
nothing else. It also leaves 48K free in the atlas, which is where the
sprite table and a second page of tiles are going.

No new mechanism was needed. A bank is registered by naming the port that
owns it, so a device with two banks needs two ports that own memory: the
base port keeps the atlas, since tiles have been at 0x0000 since there was
a screen at all, and 0x3A owns the screen. The registry now answers
honestly about which ports in the block bring memory, where it used to say
all sixteen did.

CosmOS never addresses video memory except in one place - the screen save,
which walks 196 pages of it. The page number already says which bank a page
is in, so screenBankFor works it out rather than keeping a second list
beside screenPageFor. Grid and picture.asm register both banks; colours.asm
only touches the palette and needed none of it.

Tests/video.sh names the memory every write is for, because an address
cannot: tile 5 and bitmap pixel 5 are both 0x0005, and a helper that
guessed would be right for the tiles and silently wrong for a picture.

And picture.asm gained a check, because this change broke it and nothing
noticed - registering the second bank leaves DestBank pointing at it, so
the palette went into the wrong one and the picture came out black. It was
the only thing here found by looking rather than by a test.

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-02 10:09:53 -04:00
co-authored by Claude Opus 5
parent 2abc8281df
commit 66e7b84272
17 changed files with 313 additions and 108 deletions
+26 -13
View File
@@ -11,7 +11,11 @@
// 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];
// 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_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
@@ -129,7 +133,7 @@ void videoLoadFont(void) {
// 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 = videoRAM + VIDEO_TILE_BASE + glyph * VIDEO_TILE_BYTES;
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++) {
@@ -140,7 +144,7 @@ void videoLoadFont(void) {
}
void videoLoadPalette(void) {
uint8_t *palette = videoRAM + VIDEO_PALETTE_BASE;
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.
@@ -165,7 +169,7 @@ void videoPutCell(int screenRow, int column, uint8_t tile, uint8_t attribute) {
// 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 = videoRAM + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE
uint8_t *cell = videoScreen + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE
+ mapColumn * VIDEO_CELL_BYTES;
cell[0] = tile;
cell[1] = attribute;
@@ -178,11 +182,12 @@ void videoScrollUp(void) {
// 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(videoRAM + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE, 0, VIDEO_MAP_STRIDE);
memset(videoScreen + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE, 0, VIDEO_MAP_STRIDE);
}
void videoReset(void) {
memset(videoRAM, 0, sizeof(videoRAM));
memset(videoAtlas, 0, sizeof(videoAtlas));
memset(videoScreen, 0, sizeof(videoScreen));
mode = VIDEO_MODE_40x25;
scroll = 0;
scrollColumn = 0;
@@ -201,9 +206,17 @@ void videoReset(void) {
videoLoadPalette();
}
uint8_t *videoMemory(uint32_t *capacity) {
uint8_t *videoMemory(uint8_t port, uint32_t *capacity) {
*capacity = VIDEO_MEMORY_BYTES;
return videoRAM;
if (port == VIDEO_STATUS) {
return videoAtlas;
}
if (port == VIDEO_SCREEN) {
return videoScreen;
}
// 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) {
@@ -320,8 +333,8 @@ void videoRender(void) {
// 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 = videoRAM + VIDEO_PALETTE_BASE;
const uint8_t *from = videoRAM + VIDEO_BITMAP_BASE;
const uint8_t *palette = videoAtlas + VIDEO_PALETTE_BASE;
const uint8_t *from = videoScreen + 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;
@@ -351,7 +364,7 @@ void videoRender(void) {
// 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;
const uint8_t *cells = videoScreen + 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];
@@ -377,7 +390,7 @@ void videoRender(void) {
// 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;
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
@@ -395,7 +408,7 @@ void videoRender(void) {
// 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
const uint8_t *entry = videoAtlas + VIDEO_PALETTE_BASE
+ index * VIDEO_PALETTE_BYTES;
uint8_t *out = pixels + (atY * width + atX) * 3;
out[0] = entry[0];