← back to ZenZX

Pluggable video rendering

How -ns-graphics modes plug into ZenZX's display, in both the live (GUI) and headless builds. Landed 2026-08-17, replacing a design where the standard bitmap+attribute renderer was hardcoded twice: once as a GPU-texture fast path in the GUI (display.go), once as a CPU decoder duplicated near-identically in both the GUI and headless builds.

The seam: VideoRenderer

type VideoRenderer interface {
    Name() string
    Decode(mem *SpectrumMemory, screen *SpectrumScreen) *image.RGBA
    Dimensions() (width, height int)
    BorderMargins() (left, right, top, bottom int)
}

videorender.go (raylib-free, compiled into both builds). A renderer registers itself in an init():

func init() { RegisterVideoRenderer(myRenderer{}) }

keyed by Name() -- "" for the standard renderer, or the exact -ns-graphics value it implements (e.g. NSGraphicsZenZX01). Both mains resolve the active renderer once at startup, from the validated -ns-graphics value (nonstandard.go), via zx.SelectVideoRenderer. Selecting an unregistered-but-valid mode is a startup error, not a silent fallback to standard -- a not-yet-implemented mode must say so, not quietly show the wrong picture. zx.DecodeDisplay() calls the active renderer; every caller (the GUI's per-frame loop, headless screenshot capture, the zenscript shot command) goes through this one method and never touches a VideoRenderer directly.

Implementing a new mode is: write a VideoRenderer, register it. Nothing in display.go, display_headless.go, or either main needs to change.

Three design decisions (2026-08-17)

FLASH is honoured by every renderer that has it in its real attribute format. Standard mode always did; hi-colour mode now does too (fixed 2026-08-18, corroborated by the ZX-Uno manual's own attribute description for this mode -- "paper/ink/bright/flash attribute per each 8x1 pixels block" -- and this project's own docs/timex-modes.md). An earlier version of this document claimed FLASH was standard-only "by design," which was a mistaken description of a scope decision as if it reflected real hardware behaviour -- it didn't; nothing found suggests real hi-colour hardware disables or ignores this bit. FLASH state (flashEnabled, flashTickTock, lastFlashTime) lives on SpectrumScreen because that's the per-instance parameter already threaded into every Decode call, not because it's part of the generic contract; a renderer whose real format has no FLASH bit at all (should one ever be added) simply never reads those fields. screen.updateFlash() is called unconditionally once per frame from the GUI's live path, regardless of which renderer is active, and preserves this codebase's pre-existing asymmetry where headless screenshot capture never advances FLASH on its own.

Border is optional, shared infrastructure. BorderMargins() lets a renderer opt out entirely (all-zero margins); DisplayManager skips every border code path when that's the case. Border rendering itself (colour history, the stripe visualisation) is entirely orthogonal to which renderer is active -- it composites around whatever the renderer decoded, driven by the emulated machine's own port 0xFE writes, not by video-mode logic. Headless screenshots never include the border, in any mode, matching existing behaviour (the border isn't part of any mode's display file).

Magnification is shared; not every zoom level fits every mode. SpectrumScreen.multiplier is one scale factor for all modes. Because a higher-resolution mode's window at a given multiplier can exceed the monitor (e.g. a hypothetical 512x384 mode at 5x, with a proportional border, would be far larger than most displays), DisplayManager computes window and texture sizes from the active renderer's own Dimensions()/ BorderMargins() rather than assuming the standard 256x192/32-32-24-32, and maxMultiplierThatFits() clamps ScaleUp() (and the initial -scale, in InitDisplay) to what the current monitor can actually show, falling back to no clamping if the monitor size can't be determined. Not yet exercised against an actual non-256x192 renderer -- there isn't one yet (T-09) -- so treat the clamping logic as reasoned-through but unverified until a higher-resolution mode exists to test it against.

What moved where

Before After
SpectrumScreen (display.go, GUI: bitmap/attributes/flash + GPU textures) and SpectrumScreen (display_headless.go, headless: bitmap/attributes/flash only) -- two independent definitions SpectrumScreen (screen.go, one definition, raylib-free): bitmap, attributes, multiplier, flash state. Storage only.
DecodeRGBA (display.go) and DecodeRGBA (display_headless.go) -- near-identical, independently maintained standardVideoRenderer.Decode (videorender.go), one implementation. Also fixed a real divergence: the GUI's version checked flashEnabled before swapping ink/paper on FLASH; headless's didn't. Now both call the GUI's (correct) version.
GUI's per-cell fast path: 256 pre-baked 1x8 bit-pattern textures x 16 paper-colour textures, blitted per character cell (generateTextures, render()) One image.RGBA from Decode, uploaded to one rl.Texture2D per frame (rl.UpdateTexture), drawn scaled. Removed entirely rather than kept as a special case, since it only knew how to draw the standard bitmap+attribute layout.
writeScreenPNG (scheduler.go) and writePNG (zenzx_headless.go) -- two near-identical PNG-encode-a-screenshot functions One writeScreenPNG(path string, zx *ZenZX), calling zx.DecodeDisplay().
GPU texture fields (bitPatternTextures, paperColorTextures, borderTexture) and borderStripesEnabled on SpectrumScreen borderTexture, screenTexture, borderStripesEnabled on DisplayManager (GUI-only) -- these are rendering-backend resources, not display-file storage, so they don't belong on the type memory.go reads and writes directly.

Regression evidence

Before touching any rendering code, the pre-refactor headless build was built fresh from the last git commit (not the working tree) and run alongside the post-refactor build with identical flags. Screenshot output was byte-for-byte identical (SHA-256 match) across two models (48K, 128K) and multiple frame checkpoints. The standard mode's picture did not change; only the path that produces it did.