Skip to content

P8X Graphics Engine — Theory of Operation

How the display subsystem actually works, from the bus window to the panel. This is the hardware-facing narrative: what the machines inside the card are, how work flows between them, and why the design is shaped the way it is. For using the engine, see p8x-graphics-guide.md; for the GL language reference card, man gl on-target; for the design history with its decision records, fpga/tang-nano-20k/sdram/STAGE*-DESIGN.md.

The authoritative register map is generated by generators/gen_memmap.py; addresses quoted here are correct as of stage 10f but the generator wins.

1. The one front door

The CPU sees the graphics engine as ONE register window on the I/O page:

$FF50-$FF57   the GL PORT       a byte FIFO speaking the graphics
                                LANGUAGE (stage 10); text or hex

There used to be a second: the 2D DEVICE window at $FF20-$FF2F, a coordinate/pen register file plus a command register where pokes drew immediately. The single-interface migration (2026-09-01) closed it — those addresses float $FF now, on the fabric and in the emulator alike — but the register file it exposed did not go anywhere: the GL interpreter's walker masters it internally, issuing the same line/box/circle commands through the same registers a CPU poke used to reach. One Bresenham, one span filler, one pixel path; one way of asking.

Presence probe: GLID ($FF54) reads 'G' when the engine is fitted. An absent card floats the bus — software must know before poking. Since 2026-09-09 the monitor probes GLID once at wake and records the result in the resident byte GFXPRES ($1FA4); the OS re-affirms it at boot. Programs read that flag — has_graphics() in lib_gfx.c, which gpresent() now sources from — rather than re-probing the bus. This is the two-mode selector (headless serial console vs. graphics desktop; see p8x-two-mode-design.md). (The old two-byte "PG" signature at GID0/GID1 retired with its window.)

2. The 2D engine (the walker's private register file)

A register file (X0/Y0/X1/Y1 as low+high byte pairs, a 16-bit pen, scalar parameters for radii) and a command register: PIXELW, LINE, BOXFILL, PIXELR (a read), ELLIPSE, ELLIPSEFILL, and the LINPAT latch. (BOX outline, CLS and CIRCLE/CIRCLEFILL were retired by the stage-10 diet: four LINEs, a full-screen BOXFILL and the ellipse with rx=ry are the same pixels, and their walkers' fabric bought the PGC's curves, patterns and text. RESET and the IDENT record retired with the CPU window — GL RF owns reset, GLID and the bridge PING answer identity.) Writing the command register starts the operation; the busy flag holds until it completes, and a command issued while busy aborts the running one — which is why the WALKER polls it between every primitive it issues, the same discipline the libraries used from outside.

The drawing algorithms are integer state machines chosen to be step-for-step identical to the emulator's C: Bresenham with dy held negative, midpoint circle, two-region midpoint ellipse. That identity is load-bearing — see §8.

Coordinates are 16-bit and treated as unsigned; off-screen pixels are DISCARDED, not clipped (the one rule trivially identical in C and Verilog). Screen space is 480x272, y down, RGB565 direct colour: a pixel IS its colour.

GMODE ($FF2E's write side, stage 10f) selects the pixel-write mode for lines, points and outlines: 0 replace, 1 complement (dest inverted, pen ignored), 2 OR, 3 AND, 4 XOR. Fills and CLS always replace — the burst filler is not modal. A nonzero mode turns the single-pixel write into a read-modify-write against the framebuffer.

3. The GL port and its three FIFOs ($FF50)

GLDATA $FF50  write: one command-stream byte
GLSTAT $FF51  read:  bit7 command FIFO full, bit6 BUSY (interpreter
                     AND walker), bit1 error queued, bit0 read-back
                     byte available
GLRB   $FF52  read:  pop one read-back byte (reserved; live when a
                     read-back stage is fitted)
GLERR  $FF53  read:  pop one error byte (0 = none)
GLID   $FF54  read:  'G'

The PGC's three-FIFO contract shrunk to a byte port. The host checks bit7 before pushing (or trusts a depth-guaranteed burst), drains GLERR after a stream, and treats bit6 as "the card is still working". Errors are code bytes: 1 unknown command, 2 bad parameter, 4 FIFO overflow, 5 list op out of place, 6 undefined list, 7 list full.

Two encodings share the stream, switchable in-band: HEX (opcode byte + int16 little-endian parameters; power-up default) and ASCII (keywords + decimal numbers, any of space/tab/comma/semicolon/CR/LF as separators). CA enters ASCII, CX returns to hex — each is its own byte sequence in both modes, and in hex mode the switch is the literal three bytes "CA " (space-terminated).

4. The interpreter: translator, consumer, walker

Three machines in a pipeline, all in fpga/rtl/p8x_geom.v:

The translator (stage 10d) sits ahead of the byte source in ASCII mode. It matches keywords against a 118-entry ROM (long and short forms, generated by generators/gen_glkw.py — the same table the emulator and BASIC use), converts decimal numbers to width-correct parameters, and feeds the result — pure hex — through a small queue into the consumer. Error recovery is deterministic: an unknown keyword logs 1 and swallows its numbers; a keyword arriving early logs 2 and zero-fills; an orphaned number logs 2. The stream stays in sync.

The consumer decodes one command at a time: opcode → parameter count (a wire table), collect parameters, dispatch. Most 2D state changes (pen, window, current point) happen in the dispatch cycle; anything that draws or computes launches the walker and waits — the consumer only dispatches when the walker is idle, which is what makes GLSTAT bit6 a single honest busy flag.

The walker is the execution back end, a ~110-state FSM that owns three resources: the scratchpad RAM, the muldiv core, and the 2D device's register port. Its jobs: the vertex transform MAC, near/far clipping, perspective projection, Cohen-Sutherland window clipping, viewport mapping, polygon fan filling, the box issuer (CLEARS/FLOOD/RECT), matrix composition, single-register device writes (the LINFUN mode, which must WAIT for the device to go idle so a mode change never overtakes a primitive still drawing), and the AREA flood-fill walker (stage 10g): a scanline seed fill that probes the framebuffer through real device POINT commands, paints spans as device LINEs, and keeps its 16384-entry seed stack in SDRAM at $180000 — the emulator's gl_afill reproduced state for state, with error 2 for an off-window seed and error 8 (deterministic partial fill) when the stack caps out. Vector text (stage 10h) adds almost no datapath at all: a glyph IS a command list in a second SDRAM slot bank ($140000), TDEFIN records into it through the unchanged recording machinery (one bank-select bit), and TEXT is a small per-char loop that replays glyph lists through the unchanged replay fetcher -- TSIZE/TANGLE are compose aliases of MDSCAL/MDROTZ, so size, angle and the baseline advance all ride the ordinary matrix path.

5. Transforms: the matrix pipeline (stage 10b)

All 3D state lives on the card. Two master matrices — modeling M (composed by MD verbs about the MDORG pivot) and viewing VR (VW verbs, angles negated: the viewer orbits) — are kept in a scratchpad RAM and recomposed into a parameter file on every matrix verb:

v_screen = project( (VR*((M*v>>8) + Tm - r))>>8 + (0,0,dist) )

Everything expensive happens at COMMAND time (a matrix verb costs a compose microprogram run); the per-vertex datapath is untouched from stage 9. Angles are integer degrees through a shared quarter-wave sine table (generators/gen_trig.py emits both the emulator's C table and the RTL ROM from one formula). Projection: K = 256 native at power-up (the stage-9 camera; DISTAN 0 keeps old streams pixel-identical), or PGC-style PROJCT angle deriving K from the window width. Hither/yon planes live in the parameter file; the near clip is always on (floor 16 — the divide guard), the far clip costs nothing when disabled.

The scratchpad is ONE true-dual-port BSRAM (port A write-else-read, port B read-only) holding the master matrices, compose scratch, the polygon lanes, the working vertices, and the keyword ROM — the register arrays that once made placement impossible, serialized behind two registered read ports.

6. Command lists (stage 10c)

64 slots x 4KB in SDRAM at $100000 (a second, identical bank at $140000 holds the stage-10h GLYPHS -- same format, one address bit); slot n holds a byte length in its first halfword and hex command bytes from byte 2. The stage-10g fill's seed stack lives at $180000. CLBEG records — bytes ride the normal decoder (execution suppressed, boundaries tracked, unknown opcodes skipped not stored), CLEND finalizes, CLAPP (P8X-only) appends. CLRUN replays once; CLOOP replays n times — the interpreter's byte source switches from the FIFO to a fetcher walking the slot, and the decode machinery neither knows nor cares. Matrix deltas inside a looped list accumulate: a list that nudges MDROTY, erases, draws and FLIPs is a self-running animation with the CPU idle. Lists store hex regardless of the input encoding, so an ASCII-recorded scene replays identically. Nesting is refused (error 5); an undefined slot is error 6; outgrowing a slot aborts the recording and leaves the slot undefined (error 7).

7. Pixels: memory, spans, scanout, pages

The framebuffer lives in the in-package SDRAM behind an arbiter that serves four masters: the pixel back-end (gfx_mem — single pixel reads/writes, and the LINFUN read-modify-write), the span filler (gfx_span — burst fills for boxes/CLS/triangle spans), the GL list fetcher/recorder, and the scanout.

The address is pure wiring: stride 1024 bytes means {page, y[8:0], x[8:0], 0} — no multiplier, no width-wrap bugs. Scanout streams each line into a line buffer with its CAS issued every OTHER cycle: gapless bursts proved electrically marginal on real hardware (snow, missing edges), and half-rate streaming is still 3x faster than the panel needs.

Two pages. Drawing targets the DRAW page, the panel shows the DISPLAY page; FLIP (applied at a frame boundary) swaps them and PGSYNC rejoins them. CLEARS erases BOTH pages (they power up as garbage). WAIT n paces on real frame ticks — the pacing primitive inside animation lists.

8. The golden-model discipline

The emulator (emulator/p8xemu.c) is the reference implementation: the RTL must reproduce it byte-for-byte, and the claim is enforced, not asserted. The proof chain, in escalating strength:

  1. Directed op-level benches (tb_gl.v): the interpreter's exact operation stream, error sequences, FIFO backpressure.
  2. Eleven cross-implementation FRAMES (emulator/test/c_gl_rtl_test.sh): the same command bytes rendered by the emulator on the emulated machine and by the real RTL stack (geometry + gfx + arbiter + SDRAM controller + a protocol-checking chip model), compared pixel for pixel — the 10a scene, the 10b matrix scene, the 10c fly-through, the 10d ASCII scene, the 10f LINFUN scene, the 10g AREA fills, the 10h TEXT scene (the whole generated font TDEFIN'd, then sized, tilted and folded), the 10i curve scene (both midpoint regions, both aspects, the r=1/r=0 edges), the 10j pattern scene, the 10k text2 scene and the BLIT frame — plus self-checking PIXRD/BLIT read-back rungs that compare RB words instead of frames.
  3. On-board probes: the same programs typed at the real machine, with PIXELR read-backs checked against the emulator's golden values.

The corollary rules: algorithms are written once in C and transcribed, never re-derived; a divergence is a bug in the RTL by definition; and anything the frame tests cannot see (scanout mapping, electrical margin) needs its own bench or a human eye on the panel.

9. What is deliberately NOT here

Read-back (FLAGRD/MATXRD/CLRD/CLMOD — stage 10e) is built and proven in simulation but not fitted: it costs more logic than the current FPGA has free. Its port plumbing (GLRB, GLSTAT bit0) is already in the contract. AREA seed fill (10g) and vector TEXT (10h) are scoped in BACKLOG.md. The stage-8 record engine and its $FF40 window are retired — a scene is a command list now.