Writing a video board
The lamp chapter built a board that latches a byte. This one builds a board that paints —
and it is the same board with one more connection: a host video service it draws into. The
worked example is CADzilla (src/boards/cadzilla.{h,cpp}, docs/boards/cadzilla.md), an
HD63484 ACRTC with a Bt453 RAMDAC, chosen because it exercises every part of the seam: a chip
with its own frame memory, a chip that is the palette, and a picture you can only reach by
issuing commands. The Dazzler and the VDM-1 are the two simpler shapes and are cited where
they differ.
Everything here is tested with no window. That is not a limitation to work around; it is the design, and the last section is how you prove a picture without one.
1. The nine questions
A video board is an ordinary Board that happens to paint, and nine decisions turn into code.
Answer them in the board's docs/boards/*.md before writing the class; seven become a
method each, and the other two are the Limitations section.
| # | Question | CADzilla's answer | Where it lands |
|---|---|---|---|
| 1 | Name. The chip or the common name, never a catalog number. "board", never "card" (DESIGN.md §0.3) |
cadzilla |
type(), the registry, the filename |
| 2 | I/O footprint — which ports, and separately which answer IN vs OUT: the bus decodes by cycle type |
one 8-port block from port: +0/+2 the ACRTC (both directions, different registers each way), +1 the board's own MODE register (write-only), +4..+7 the Bt453 (both directions); +3, the high byte of a 16-bit transfer an 8-bit host never makes, answers nothing |
decodes(), ioMap() |
| 3 | Memory footprint. On-board screen RAM the CPU addresses (VDM-1 decodes a 1 KB window), a framebuffer in main RAM (Dazzler decodes none, reads RAM), or private memory (CADzilla decodes none, and the CPU never sees it) | none | decodes(), memMap() |
| 4 | Where pixels come from and how you know they moved. A write to on-board RAM latches a dirty flag; main RAM has to be polled against a shadow; a chip with its own memory tells you | Hd63484::takeDirty(), Bt453::takeDirty() |
pump() |
| 5 | Geometry. Native w×h — fixed, decoded from registers each frame, or the monitor's: a board with a real CRT controller can carry a fixed-frequency display and place the chip's picture in it. PixelFormat::Indexed8 is the only format the seam has |
the mode strap's VESA frame (one of the three primary VESA resolutions, 1024x768 by default); the ACRTC's picture placed by HDS/VDS against the mode's porches |
render(), acquire() |
| 6 | Palette. How many entries, from where. Dazzler: 16 from an RGBI nibble. VDM-1: 2. CADzilla: 256, and they are a chip | Bt453::palette() |
setPalette() |
| 7 | Observable timing. A status bit a guest can time (vblank, scan parity) comes off clock_->now() — never a poll counter. An oscillator the guest cannot observe (a cursor blink) comes off Display::hostSeconds() |
the drawing time at draw_rate=real: T-states carried into 2CLK (sync(), remainder kept), one Clock deadline at the command's end (arm()) so an interrupt lands with no bus cycle; the raster counter is not modeled |
sync(), arm() |
| 8 | Straps vs live status. A strap has a setter and round-trips through CONFIG SAVE; live status has no setter, and that absence is the whole signal. A register that is write-only on the wire (a chip's format byte, or a board's own glue register) is still reflected as read-only live status — the board keeps the shadow the wire cannot give back. A fixed hardware fact with no jumper on the board (CADzilla's 2 MB of SRAM) is neither a strap nor live status — it is a constant. Every video board pushes Display::widthProperty(videoWidth_) |
port, mode, draw_rate, width, interrupt; live video, picture, wiring, hspol/vspol/amode/olen, status, irq |
properties() |
| 9 | Snapshot. Runtime state only — never a strap, never the Display*. If no memory board holds your pixels, they travel with you |
both chips, frame memory as a blob |
serialize() |
Two things that are not the board's business, and each is the classic mistake: a keyboard
is a separate board or an endpoint, never the window (host/display.h's header note); and the
frame rate is the host's (wantsFrame()), never yours.
2. Chips first, if the board has any
CADzilla's two parts are chips before they are a board, in src/chips/, and that split is
the one the FD1771 and the 8257 already made (chips/wd17xx.h, chips/i8257.h): a chip
knows nothing about S-100 or about the board's ports. It has pins — the ACRTC's RS, the
Bt453's C1C0 — and the board decodes an address onto them:
uint8_t CadzillaBoard::read(const BusCycle& c) {
uint8_t off = (uint8_t)(c.port() - port_);
if (off == 0 || off == 2) return acrtc_.read(off == 2); // RS: +0 status, +2 data/FIFO
return dac_.read(off & 3); // C1C0 = A1A0
}
The pay-off is that a chip is tested on its own, from its data sheet, with no bus at all —
tests/test_hd63484.cpp and tests/test_bt453.cpp — and the board test is left with only the
board's own decisions to prove. The decisions a chip cannot make are the board's, and they
belong in the board header where CADzilla's are: the shift register (8 bits per pixel, 8 words
per fetch, low byte first), the monitor (a fixed VESA frame), how much frame memory was fitted,
what the overlay inputs are tied to, where IRQ* is strapped. Draw the line where the hardware draws it:
the ACRTC only ever puts an address on its bus, so Hd63484 answers address-level questions
(backgroundRaster(), windowRaster(), gaiWords()) and the board fetches words and makes
pixels of them — which is also why a program that sets the chip to 4 bpp gets a scrambled
picture here, as it would on the board, instead of a helpfully re-unpacked one.
3. The board: three gates and one call
The whole host side of a video board is pump(), and it is three gates in a fixed order:
void CadzillaBoard::pump() {
if (!g_display) return; // no service at all (the bench)
bool a = acrtc_.takeDirty(); // consume BOTH flags every time, so one
bool d = dac_.takeDirty(); // is never lost behind the other
dirty_ = dirty_ || a || d;
if (!dirty_) return; // deterministic: would it look different?
if (!g_display->wantsFrame()) return; // host-side rate limit; 0 = always, in tests
render();
dirty_ = false;
}
The order matters. Change detection is deterministic and always on: it is what makes a headless test reproducible. The frame limit is wall-clock and off in tests: it is a host economy that changes what is drawn, never what is computed. Put them the other way round and a test's frame count depends on the machine it ran on.
render() is one call that is the window, and its arguments are not decoration:
Surface* s = g_display->acquire(this, id, m.width, m.height, PixelFormat::Indexed8, videoWidth_);
this keys this board's own window (issue #234: two boards of the same resolution would
otherwise land on one Surface), id titles it, videoWidth_ sizes it — and m is the
monitor mode, so the frame is the monitor's, not the chip's. Get that line right and the SDL
back end's windowing, integer scaling, CRT look, focus policy and close box all arrive with no
further code — and a NullDisplay keeps the same Surface per owner for a test to read. Then
paint every pixel (the Surface may be last frame's buffer), hand over the palette, and present:
g_display->setPalette(this, dac_.palette()); // the RAMDAC IS the palette, verbatim
s->clear(0); // blanking is black
if (on) paintFrame(s, m.width, m.height); // the shift register, over the ACRTC's addresses
g_display->present(this, s);
That is the seam's whole shape, and for a board with a RAMDAC it is not an abstraction: an
Indexed8 Surface is the eight-bit pixel bus and setPalette() is the look-up table.
Nothing gets translated.
4. Wiring — every file a video board touches
| File | What goes in |
|---|---|
CMakeLists.txt |
the .cpps in altair_core; the test in altair_tests |
src/boards/registry.cpp |
boardTypes() and makeBoard() |
src/main.cpp and tests/main.cpp |
YourBoard::setDisplay(&g_display) — miss the second and the board draws into nothing in every test |
tests/test.h, tests/main.cpp |
the suite's declaration and its table row |
machines/<name>.toml |
a sample machine |
docs/boards/<name>.md |
from _TEMPLATE.md; Limitations and Quirks are the load-bearing sections |
docs/manual/boards.md, docs/changelog/changelog.md |
the prose list and an Unreleased line (add the ## Unreleased header if it is not there) — no test guards either |
docs/manual/ref/ |
regenerated: cmake --build build --target docs-reference |
Nothing in the bus, the monitor, the TOML loader or the MCP server changes. properties() is
the entire configuration layer.
5. Proving a picture with no window
test_dazzler.cpp used to check a picture one pixel at a time — px(32, 0) == 1 — which is
fine for "is this element red" and useless for "is this the picture", and prints nothing you
can look at when it fails. Two pieces fix that.
host/framedump.h takes a Surface and its palette out of the seam in the forms a person
and a test can use: frameText() renders it as a text grid, one character per pixel from a
legend; framePpm() / writePpm() resolve it through the palette into a PPM image any
viewer opens; frameCrc() hashes the resolved RGB, so a palette-only change moves it. For a
board with a RAMDAC, frameRgb() is literally the DAC in software — what the wire carries.
tests/framecheck.h builds the assertion on top. The expected picture is a raw string
literal in the test, readable by a person; on a mismatch the failure prints the first row that
differs with a caret under the column and writes what the board drew as a .ppm, naming the
path. A 640x480 frame is not readable one character per pixel, so CADzilla's end-to-end test
draws its rectangle, line and dot on a 32-pixel grid and samples the frame at the same pitch —
20 x 15 characters that a person can check against the commands that drew them:
TextGridOpts every32;
every32.xStep = 32;
every32.yStep = 32;
CHECK_FRAME_OPTS(g.disp, g.cad, R"(
11111111111111111111
1..................1
1..................1
1..................1
1..................1
1..................1
1..................1
1.3333333333333333.1
1..................1
1..................1
1..................1
1.........2........1
1..................1
1..................1
11111111111111111111
)", every32, "sampled every 32nd pixel: the rectangle, the line and the dot, right way up");
Every character is a pixel's palette index. A rectangle one row too high, a mirrored X axis, a
picture placed one memory cycle late — each is visible in the diff, and the .ppm beside it
shows the full-resolution picture through the colors the guest loaded.
Be clear about what a sampled grid proves. It looks at one pixel in a thousand: it proves
the geometry in a form a person can read, and nothing about the pixels between the samples.
So pair it with CHECK_FRAME_PIXELS, which compares every pixel against an oracle the
test writes — a function from (x, y) to the palette index the commands must have left there.
For a rectangle, a line and a dot that is four comparisons:
auto oracle = [](int x, int y) -> uint8_t {
if (x == 320 && y == 352) return 2; // the dot
if (y == 224 && x >= 64 && x <= 544) return 3; // the line, end excluded
bool onRect = (y == 0 || y == 448) ? (x <= 608) // top and bottom edges
: ((x == 0 || x == 608) && y < 448); // the sides
return onRect ? 1 : 0;
};
CHECK_FRAME_PIXELS(g.disp, g.cad, oracle, "all 307,200 pixels match the oracle");
A single wrong pixel anywhere fails it, and the failure names the first differing pixel, how
many differ in all, and the .ppm. The grid is for the reader; the oracle is for the proof.
Use both. For a frame you cannot describe in a function, keep a golden file under
tests/golden/ and CHECK_FRAME_GOLDEN it; the golden is rewritten only under
ALTAIR_TEST_WRITE_GOLDEN=1, because a golden that rewrites itself asserts nothing.
Then prove the two things a picture alone does not: that a palette-only change reaches the
host (frames() advanced, frameCrc() moved, the pixel bytes unchanged), and that a
snapshot repaints the same grid on a fresh board. Both are in tests/test_cadzilla.cpp.
What CADzilla left for next time
Its own docs/boards/cadzilla.md says exactly what is not modeled. Interrupts are wired —
IrqJumper and irqJumperProperty(), assertsInt()/assertsVi() reading the chip's own
irq(), and intChanged() called from every place that could move, per the lamp chapter's
closing section — so that recipe step is already done here and is the worked example for the
next board that needs it. Drawing time is done too, at draw_rate=real: the chip counts
only 2CLK and is told the time (Hd63484::advance()), and the board owns the conversion from
T-states and the one Clock deadline — the pattern for a chip whose speed depends on a clock the
board supplies. One piece still remains: the raster counter (r80 reads 0). A guest reads it
to sync to the beam, so it must come off the Clock, like the Dazzler's vblank bit; the frame's
slot timeline that Hd63484::buildTimeline() already builds for drawing time is where its
position would come from.