diff --git a/Source/Emulator/video.c b/Source/Emulator/video.c index db80685..07eeb70 100644 --- a/Source/Emulator/video.c +++ b/Source/Emulator/video.c @@ -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; diff --git a/Source/Emulator/video.h b/Source/Emulator/video.h index 9dda0e2..f81b4a4 100644 --- a/Source/Emulator/video.h +++ b/Source/Emulator/video.h @@ -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 diff --git a/SplitBit Programming Manual.md b/SplitBit Programming Manual.md index 7b7be15..fce014d 100644 --- a/SplitBit Programming Manual.md +++ b/SplitBit Programming Manual.md @@ -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. | | Screen | 0x0000 - 0x3FFF | Free in a tile mode. | | 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. | **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. +### 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: 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. | | 0x3B | Owns screen 1. Not read or written. | | 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 | | --- | --- | --- | diff --git a/Tests/video.sh b/Tests/video.sh index 4afe85a..d8126a0 100755 --- a/Tests/video.sh +++ b/Tests/video.sh @@ -202,6 +202,15 @@ pageTile() { 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 + 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: # index nought is what a sprite does not draw. 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 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 ---- # # One red cell in each screen, in different rows: row one of the screen being shown, and the