Scroll the screen sideways, and by less than a cell

The screen could move one way, a cell at a time. Three registers were
missing and this adds them: a column origin so the map can be wider than
the screen as well as taller, and a pixel remainder for each axis so the
step can be one pixel rather than eight.

  0x36  Scroll column, in cells, wrapping at 128
  0x37  Fine X, 0 to 7 pixels
  0x38  Fine Y, 0 to 7 pixels

FINE DOES NOT CARRY INTO COARSE. Writing 8 to a fine register writes 0,
because only its low three bits mean anything. The alternative was for a
write of 8 to step the coarse register, and it was rejected for one reason:
a program that scrolls has to know where it has got to, and if the hardware
carries then the only way to find out is to read the register back. Keeping
them apart means the program already knows, because it did the arithmetic
itself. It is also what the machines this one is pretending to be did.

The renderer now draws one more row and one more column than fit and clips
them, because with a fine offset the screen no longer begins on a cell
boundary and the cells at two edges are partly off it.

videoPutCell follows the column origin as it has always followed the row -
a caller means a cell of the SCREEN, and the screen is a window onto the
map. The fine offsets are deliberately not applied there: they move the
finished picture by less than a cell, and there is no such thing as less
than a cell to write into. So a program may scroll to any pixel without the
console's idea of where row three, column five is moving underneath it.

Grid now scrolls diagonally, a pixel a frame, in four port writes and two
carries. It moved eight pixels every fourth frame before, which reads as
the picture jumping rather than travelling.

Seven checks, each one the same program with one register changed, so what
is compared is where the picture stopped. Breaking fine X, fine Y, the
column origin, the three-bit mask, or the console's use of the origin each
fails exactly one of them.

Grid's own two checks had to be rewritten, and the reason is worth keeping:
they asked whether pixel 4 was a grid line, which was really a check that
the scroll happened to be at a cell boundary. A picture that moves a pixel
a frame can only be asked things that are true at every offset - that it
repeats every eight pixels, and that one band of eight rows holds different
colours from the next.

Also repairs docs.sh, which found the minimal CosmOS application by taking
the first asm block in the README. Documenting a program with an example
above it made that a different block, and the check complained that the
minimal application had no #Base about something that never claimed to be
one. It looks under System Services now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
Anachronaut
2026-08-30 18:52:15 -04:00
co-authored by Claude Opus 5
parent 848103f5e4
commit bcd42e75ca
10 changed files with 329 additions and 61 deletions
+66 -10
View File
@@ -24,6 +24,13 @@ static uint8_t mode;
// 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;
@@ -140,9 +147,19 @@ void videoLoadFont(void) {
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 = videoRAM + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE
+ column * VIDEO_CELL_BYTES;
+ mapColumn * VIDEO_CELL_BYTES;
cell[0] = tile;
cell[1] = attribute;
}
@@ -161,6 +178,9 @@ void videoReset(void) {
memset(videoRAM, 0, sizeof(videoRAM));
mode = VIDEO_MODE_40x25;
scroll = 0;
scrollColumn = 0;
fineX = 0;
fineY = 0;
renderedWidth = 0;
renderedHeight = 0;
lastFrame = videoNow;
@@ -202,6 +222,19 @@ uint8_t videoWrite(uint8_t value, uint8_t port) {
// 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;
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.
@@ -236,6 +269,9 @@ uint8_t videoRead(uint8_t port) {
// 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;
// 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);
@@ -270,14 +306,24 @@ void videoRender(void) {
const int rows = rowsFor(mode);
const int width = columns * VIDEO_CELL_PIXELS;
for (int row = 0; row < rows; row++) {
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 = videoRAM + VIDEO_MAP_BASE + mapRow * VIDEO_MAP_STRIDE;
for (int column = 0; column < columns; column++) {
const uint8_t tile = cells[column * VIDEO_CELL_BYTES];
uint8_t attribute = cells[column * VIDEO_CELL_BYTES + 1];
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
@@ -301,18 +347,28 @@ void videoRender(void) {
const uint8_t bank = (uint8_t)((attribute & 0x0F) << 4);
const uint8_t *art = videoRAM + VIDEO_TILE_BASE + tile * VIDEO_TILE_BYTES;
for (int y = 0; y < VIDEO_CELL_PIXELS; y++) {
uint8_t *out = pixels + ((row * VIDEO_CELL_PIXELS + y) * width
+ column * VIDEO_CELL_PIXELS) * 3;
// 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 index = (uint8_t)(art[y * VIDEO_CELL_PIXELS + x] + bank);
const uint8_t *entry = videoRAM + VIDEO_PALETTE_BASE
+ index * VIDEO_PALETTE_BYTES;
*out++ = entry[0];
*out++ = entry[1];
*out++ = entry[2];
uint8_t *out = pixels + (atY * width + atX) * 3;
out[0] = entry[0];
out[1] = entry[1];
out[2] = entry[2];
}
}
}