Give the Voyager a screen
A tile engine on ports 0x30 to 0x3F, bringing one bank of video memory registered the way the disk's buffer is. The CPU writes cell indices and the device turns them into pixels, which is the whole reason a screen is affordable at a megahertz: a frame is 16,667 cycles, a full 320 by 200 picture is 64,000 bytes, and a 40 by 25 map is 2,000. A program that changes two cells writes four bytes. The cost of a screen becomes the number of cells that changed rather than the number of pixels on it. Which makes colour depth free, so the tiles are eight bits: an 8 by 8 cell is 64 pixels and each picks independently out of 256 colours, with no per-cell limit of the kind that made a Spectrum two and C64 multicolour four. The low nibble of a cell's attribute is ADDED to every index in its tile, sixteen at a time, so a tile drawn in 0 to 15 appears in any of sixteen schemes without a second copy in tile memory - and a tile wanting all 256 leaves the nibble at zero and gets them. Neither use costs the other anything. Two decisions are arithmetic rather than taste, and both come from the machine having no multiply. A map row is a page whether the mode fills it or not, so a cell address is the row number as the high byte and the doubled column as the low byte with no arithmetic at all; otherwise every cursor move on a 40 column screen would cost a row-times-40 in software. And a palette entry is four bytes rather than three, so entry n is at n times four, a shift. THE MAP IS A RING and the Scroll register says which of its 128 rows is on top. Scrolling moves a register and no memory: blitting a 40 by 25 screen up one line is 1,920 bytes inside one bank, which is twelve percent of a frame even with the controller widened, and a program printing one page would spend six frames shuffling memory. It is now one port write - and the rows that scrolled off are still there, which is where a terminal gets scrollback it never had. The device is part of the machine rather than part of the window. It renders into a buffer that is a pure function of video memory, so the same program draws the same picture with nobody watching; Voyager puts that buffer on the glass and decides nothing. Both binaries take --screen, which saves a PPM when the machine stops, and that is what makes a screen checkable on a host with no display at all. Tests/video.sh checks fourteen named behaviours rather than comparing a recorded image, because a recorded image would say "something changed" and leave which of the palette, the tile, the attribute, the map or the scroll register broke to be found by hand. Verified by breaking three things in turn: the additive nibble failed exactly one check, the scroll origin exactly two, and moving every cell one pixel sideways exactly the four about placement. Tests/docs.sh could not count past nine, which is how a suite of ten scripts reported itself as wrong for the wrong reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
co-authored by
Claude Opus 5
parent
e3ef25e3b3
commit
83623a3df3
@@ -47,6 +47,14 @@ The suite holds the two to being the same machine rather than taking it on trust
|
||||
the entire manifest through Voyager as well, with `--headless`, and requires it to satisfy
|
||||
every recorded result byte for byte.
|
||||
|
||||
**The screen belongs to the machine, not to the window.** The video device is a tile engine
|
||||
on ports 0x30 to 0x3F that brings its own bank of video memory, and it renders into a buffer
|
||||
that is a pure function of that memory - so the same program draws the same picture whether
|
||||
or not anybody is watching. Voyager puts that buffer on the glass and decides nothing about
|
||||
it. Either binary will save a picture of the screen with `--screen`, which is how a test
|
||||
suite on a host with no display checks what was drawn. See **The Screen** in the Programming
|
||||
Manual.
|
||||
|
||||
## The Machine:
|
||||
|
||||
- **Harvard architecture.** Two 64K memories, one for instructions and one for data. An instruction can only read the second, which is why strings live there and why the memory controller exists.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "io.h"
|
||||
#include "../Assembler/assembly.h" // For the fault vector numbers.
|
||||
#include "controller.h"
|
||||
#include "video.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -555,6 +556,12 @@ uint8_t *deviceMemory(uint8_t port, uint32_t *capacity) {
|
||||
*capacity = DISK_BLOCK_BYTES;
|
||||
return diskBuffer;
|
||||
}
|
||||
if (port == PORT_VIDEO) {
|
||||
// Tiles, the map and the palette, in one bank. A program blits the part that
|
||||
// changed and the rest stays as it was, which is the whole reason the screen is a
|
||||
// bank rather than a window onto a port.
|
||||
return videoMemory(capacity);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -582,6 +589,7 @@ static const DeviceRecord deviceTable[] = {
|
||||
{ PORT_MACHINE, DEVICE_MACHINE, 0 },
|
||||
{ PORT_MEMORY, DEVICE_MEMORY, DEVICE_FLAG_HAS_MEMORY },
|
||||
{ PORT_DISK, DEVICE_DISK, DEVICE_FLAG_HAS_MEMORY },
|
||||
{ PORT_VIDEO, DEVICE_VIDEO, DEVICE_FLAG_HAS_MEMORY },
|
||||
{ PORT_REGISTRY, DEVICE_REGISTRY, 0 },
|
||||
};
|
||||
static const int deviceCount = (int)(sizeof(deviceTable) / sizeof(deviceTable[0]));
|
||||
@@ -610,6 +618,10 @@ static const DeviceRecord *deviceOnPort(uint8_t port) {
|
||||
// memory and raises the line. The rest of the block reports the same device.
|
||||
return deviceOnPort(PORT_DISK);
|
||||
}
|
||||
if (port > PORT_VIDEO && port <= PORT_VIDEO_TOP) {
|
||||
// Sixteen ports, one device, and the same rule again.
|
||||
return deviceOnPort(PORT_VIDEO);
|
||||
}
|
||||
for (int i = 0; i < deviceCount; i++) {
|
||||
if (deviceTable[i].port == port) {
|
||||
return &deviceTable[i];
|
||||
@@ -639,6 +651,10 @@ uint8_t OutputHandler(uint8_t DataByte, uint8_t Address) {
|
||||
if (Address >= CONTROLLER_PORT_BASE && Address <= CONTROLLER_PORT_TOP) {
|
||||
return controllerWrite(DataByte, Address);
|
||||
}
|
||||
// And so does the screen.
|
||||
if (Address >= PORT_VIDEO && Address <= PORT_VIDEO_TOP) {
|
||||
return videoWrite(DataByte, Address);
|
||||
}
|
||||
// This function sends the DataByte to the appropriate place based on the Port Address.
|
||||
switch(Address) {
|
||||
case CONSOLE_DATA:
|
||||
@@ -704,6 +720,9 @@ uint8_t InputHandler(uint8_t Address) {
|
||||
if (Address >= CONTROLLER_PORT_BASE && Address <= CONTROLLER_PORT_TOP) {
|
||||
return controllerRead(Address);
|
||||
}
|
||||
if (Address >= PORT_VIDEO && Address <= PORT_VIDEO_TOP) {
|
||||
return videoRead(Address);
|
||||
}
|
||||
switch(Address) {
|
||||
case CONSOLE_DATA:
|
||||
// If data is sent here, it should be read from STDIN.
|
||||
|
||||
@@ -51,6 +51,14 @@
|
||||
#define DISK_BLOCK_LOW 0x21
|
||||
#define DISK_COMMAND 0x22
|
||||
#define DISK_STATUS 0x23
|
||||
// ---- The screen ----
|
||||
//
|
||||
// Sixteen ports, like the controller, and it interrupts on its base the way the disk
|
||||
// established for a device that spans more than one. The registers themselves are in
|
||||
// video.h, with the memory layout they describe.
|
||||
#define PORT_VIDEO 0x30
|
||||
#define PORT_VIDEO_TOP 0x3F
|
||||
|
||||
#define PORT_REGISTRY 0xFF
|
||||
|
||||
// ---- The console ----
|
||||
@@ -144,6 +152,7 @@ uint8_t consoleReadByte(void);
|
||||
#define DEVICE_REFUSE 0x11
|
||||
#define DEVICE_MEMORY 0x12
|
||||
#define DEVICE_DISK 0x13
|
||||
#define DEVICE_VIDEO 0x14
|
||||
|
||||
// What a device brings besides itself. This means memory that somebody has to register
|
||||
// with the controller, so the controller's own bank 2 does not count: it is already there.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "cpu.h"
|
||||
#include "controller.h"
|
||||
#include "io.h"
|
||||
#include "video.h"
|
||||
#include "utility.h"
|
||||
#include "../Assembler/assembly.h"
|
||||
#include <stdio.h>
|
||||
@@ -102,6 +103,10 @@ uint8_t machineStart(Machine *m, const EmulatorOptions *options, const char *pro
|
||||
if (options->disk != NULL && attachDisk(options->disk, options->writeProtect)) {
|
||||
return MACHINE_ERROR;
|
||||
}
|
||||
// The screen starts blank, and starts blank again on a warm restart: video memory is
|
||||
// the device's, and a reset that left last program's screen up would be a reset that
|
||||
// did not happen.
|
||||
videoReset();
|
||||
// The controller has to know where the memories are before anything can reach
|
||||
// them through it. Banks 0 and 1 are those two arrays.
|
||||
initializeController(Program, Data);
|
||||
@@ -184,6 +189,7 @@ void machineRunSlice(Machine *m) {
|
||||
m->restartFailed = 1;
|
||||
return;
|
||||
}
|
||||
videoReset();
|
||||
initializeCPU(&m->cpu, Program, Data);
|
||||
break; // Out of this batch; the loop above carries on with a new CPU.
|
||||
}
|
||||
@@ -203,7 +209,15 @@ void machineRunSlice(Machine *m) {
|
||||
}
|
||||
|
||||
void machineStop(Machine *m) {
|
||||
(void)m;
|
||||
// ---- Saving the screen ----
|
||||
//
|
||||
// Written when the machine stops, and it is what makes the screen testable at all: a
|
||||
// suite has no display, so the only way to check what was drawn is to be handed it. A
|
||||
// picture out of a headless run is also the quickest way for a person to see what a
|
||||
// program actually put on the screen without sitting and watching it happen.
|
||||
if (m->options.screen != NULL) {
|
||||
videoWriteImage(m->options.screen);
|
||||
}
|
||||
detachDisk();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ void printHelp(const char *programName) {
|
||||
printf(" default, finishes before the next instruction starts.\n");
|
||||
printf(" -W, --write-protect Attach the disk read only. A disk the host will not let\n");
|
||||
printf(" you write is read only whether you ask for this or not.\n");
|
||||
printf(" -S, --screen FILE Save a picture of the screen, as a PPM, when the machine\n");
|
||||
printf(" stops. Works with or without a window, which is how the\n");
|
||||
printf(" tests look at a screen on a host that has no display.\n");
|
||||
printf(" -h, --help Display this help message.\n");
|
||||
}
|
||||
|
||||
@@ -38,6 +41,7 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
|
||||
{"disk", required_argument, 0, 'D'},
|
||||
{"write-protect", no_argument, 0, 'W'},
|
||||
{"disk-cycles", required_argument, 0, 'L'},
|
||||
{"screen", required_argument, 0, 'S'},
|
||||
{"help", no_argument, 0, 'h'},
|
||||
{0, 0, 0, 0 }
|
||||
};
|
||||
@@ -50,9 +54,10 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
|
||||
options->disk = NULL;
|
||||
options->writeProtect = 0;
|
||||
options->diskCycles = 0;
|
||||
options->screen = NULL;
|
||||
|
||||
// Parse options
|
||||
while ((opt = getopt_long(argc, argv, "dc:fhD:WL:", long_options, &option_index)) != -1) {
|
||||
while ((opt = getopt_long(argc, argv, "dc:fhD:WL:S:", long_options, &option_index)) != -1) {
|
||||
switch (opt) {
|
||||
case 'd':
|
||||
options->debug = 1;
|
||||
@@ -82,6 +87,9 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) {
|
||||
case 'L':
|
||||
options->diskCycles = strtoul(optarg, NULL, 0);
|
||||
break;
|
||||
case 'S':
|
||||
options->screen = optarg;
|
||||
break;
|
||||
case 'h':
|
||||
printHelp(argv[0]);
|
||||
return OPTIONS_HELP;
|
||||
|
||||
@@ -22,6 +22,7 @@ typedef struct {
|
||||
unsigned long diskCycles; // How long a block move takes. Zero is instant, and the default.
|
||||
const char *disk; // Disk image to attach, or NULL for a machine with no disk.
|
||||
uint8_t writeProtect; // Attach the disk read only, the way a tab on a floppy would.
|
||||
const char *screen; // Where to save a picture of the screen when the machine stops.
|
||||
} EmulatorOptions;
|
||||
|
||||
uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// video.c
|
||||
// The Voyager's video device.
|
||||
// Written by Anachronaut
|
||||
|
||||
#include "video.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
// 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];
|
||||
|
||||
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.
|
||||
//
|
||||
// That is worth more than it looks. Blitting a 40 by 25 screen up one line is 1,920 bytes
|
||||
// inside one bank, which is 1,920 cycles even with the controller widened - twelve percent
|
||||
// of a frame, every line. A program printing one page would spend six frames shuffling
|
||||
// memory. Here it costs one port write, and the rows that scrolled off are still there,
|
||||
// which is where the console gets scrollback it never had.
|
||||
static uint8_t scroll;
|
||||
|
||||
static uint8_t pixels[VIDEO_MAX_WIDTH * VIDEO_MAX_HEIGHT * 3];
|
||||
static int renderedWidth = 0;
|
||||
static int renderedHeight = 0;
|
||||
|
||||
static int columnsFor(uint8_t m) { return m == VIDEO_MODE_80x50 ? 80 : 40; }
|
||||
static int rowsFor(uint8_t m) { return m == VIDEO_MODE_80x50 ? 50 : 25; }
|
||||
|
||||
void videoReset(void) {
|
||||
memset(videoRAM, 0, sizeof(videoRAM));
|
||||
mode = VIDEO_MODE_40x25;
|
||||
scroll = 0;
|
||||
renderedWidth = 0;
|
||||
renderedHeight = 0;
|
||||
}
|
||||
|
||||
uint8_t *videoMemory(uint32_t *capacity) {
|
||||
*capacity = VIDEO_MEMORY_BYTES;
|
||||
return videoRAM;
|
||||
}
|
||||
|
||||
uint8_t videoWrite(uint8_t value, uint8_t port) {
|
||||
switch (port) {
|
||||
case VIDEO_MODE:
|
||||
// A mode that does not exist is not taken. Refusing outright would be the other
|
||||
// choice, but a screen is not the place to stop the machine: a program that
|
||||
// asked for something impossible still has the screen it had.
|
||||
if (value < VIDEO_MODE_COUNT) {
|
||||
mode = value;
|
||||
}
|
||||
break;
|
||||
case VIDEO_SCROLL:
|
||||
// Wrapped rather than clipped, because the map is a ring and every byte names a
|
||||
// row that exists.
|
||||
scroll = (uint8_t)(value % VIDEO_MAP_ROWS);
|
||||
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.
|
||||
break;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint8_t videoRead(uint8_t port) {
|
||||
switch (port) {
|
||||
// Reserved for the frame interrupt, which is the next rung. Zero until then.
|
||||
case VIDEO_STATUS: return 0;
|
||||
case VIDEO_MODE: return mode;
|
||||
// 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);
|
||||
case VIDEO_ROWS: return (uint8_t)rowsFor(mode);
|
||||
case VIDEO_SCROLL: return scroll;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
void videoRender(void) {
|
||||
const int columns = columnsFor(mode);
|
||||
const int rows = rowsFor(mode);
|
||||
const int width = columns * VIDEO_CELL_PIXELS;
|
||||
|
||||
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];
|
||||
const uint8_t attribute = cells[column * VIDEO_CELL_BYTES + 1];
|
||||
// ---- The additive nibble ----
|
||||
//
|
||||
// The low nibble of the attribute is added to every palette index in the tile,
|
||||
// sixteen at a time. A tile drawn in indices 0 to 15 therefore appears in any
|
||||
// of sixteen colour schemes without a second copy of it in tile memory, and a
|
||||
// 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;
|
||||
for (int y = 0; y < VIDEO_CELL_PIXELS; y++) {
|
||||
uint8_t *out = pixels + ((row * VIDEO_CELL_PIXELS + y) * width
|
||||
+ column * VIDEO_CELL_PIXELS) * 3;
|
||||
for (int x = 0; x < VIDEO_CELL_PIXELS; x++) {
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
renderedWidth = width;
|
||||
renderedHeight = rows * VIDEO_CELL_PIXELS;
|
||||
}
|
||||
|
||||
const uint8_t *videoPixels(int *width, int *height) {
|
||||
*width = renderedWidth;
|
||||
*height = renderedHeight;
|
||||
return pixels;
|
||||
}
|
||||
|
||||
// A binary PPM, because it is the smallest format that needs no library to write and no
|
||||
// library to read - which matters when the thing reading it is a test script.
|
||||
int videoWriteImage(const char *path) {
|
||||
videoRender();
|
||||
FILE *file = fopen(path, "wb");
|
||||
if (file == NULL) {
|
||||
fprintf(stderr, "Error: Couldn't write the screen to: %s\n", path);
|
||||
return 1;
|
||||
}
|
||||
fprintf(file, "P6\n%d %d\n255\n", renderedWidth, renderedHeight);
|
||||
size_t bytes = (size_t)renderedWidth * (size_t)renderedHeight * 3;
|
||||
size_t written = fwrite(pixels, 1, bytes, file);
|
||||
fclose(file);
|
||||
if (written != bytes) {
|
||||
fprintf(stderr, "Error: The screen was not written whole to: %s\n", path);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// video.h
|
||||
// The Voyager's video device.
|
||||
// Written by Anachronaut
|
||||
|
||||
#ifndef VIDEO_H
|
||||
#define VIDEO_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// ---- What this is ----
|
||||
//
|
||||
// A tile engine. The CPU writes cell indices and the device expands them into pixels, which
|
||||
// is the difference between a screen costing 2,000 bytes a frame and 64,000 - and at a
|
||||
// megahertz that is the difference between a screen and no screen at all.
|
||||
//
|
||||
// It follows that COLOUR DEPTH IS FREE AT FRAME TIME. The map is the same size whether the
|
||||
// tiles behind it are one bit deep or eight, because the depth lives in tile memory, which
|
||||
// is written once when a program loads and not sixty times a second. So the tiles are eight
|
||||
// bits: an 8x8 cell is 64 pixels and each one picks independently out of 256 colours, with
|
||||
// no per-cell limit of the kind that made a Spectrum two and C64 multicolour four.
|
||||
//
|
||||
// ---- The device brings memory ----
|
||||
//
|
||||
// One bank, registered the way the disk's buffer is, so it costs a program nothing in Data
|
||||
// Memory and keeps what is in it between frames. A program blits the region that changed
|
||||
// and the rest stays as it was, which is the whole reason this is a bank rather than a
|
||||
// window onto a port.
|
||||
|
||||
#define VIDEO_MEMORY_BYTES 0x10000
|
||||
|
||||
// Tile memory: 256 tiles of 8x8, one byte a pixel.
|
||||
#define VIDEO_TILE_BASE 0x0000
|
||||
#define VIDEO_TILE_BYTES 64
|
||||
#define VIDEO_TILE_COUNT 256
|
||||
|
||||
// ---- The map, one page a row ----
|
||||
//
|
||||
// A row is padded to exactly 256 bytes whether the mode uses all of it or not, and that is
|
||||
// not waste, it is arithmetic. THE MACHINE HAS NO MULTIPLY. On a 40 column screen every
|
||||
// cursor move would otherwise need row times 40 in software, which is a tax on the most
|
||||
// common operation in the whole system. At a page a row the address needs no arithmetic at
|
||||
// all: the row number IS the high byte and the doubled column IS the low byte.
|
||||
//
|
||||
// It also frees the geometry from having to be a power of two, which is what lets the
|
||||
// pixel resolution be whatever looks right.
|
||||
#define VIDEO_MAP_BASE 0x4000
|
||||
#define VIDEO_MAP_STRIDE 256
|
||||
#define VIDEO_MAP_ROWS 128
|
||||
#define VIDEO_MAP_COLUMNS (VIDEO_MAP_STRIDE / 2)
|
||||
|
||||
// Two bytes to a cell: which tile, and how to colour it.
|
||||
#define VIDEO_CELL_BYTES 2
|
||||
|
||||
// ---- The palette ----
|
||||
//
|
||||
// Four bytes an entry rather than three, for the same reason a map row is a page: entry n
|
||||
// begins at n times four, which is a shift. Three would need a multiply the machine does
|
||||
// not have. The fourth byte is unused and reads as whatever was put there.
|
||||
#define VIDEO_PALETTE_BASE 0xC000
|
||||
#define VIDEO_PALETTE_BYTES 4
|
||||
#define VIDEO_PALETTE_SIZE 256
|
||||
|
||||
// ---- Modes ----
|
||||
//
|
||||
// Both are 8x8 cells over the same engine; only how many of them differ. The pixel count
|
||||
// costs the CPU nothing, because it only ever writes the map - which is why the larger mode
|
||||
// is affordable at all.
|
||||
#define VIDEO_MODE_40x25 0
|
||||
#define VIDEO_MODE_80x50 1
|
||||
#define VIDEO_MODE_COUNT 2
|
||||
|
||||
#define VIDEO_CELL_PIXELS 8
|
||||
#define VIDEO_MAX_WIDTH (80 * VIDEO_CELL_PIXELS)
|
||||
#define VIDEO_MAX_HEIGHT (50 * VIDEO_CELL_PIXELS)
|
||||
|
||||
// ---- Ports ----
|
||||
//
|
||||
// Sixteen, like the controller, and it interrupts on its base the way the disk established.
|
||||
// Nothing interrupts yet; the frame interrupt is the next rung.
|
||||
#define VIDEO_STATUS 0x30
|
||||
#define VIDEO_MODE 0x31
|
||||
#define VIDEO_COLUMNS 0x32
|
||||
#define VIDEO_ROWS 0x33
|
||||
#define VIDEO_SCROLL 0x34
|
||||
|
||||
void videoReset(void);
|
||||
|
||||
uint8_t *videoMemory(uint32_t *capacity);
|
||||
|
||||
uint8_t videoWrite(uint8_t value, uint8_t port);
|
||||
uint8_t videoRead(uint8_t port);
|
||||
|
||||
// Turns what is in video memory into pixels. A pure function of that memory, so the same
|
||||
// contents give the same picture with nobody watching - which is what lets the suite check
|
||||
// a screen on a machine that has no display.
|
||||
void videoRender(void);
|
||||
|
||||
// The pixels the last render produced, three bytes each, red then green then blue.
|
||||
const uint8_t *videoPixels(int *width, int *height);
|
||||
|
||||
// Renders and writes a binary PPM. Returns 0 if it worked.
|
||||
int videoWriteImage(const char *path);
|
||||
|
||||
#endif // VIDEO_H
|
||||
+40
-11
@@ -16,22 +16,22 @@
|
||||
// lets a test suite with no display hold this binary to the same behaviour as the other
|
||||
// one.
|
||||
//
|
||||
// At this stage the window is empty. There is no video device yet, and inventing a
|
||||
// temporary way to draw would mean building something to throw away.
|
||||
// The window shows what the video device produced and decides nothing about it. Render is a
|
||||
// pure function of video memory, so the same program draws the same picture whether or not
|
||||
// anybody is watching - which is what lets a suite with no display check a screen.
|
||||
|
||||
#include "machine.h"
|
||||
#include "video.h"
|
||||
#include "utility.h"
|
||||
#include "raylib.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <getopt.h>
|
||||
|
||||
// The screen the Voyager will have, scaled up because a 320 by 200 window is a postage
|
||||
// stamp on a modern display. Both numbers are provisional until the video device decides
|
||||
// them for real.
|
||||
#define SCREEN_WIDTH 320
|
||||
#define SCREEN_HEIGHT 200
|
||||
#define SCREEN_SCALE 3
|
||||
// The window is the largest screen the video device can produce, scaled up because a 320 by
|
||||
// 200 window is a postage stamp on a modern display. A smaller mode is drawn into the middle
|
||||
// of it rather than resizing the window out from under whoever is looking at it.
|
||||
#define SCREEN_SCALE 2
|
||||
|
||||
// ---- Running without a window ----
|
||||
//
|
||||
@@ -96,9 +96,15 @@ int main(int argc, char *argv[]) {
|
||||
machineRunSlice(&machine);
|
||||
}
|
||||
} else {
|
||||
InitWindow(SCREEN_WIDTH * SCREEN_SCALE, SCREEN_HEIGHT * SCREEN_SCALE,
|
||||
InitWindow(VIDEO_MAX_WIDTH * SCREEN_SCALE, VIDEO_MAX_HEIGHT * SCREEN_SCALE,
|
||||
"Segan Voyager");
|
||||
SetTargetFPS(60);
|
||||
// One texture, updated in place. Making a new one every frame would be a new
|
||||
// allocation sixty times a second for a picture that is the same size every time.
|
||||
Image blank = GenImageColor(VIDEO_MAX_WIDTH, VIDEO_MAX_HEIGHT, BLACK);
|
||||
ImageFormat(&blank, PIXELFORMAT_UNCOMPRESSED_R8G8B8);
|
||||
Texture2D screen = LoadTextureFromImage(blank);
|
||||
UnloadImage(blank);
|
||||
// ---- A slice a frame ----
|
||||
//
|
||||
// The machine gets its turn, then the window gets its turn. Closing the window
|
||||
@@ -109,12 +115,35 @@ int main(int argc, char *argv[]) {
|
||||
if (machineRunning(&machine)) {
|
||||
machineRunSlice(&machine);
|
||||
}
|
||||
// ---- Presenting, not deciding ----
|
||||
//
|
||||
// The device turns video memory into pixels; this puts them on the glass. The
|
||||
// split is the whole design: everything that decides what the screen looks like
|
||||
// is in the machine, where the suite can reach it.
|
||||
videoRender();
|
||||
int width, height;
|
||||
const uint8_t *frame = videoPixels(&width, &height);
|
||||
if (width > 0 && height > 0) {
|
||||
UpdateTextureRec(screen, (Rectangle){ 0, 0, (float)width, (float)height },
|
||||
frame);
|
||||
}
|
||||
BeginDrawing();
|
||||
// Not black. A screen with nothing driving it should look like a screen that
|
||||
// is on, rather than like a window that failed to open.
|
||||
// Not black. The border around a smaller mode should look like a machine with
|
||||
// a screen on, rather than like a window that failed to open.
|
||||
ClearBackground((Color){ 18, 22, 20, 255 });
|
||||
if (width > 0 && height > 0) {
|
||||
// Centred, so changing mode moves the picture rather than the window.
|
||||
Rectangle from = { 0, 0, (float)width, (float)height };
|
||||
Rectangle to = {
|
||||
(float)((VIDEO_MAX_WIDTH * SCREEN_SCALE - width * SCREEN_SCALE) / 2),
|
||||
(float)((VIDEO_MAX_HEIGHT * SCREEN_SCALE - height * SCREEN_SCALE) / 2),
|
||||
(float)(width * SCREEN_SCALE), (float)(height * SCREEN_SCALE)
|
||||
};
|
||||
DrawTexturePro(screen, from, to, (Vector2){ 0, 0 }, 0.0f, WHITE);
|
||||
}
|
||||
EndDrawing();
|
||||
}
|
||||
UnloadTexture(screen);
|
||||
CloseWindow();
|
||||
}
|
||||
|
||||
|
||||
@@ -509,9 +509,67 @@ If nothing is installed for the vector a device refused with, the machine stops
|
||||
| 0x20 - 0x23 | The disk. See Storage. It interrupts on 0x20, its base port. | 0x13 |
|
||||
| 0x13 | The machine itself. Writing 1 asks it to start over: whatever put the first instruction in memory does it again, and the CPU begins where the boot vector points. A port rather than a service, because a reset has to work when the system does not - and a program that owns the whole machine has no system to ask. The disk is not unplugged and keeps what was written to it; the vector table is cleared, because a handler left behind would aim an interrupt into a program that is no longer running. | 0x04 |
|
||||
| 0x12 | A device that owns 256 bytes of memory. Writing to its port fills that memory with the byte written, standing in for a disk controller reading a sector. Its memory is unreachable until it is registered as a bank. | 0x12 |
|
||||
| 0x30 - 0x3F | The screen. See The Screen. It brings video memory, which is unreachable until it is registered as a bank. | 0x14 |
|
||||
| 0xE0 - 0xEF | The memory controller. See The Memory Controller. | 0x03 |
|
||||
| 0xFF | The bus registry. See Asking What Is There. | 0x01 |
|
||||
|
||||
## The Screen:
|
||||
|
||||
A tile engine, on ports 0x30 to 0x3F. The CPU writes cell indices and the device turns them into pixels.
|
||||
|
||||
That indirection is the whole reason a screen is affordable here. At a megahertz a frame is 16,667 cycles, and pushing a full 320 by 200 picture a byte at a time is 64,000 bytes - four frames of work for one frame of screen. A 40 by 25 map is 2,000 bytes, and a program that changes two cells writes four. **The cost of a screen becomes the number of cells that changed rather than the number of pixels on it.**
|
||||
|
||||
It follows that colour depth is free. The map is the same size whatever is behind it, so the tiles are eight bits deep: an 8 by 8 cell is 64 pixels and each one picks independently out of 256 colours. There is no limit of two to a cell, or four, or sixteen.
|
||||
|
||||
### Video Memory:
|
||||
|
||||
One bank, brought by the device and reached only through the memory controller, like the disk's buffer. It keeps what is in it between frames, so a program writes the part that changed and the rest stays as it was.
|
||||
|
||||
| Address | Holds |
|
||||
| --- | --- |
|
||||
| 0x0000 - 0x3FFF | Tile memory. 256 tiles of 8 by 8, one byte a pixel, so tile n begins at n times 64. |
|
||||
| 0x4000 - 0xBFFF | The map. 128 rows of 256 bytes. |
|
||||
| 0xC000 - 0xC3FF | The palette. 256 entries of four bytes: red, green, blue, and one unused. |
|
||||
|
||||
**A map row is a page whether the mode fills it or not**, and that is arithmetic rather than waste. This machine has no multiply, so on a 40 column screen every cursor move would otherwise cost a `row times 40` in software - a tax on the most common operation in the system. At a page a row there is no arithmetic at all: the row number is the high byte of the address and the doubled column is the low byte.
|
||||
|
||||
A palette entry is four bytes for the same reason. Entry n begins at n times four, which is a shift; three bytes would need a multiply.
|
||||
|
||||
### Cells:
|
||||
|
||||
Two bytes. The first says which tile, the second how to colour it.
|
||||
|
||||
The low nibble of the second byte is **added to every palette index in the tile, sixteen at a time**. A tile drawn in indices 0 to 15 therefore appears in any of sixteen colour schemes without a second copy of it in tile memory. A tile that wants all 256 colours leaves the nibble at zero and gets them. The addition wraps, because a byte plus a byte is a byte.
|
||||
|
||||
The high nibble is reserved and should be left at zero, so that a meaning can be given to it later without changing what already-written programs mean.
|
||||
|
||||
### Registers:
|
||||
|
||||
| Port | Register |
|
||||
| --- | --- |
|
||||
| 0x30 | Status. Reserved for the frame interrupt, and reads zero until there is one. |
|
||||
| 0x31 | Mode. |
|
||||
| 0x32 | Columns, read only. |
|
||||
| 0x33 | Rows, read only. |
|
||||
| 0x34 | Scroll. |
|
||||
|
||||
| Mode | Screen | Cells |
|
||||
| --- | --- | --- |
|
||||
| 0 | 320 by 200 | 40 by 25 |
|
||||
| 1 | 640 by 400 | 80 by 50 |
|
||||
|
||||
Both are 8 by 8 cells over the same engine, and the pixel count costs a program nothing, because it only ever writes the map. A mode that does not exist is not taken, and is not a fault either: a screen is a poor place to stop the machine, and a program that asked for something impossible still has the screen it had.
|
||||
|
||||
How big the screen is, is asked for rather than assumed. A program written once can find out what it is running on.
|
||||
|
||||
### Scrolling:
|
||||
|
||||
**The map is a ring, and the Scroll register says which of its 128 rows is drawn at the top.** Screen row *r* shows map row *scroll + r*, wrapped.
|
||||
|
||||
Scrolling therefore moves a register and no memory at all. That is not a small saving. Moving a 40 by 25 screen up one line is 1,920 bytes inside one bank, which is 1,920 cycles even with the controller widened - twelve percent of a frame, for one line. A program printing a single page would spend six frames shuffling memory. Here it is one write to a port.
|
||||
|
||||
And the rows that scrolled off are still in the map, which is where a terminal on this machine gets scrollback without having to keep any.
|
||||
|
||||
## Asking What Is There:
|
||||
|
||||
A program that only ever runs on one machine can be told where everything is. A program meant to run on more than one has to ask, and the bus registry on port 0xFF is what it asks.
|
||||
|
||||
+20
-1
@@ -9,7 +9,7 @@ believe them.
|
||||
|
||||
## What The Suite Claims:
|
||||
|
||||
The suite is not one thing. It is nine scripts making five different kinds of claim, and
|
||||
The suite is not one thing. It is ten scripts making five different kinds of claim, and
|
||||
knowing which claim you are relying on is the whole point of this document. A recorded
|
||||
transcript and a byte-for-byte comparison against a second implementation both print
|
||||
`[ok ]`, and they are worth wildly different amounts.
|
||||
@@ -57,6 +57,7 @@ Individual scripts can be run on their own, from anywhere:
|
||||
./Tests/voyager.sh The same manifest, through the other front end.
|
||||
./Tests/disk.sh The disk tool against the format.
|
||||
./Tests/cycles.sh What the memory controller charges.
|
||||
./Tests/video.sh What the video device draws.
|
||||
./Tests/terminal.sh The things a recorded file cannot see.
|
||||
./Tests/native.sh The two assemblers against each other.
|
||||
./Tests/agree.sh The two filesystems against each other.
|
||||
@@ -143,6 +144,24 @@ then asks for the things the format says cannot happen and requires them to be r
|
||||
rather than half done. Roughly half of its checks are `refuses`, which is the shape
|
||||
worth copying: **a tool that never says no is not finished.**
|
||||
|
||||
`Tests/video.sh` belongs here too, and exists for the same reason as the two above: the
|
||||
suite has no display, and a screen nothing can look at is a screen nothing checks. The
|
||||
device renders into a buffer that is a pure function of video memory, and the machine can be
|
||||
asked to save it with `--screen`, so every check runs a program, saves the picture and reads
|
||||
pixels back out of it. No window, no display server, and the same answer every time.
|
||||
|
||||
**It checks named behaviours rather than a recorded image**, which for a screen matters more
|
||||
than usual. A recorded image would say "something changed" and leave which of the palette,
|
||||
the tile, the attribute, the map or the scroll register broke to be found by hand. Instead
|
||||
each check is one claim: that a tile lands where it is put and stops at the cell edge, that
|
||||
the palette is what colours it, that the attribute nibble adds sixteen, that scrolling moves
|
||||
which row is on top, that the map wraps, and that an impossible mode is refused without
|
||||
stopping the machine.
|
||||
|
||||
Breaking the additive nibble fails exactly one check. Breaking the scroll origin fails
|
||||
exactly two. Moving every cell one pixel sideways fails the four about placement. That is
|
||||
what a screen test is supposed to do.
|
||||
|
||||
`lint.sh` builds a fixture in which every line trips exactly one rule, and checks which
|
||||
warning came out at which line. It used to compare a total, and a total is a number that
|
||||
stays right while the thing behind it goes wrong: a change that stopped one rule firing and
|
||||
|
||||
+5
-1
@@ -601,7 +601,11 @@ flat = re.sub(r"\s+", " ", manual)
|
||||
|
||||
scripts = sorted(os.path.basename(p) for p in glob.glob("Tests/*.sh")
|
||||
if os.path.basename(p) != "makedisks.sh")
|
||||
words = {"three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9}
|
||||
# Spelled out, because that is how the documents say them. Kept a few ahead of the count so
|
||||
# that adding a script fails on the number being wrong rather than on the word being unknown,
|
||||
# which is a much less helpful thing to be told.
|
||||
words = {"three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9,
|
||||
"ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14}
|
||||
said = re.search(r"It is ([a-z]+) scripts making", flat)
|
||||
if not said:
|
||||
problems.append("the Test Manual no longer says how many scripts the suite is")
|
||||
|
||||
Executable
+248
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env bash
|
||||
# Checks what the video device actually draws.
|
||||
#
|
||||
# THE SUITE HAS NO DISPLAY, and a screen nothing can look at is a screen nothing checks. So
|
||||
# the device renders into a buffer that is a pure function of video memory, and the machine
|
||||
# can be asked to save it with --screen. Every check below runs a program, saves the picture
|
||||
# and reads pixels out of it - no window, no display server, and the same answer every time.
|
||||
#
|
||||
# Each check is a named claim about one behaviour rather than a comparison against a
|
||||
# recorded image. A recorded image would say "something changed" and leave which of the
|
||||
# palette, the tile, the attribute, the map or the scroll register broke to be found by
|
||||
# hand, which for a screen is the hardest kind of bug to see.
|
||||
#
|
||||
# Written by Anachronaut
|
||||
|
||||
set -u
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BUILD="$ROOT/Tests/build/video"
|
||||
ASM="$ROOT/Assembler"
|
||||
EMU="$ROOT/SplitBit"
|
||||
|
||||
for tool in "$ASM" "$EMU"; do
|
||||
[ -x "$tool" ] || { echo "$(basename "$tool") is not built."; exit 1; }
|
||||
done
|
||||
|
||||
rm -rf "$BUILD"; mkdir -p "$BUILD"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
FAILED_NAMES=()
|
||||
|
||||
GREEN=$'\033[32m'; RED=$'\033[31m'; RESET=$'\033[0m'
|
||||
[ -t 1 ] || { GREEN=""; RED=""; RESET=""; }
|
||||
|
||||
result() {
|
||||
# result <ok|no> <name> <detail>
|
||||
if [ "$1" = "ok" ]; then
|
||||
PASS=$((PASS + 1)); printf " [%sok %s] %-38s %s\n" "$GREEN" "$RESET" "$2" "$3"
|
||||
else
|
||||
FAIL=$((FAIL + 1)); FAILED_NAMES+=("$2")
|
||||
printf " [%sFAIL%s] %-38s %s\n" "$RED" "$RESET" "$2" "$3"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- Writing to video memory from a program ----
|
||||
#
|
||||
# Through the controller, because that is the only way to reach a device's bank: the CPU
|
||||
# never touches it directly. The Data port puts a byte at the destination and steps the
|
||||
# address on, which is what makes a poke six instructions instead of a loop.
|
||||
prologue() {
|
||||
cat <<'ASM'
|
||||
#Program
|
||||
start:
|
||||
INIA 0d3
|
||||
OUTA 0xE3
|
||||
INIA 0x30
|
||||
OUTA 0xE2
|
||||
INIA 0x03
|
||||
OUTA 0xE8 ; Video memory becomes bank 3
|
||||
ASM
|
||||
}
|
||||
|
||||
poke() {
|
||||
# poke <address> <byte>
|
||||
printf ' INIA 0x%02X\n OUTA 0xE4\n INIA 0x%02X\n OUTA 0xE5\n INIA 0x%02X\n OUTA 0xE9\n' \
|
||||
$(( ($1 >> 8) & 0xFF )) $(( $1 & 0xFF )) $(( $2 & 0xFF ))
|
||||
}
|
||||
|
||||
port() {
|
||||
# port <port> <byte>
|
||||
printf ' INIA 0x%02X\n OUTA 0x%02X\n' $(( $2 & 0xFF )) $(( $1 & 0xFF ))
|
||||
}
|
||||
|
||||
show() {
|
||||
# show <port> - sends a port's value to the console, so a test can read a register.
|
||||
# INA reads straight into A, so there is nothing to move first.
|
||||
printf ' INA 0x%02X\n OUTA 0x00\n' $(( $1 & 0xFF ))
|
||||
}
|
||||
|
||||
epilogue() {
|
||||
printf ' HALT\n#Vectors\n Boot start\n'
|
||||
}
|
||||
|
||||
# Assembles what is on standard input, runs it, and leaves the picture in $BUILD/<name>.ppm.
|
||||
run() {
|
||||
local name="$1"
|
||||
cat > "$BUILD/$name.asm"
|
||||
"$ASM" "$BUILD/$name.asm" -o "$BUILD/$name.bin" >"$BUILD/$name.log" 2>&1 || {
|
||||
echo "could not assemble $name"; sed 's/^/ /' "$BUILD/$name.log"; return 1; }
|
||||
"$EMU" --fast --screen "$BUILD/$name.ppm" "$BUILD/$name.bin" > "$BUILD/$name.out" 2>&1
|
||||
}
|
||||
|
||||
# One pixel out of a PPM, as "r,g,b".
|
||||
pixel() {
|
||||
python3 - "$BUILD/$1.ppm" "$2" "$3" <<'PY'
|
||||
import sys
|
||||
data = open(sys.argv[1], "rb").read()
|
||||
# P6, width height, maxval, then the bytes. The header is three whitespace-separated
|
||||
# fields after the magic, which is all this needs to know about the format.
|
||||
fields = data.split(b"\n", 3)
|
||||
width, height = (int(n) for n in fields[1].split())
|
||||
body = fields[3]
|
||||
x, y = int(sys.argv[2]), int(sys.argv[3])
|
||||
at = (y * width + x) * 3
|
||||
print("%d,%d,%d" % tuple(body[at:at + 3]))
|
||||
PY
|
||||
}
|
||||
|
||||
size() {
|
||||
head -c 20 "$BUILD/$1.ppm" | sed -n '2p'
|
||||
}
|
||||
|
||||
echo "Checking what the video device draws."
|
||||
|
||||
# ---- A tile lands where it is put ----
|
||||
#
|
||||
# Palette entry 1 is red, tile 1 is 64 pixels of index 1, and two cells name it: the corner
|
||||
# and column 3 of row 2. A tile drawn one cell out is the commonest way a tile engine is
|
||||
# wrong, so the check is where it is AND where it is not.
|
||||
{ prologue
|
||||
poke 0xC004 0xFF; poke 0xC005 0x00; poke 0xC006 0x00
|
||||
for i in $(seq 0 63); do poke $((0x0040 + i)) 0x01; done
|
||||
poke 0x4000 0x01; poke 0x4001 0x00
|
||||
poke $((0x4000 + 2 * 256 + 3 * 2)) 0x01
|
||||
epilogue
|
||||
} | run corner || exit 1
|
||||
|
||||
[ "$(pixel corner 0 0)" = "255,0,0" ] \
|
||||
&& result ok "a tile lands where it is put" "cell 0,0 is red" \
|
||||
|| result no "a tile lands where it is put" "got $(pixel corner 0 0)"
|
||||
[ "$(pixel corner 7 7)" = "255,0,0" ] \
|
||||
&& result ok "and fills its whole cell" "pixel 7,7 too" \
|
||||
|| result no "and fills its whole cell" "got $(pixel corner 7 7)"
|
||||
[ "$(pixel corner 8 0)" = "0,0,0" ] \
|
||||
&& result ok "and stops at the cell edge" "pixel 8,0 is not" \
|
||||
|| result no "and stops at the cell edge" "got $(pixel corner 8 0)"
|
||||
[ "$(pixel corner 24 16)" = "255,0,0" ] \
|
||||
&& result ok "row 2 column 3 is where it says" "pixel 24,16" \
|
||||
|| result no "row 2 column 3 is where it says" "got $(pixel corner 24 16)"
|
||||
|
||||
# ---- The palette is what colours it ----
|
||||
#
|
||||
# Same tile, same map, a different palette entry. Nothing about the picture changes except
|
||||
# the three bytes the colour came from.
|
||||
{ prologue
|
||||
poke 0xC004 0x00; poke 0xC005 0xFF; poke 0xC006 0x40
|
||||
for i in $(seq 0 63); do poke $((0x0040 + i)) 0x01; done
|
||||
poke 0x4000 0x01; poke 0x4001 0x00
|
||||
epilogue
|
||||
} | run palette || exit 1
|
||||
|
||||
[ "$(pixel palette 0 0)" = "0,255,64" ] \
|
||||
&& result ok "the palette is what colours it" "entry 1 moved, the tile did not" \
|
||||
|| result no "the palette is what colours it" "got $(pixel palette 0 0)"
|
||||
|
||||
# ---- The attribute picks a palette bank ----
|
||||
#
|
||||
# The tile is drawn in index 1 and never changes. Entry 1 is red and entry 17 is blue, and
|
||||
# the only difference between the two cells is the attribute nibble: 0 leaves the index
|
||||
# alone, 1 adds sixteen. This is the whole of the recolouring feature in one check.
|
||||
{ prologue
|
||||
poke 0xC004 0xFF; poke 0xC005 0x00; poke 0xC006 0x00
|
||||
poke 0xC044 0x00; poke 0xC045 0x00; poke 0xC046 0xFF
|
||||
for i in $(seq 0 63); do poke $((0x0040 + i)) 0x01; done
|
||||
poke 0x4000 0x01; poke 0x4001 0x00
|
||||
poke 0x4002 0x01; poke 0x4003 0x01
|
||||
epilogue
|
||||
} | run attribute || exit 1
|
||||
|
||||
[ "$(pixel attribute 0 0)" = "255,0,0" ] \
|
||||
&& result ok "attribute 0 leaves the index alone" "still entry 1" \
|
||||
|| result no "attribute 0 leaves the index alone" "got $(pixel attribute 0 0)"
|
||||
[ "$(pixel attribute 8 0)" = "0,0,255" ] \
|
||||
&& result ok "and attribute 1 adds sixteen" "the same tile, entry 17" \
|
||||
|| result no "and attribute 1 adds sixteen" "got $(pixel attribute 8 0)"
|
||||
|
||||
# ---- Scrolling moves a register, not memory ----
|
||||
#
|
||||
# The tile is in map row 3 and nothing moves it. Setting the scroll origin to 3 brings that
|
||||
# row to the top of the screen, which is the whole reason a terminal on this machine is
|
||||
# affordable at all.
|
||||
{ prologue
|
||||
poke 0xC004 0xFF; poke 0xC005 0xFF; poke 0xC006 0x00
|
||||
for i in $(seq 0 63); do poke $((0x0040 + i)) 0x01; done
|
||||
poke $((0x4000 + 3 * 256)) 0x01
|
||||
port 0x34 0x03
|
||||
epilogue
|
||||
} | run scroll || exit 1
|
||||
|
||||
[ "$(pixel scroll 0 0)" = "255,255,0" ] \
|
||||
&& result ok "scrolling moves which row is on top" "map row 3 at screen row 0" \
|
||||
|| result no "scrolling moves which row is on top" "got $(pixel scroll 0 0)"
|
||||
[ "$(pixel scroll 0 8)" = "0,0,0" ] \
|
||||
&& result ok "and takes the rest with it" "map row 4 below it" \
|
||||
|| result no "and takes the rest with it" "got $(pixel scroll 0 8)"
|
||||
|
||||
# ---- The map is a ring ----
|
||||
#
|
||||
# Origin 127 with 128 rows puts map row 127 at the top and map row 0 immediately under it.
|
||||
# A map that clipped instead of wrapping would show nothing on the second row.
|
||||
{ prologue
|
||||
poke 0xC004 0xFF; poke 0xC005 0xFF; poke 0xC006 0xFF
|
||||
for i in $(seq 0 63); do poke $((0x0040 + i)) 0x01; done
|
||||
poke 0x4000 0x01
|
||||
port 0x34 0x7F
|
||||
epilogue
|
||||
} | run ring || exit 1
|
||||
|
||||
[ "$(pixel ring 0 8)" = "255,255,255" ] \
|
||||
&& result ok "the map is a ring" "row 0 follows row 127" \
|
||||
|| result no "the map is a ring" "got $(pixel ring 0 8)"
|
||||
|
||||
# ---- Modes ----
|
||||
{ prologue; port 0x31 0x01; epilogue; } | run wide || exit 1
|
||||
[ "$(size wide)" = "640 400" ] \
|
||||
&& result ok "mode 1 is 640 by 400" "$(size wide)" \
|
||||
|| result no "mode 1 is 640 by 400" "got $(size wide)"
|
||||
|
||||
{ prologue; epilogue; } | run narrow || exit 1
|
||||
[ "$(size narrow)" = "320 200" ] \
|
||||
&& result ok "and mode 0 is 320 by 200" "$(size narrow)" \
|
||||
|| result no "and mode 0 is 320 by 200" "got $(size narrow)"
|
||||
|
||||
# The geometry is asked for rather than assumed, so a program can be written once and find
|
||||
# out what it is running on.
|
||||
{ prologue; show 0x32; show 0x33; epilogue; } | run geometry || exit 1
|
||||
GOT="$(head -c 2 "$BUILD/geometry.out" | od -An -tu1 | tr -s ' ' | sed 's/^ //;s/ $//')"
|
||||
[ "$GOT" = "40 25" ] \
|
||||
&& result ok "the ports say how big the screen is" "40 columns, 25 rows" \
|
||||
|| result no "the ports say how big the screen is" "got \"$GOT\""
|
||||
|
||||
# ---- A mode that does not exist ----
|
||||
#
|
||||
# Not taken, and not fatal either. A screen is a poor place to stop the machine: a program
|
||||
# that asked for something impossible still has the screen it had.
|
||||
{ prologue; port 0x31 0x09; show 0x32; epilogue; } | run badmode || exit 1
|
||||
GOT="$(head -c 1 "$BUILD/badmode.out" | od -An -tu1 | tr -d ' ')"
|
||||
[ "$GOT" = "40" ] \
|
||||
&& result ok "an impossible mode is not taken" "still 40 columns" \
|
||||
|| result no "an impossible mode is not taken" "got $GOT"
|
||||
|
||||
echo
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo "All $PASS video checks passed."
|
||||
exit 0
|
||||
fi
|
||||
echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}"
|
||||
exit 1
|
||||
@@ -38,7 +38,7 @@ OBJ_DIR = Object
|
||||
# SplitBit from Voyager is one file each: a terminal or a window. Anything that drifts out
|
||||
# of the shared list and into one of those is behaviour the other does not have, which is
|
||||
# the thing this split exists to prevent.
|
||||
MACHINE_SRCS = machine.c io.c controller.c utility.c cpu.c bootstrap.c assembly.c rom.c
|
||||
MACHINE_SRCS = machine.c io.c controller.c video.c utility.c cpu.c bootstrap.c assembly.c rom.c
|
||||
EMU_SRCS = emulator.c $(MACHINE_SRCS)
|
||||
VOY_SRCS = voyager.c $(MACHINE_SRCS)
|
||||
ASM_SRCS = Assembler.c assembly.c firstPass.c Assm-util.c secondPass.c
|
||||
@@ -204,6 +204,8 @@ test: all strict
|
||||
@echo
|
||||
@./Tests/cycles.sh
|
||||
@echo
|
||||
@./Tests/video.sh
|
||||
@echo
|
||||
@./Tests/terminal.sh
|
||||
@echo
|
||||
@./Tests/native.sh
|
||||
@@ -247,6 +249,8 @@ sanitize:
|
||||
@echo
|
||||
@./Tests/cycles.sh
|
||||
@echo
|
||||
@./Tests/video.sh
|
||||
@echo
|
||||
@./Tests/terminal.sh
|
||||
@echo
|
||||
@./Tests/native.sh
|
||||
|
||||
Reference in New Issue
Block a user