MAD-65
Contents

MAD-65 GPU OS — Reference#

The GPU OS is the firmware in CPU2's 16 kB ROM. It boots the video subsystem, then runs one interrupt-driven render pass per VSYNC, executing the instruction list CPU1 left in ping-pong RAM. Source: roms/gpu_os.s.

Status: pre-1.0. The ROM reports V0.8 on its diagnostic screen. Several features are still outstanding and nothing has run on real hardware yet — only in madsim and the Verilator sim. Call this v0.8→v0.9: the instruction set and the PPRAM protocol are stable enough to write cartridges against, but they are not frozen until a board boots.

Designed but not implemented — comb rendering and vertical (TATE) mode — is in MAD65_future_features.md. Opcodes $62/$63 are reserved for it.


Memory Map (GPU perspective)#

$0000–$77FF   RAM            scratch, stack, tables, graphics pool
$7800–$7FFF   PPRAM          ping-pong RAM 2 kB — inter-CPU communication slot
$8000–$BFFF   VRAM-image     active write buffer, hardware-switched each VSYNC
$C000–$FFFF   ROM (read) / VRAM-background (write)

VRAM note: only one VRAM buffer is accessible to the GPU at any time. Hardware switches buffers each VSYNC automatically. The GPU never knows which physical buffer it currently holds — this is the root of the two-frame background rule below.


GPU RAM Layout ($0000–$77FF, 30 kB)#

$0000–$00FF   Zero page               256 B   see below
$0100–$01FF   Stack                   256 B   65C02 hardware stack
$0200–$02FF   Scratch                 256 B   per-opcode working buffers
$0300–$06FF   Sprite definition table 1024 B  4 pages × 256 entries
$0700–$0EFF   Tile bank              2048 B   256 user tiles × 8 B
$0F00–$0FFF   (reserved)              256 B
$1000–$77FF   Graphics pool         26624 B   26 kB — sprite/tile bitmaps, uploaded via LOAD

The $0200 scratch page is shared and overlapping by design — only one opcode handler runs at a time, so the sprite row spans (SPR_BMP $0200, SPR_OVL $0210), draw_text_core's shift buffer and op_tile's glyph-pointer tables all reuse it.

Zero Page Layout ($0000–$00FF)#

$00       GPU_STATUS        ZP mirror of the PPRAM status byte
$01       NO_CPU_COUNT      consecutive frames without CPU_READY (diag mode trips at $40)
$02       VIDEO_REG_SHADOW  software shadow of the write-only VIDEO_REG
$03–$04   PPWP              instruction-list cursor (PPRAM, or DIAG_LIST in diag mode)
$05–$06   PTR               universal 16-bit VRAM / RAM pointer
$07–$0A   PX_X, PX_Y        full-res pixel coordinates, 16-bit each
$0B–$0C   PX_TMP            address-arithmetic scratch
$0D–$0E   GLYPH_PTR         current glyph row in the font
$0F       DRAW_BASE_HI      VRAM target high byte: $80 = image, $C0 = background
$10–$14   TEXT_*            column, row, scroll, visible length, glyph-row counter
$15–$21   LN_*              line state: X1,Y1,X2,Y2, DX,DY, SX,SY, ERR, CNT, MASK,
                            ROWSTEP (signed 16-bit, shared by line_core + dotline_core)
$22–$33   SPR_*             sprite blitter: width, rows, overlay flag, bit offset, shift
                            count + direction, span, column, clip flag, row counter,
                            source ptr, VRAM ptr, id, top-skip, stride, composite scratch
$34–$49   CIR_*             DOT_CIRCLE: centre, radius, midpoint walker, decision
                            variable, per-group column/row values, masks and validity
$4A       BOOT_BLINDED      one-shot — boot's BLINDER is still up; drop it on frame 1
$4B–$FF   free              general ROM working variables

VIDEO_REG_SHADOW is the only record of the current VIDEO_REG state (the hardware register is write-only). All code must read the shadow and write to both — never write VIDEO_REG without updating the shadow.


Ping-Pong RAM (PPRAM) Structure#

The first byte at $7800 is the status code byte; each side reads it to learn the other's state. The GPU does not process PPRAM content until the CPU has set CPU_READY. Bytes $7801 onward form the instruction list — a fixed opcode followed by zero or more argument bytes, terminated by WAI ($00).

Code Symbol Direction Meaning
$A0 CPU_BOOTING CPU → GPU CPU is initialising
$A1 CPU_WORKING CPU → GPU CPU is processing a frame
$A2 CPU_READY CPU → GPU CPU has finished writing
$B0 GPU_BOOTING GPU → CPU GPU is initialising
$B1 GPU_WORKING GPU → CPU GPU is rendering
$B2 GPU_READY GPU → CPU GPU has finished rendering

The CPU does not zero PPRAM per frame: it overwrites the list in place from $7801 and re-terminates it with WAI; stale bytes past the WAI are never read.


GPU Instruction Set#

Opcode Mnemonic Arguments (bytes following opcode) Description
$00 WAI End of instruction list
$08 GPU_LED pattern Write bit pattern to LED_GPU_REG
$10 COPY_DIS_ON Set COPY_DIS in VIDEO_REG
$11 COPY_DIS_OFF Clear COPY_DIS
$12 BG_REG_ON Set BG_REG
$13 BG_REG_OFF Clear BG_REG
$14 BLINDER_ON Set BLINDER (blank screen)
$15 BLINDER_OFF Clear BLINDER
$20 CLEAR_BG Clear VRAM-background $C000–$FFFF. Two-frame rule applies
$30 LOAD page_MSB, then 256 data bytes Copy 256 bytes into one page (page_MSB << 8). Valid: $02–$77 (RAM), $80–$BF (VRAM-image), $C0–$FF (VRAM-bg, two-frame rule). Blocked: $00, $01, $78 — data consumed but skipped
$40 PIXEL X_LSB, X_MSB, Y_LSB, Y_MSB Pixel on VRAM-image, full-res
$41 (removed) PIXEL_BG — retired: a per-bit set needs read-modify-write, and VRAM-background is write-only (reads return ROM)
$42 LINE X1, Y1, X2, Y2 Solid line on VRAM-image, half-res coords
$43 (removed) LINE_BG — retired, same reason as $41
$44 DOT_LINE X1, Y1, X2, Y2 Dotted line, image only
$45 DOT_LINES N, x0,y0, … , xN,yN Connected chain of N dotted segments (polyline), image only
$46 DOT_PIXEL X, Y One pixel at half-res coords, image only
$47 DOT_PIXELS N, x0,y0, … , xN-1,yN-1 N independent half-res pixels in one dispatch, image only
$48 DOT_CIRCLE CX, CY, R Dotted outline circle, half-res, image only. Centre must be on-screen
$49 HDOT_LINE XB, Y, NB Byte-aligned horizontal dotted rule, image only. Self-clipping
$50 SPRITE SPR_ID, X_LSB, X_MSB, Y_LSB, Y_MSB Sprite on VRAM-image, signed-16 top-left pixel, clipped on all four edges
$60 TEXT X (0–49), Y (0–35), scroll (0–7), …string…, $00 Text on VRAM-image. Last character is never drawn — it is the scroll guard
$61 TEXT_BG same as $60 Text on VRAM-background. Two-frame rule applies
$62/$63 (reserved) VTEXT/VTEXT_BG for vertical mode — not implemented, do not reuse. See MAD65_future_features.md
$70 TILE X (0–49), Y (0–35), scroll (0–7), …tile ids…, $00 Tile string on VRAM-image
$71 TILE_BG same as $70 Tile string on VRAM-background. Two-frame rule applies

The CPU-side builders map 1:1 onto these opcodes — see MAD65_CPU_OS.md.

Line family — which to use#

All line opcodes take half-resolution coordinates (0–199 × 0–149, one byte each), doubled internally to the 400×300 framebuffer. That halves PPRAM usage and keeps the Bresenham math in 8-bit arithmetic.

Opcode Rendering Speed Best for
LINE ($42) Solid — every full-res pixel filled; H/V runs byte-batched baseline; H/V lines ~6–8× faster via byte fill UI frames, borders, axes, bars — anything that must look continuous
DOT_LINE ($44) Dotted — one dot per half-res step, on even coords ~1.8× faster than LINE One-off diagonal edges
DOT_LINES ($45) Dotted chain, shares vertices ~1.4–1.5× faster than the same edges sent separately, on top of DOT_LINE's gain Wireframes and any connected path
DOT_CIRCLE ($48) Dotted outline, midpoint algorithm, 8-way symmetry ~0.6 ms at R≈50, ~0.25 ms at R≈25 Reticles, radar rings, gauges, wireframe nodes
HDOT_LINE ($49) Dotted horizontal rule — constant $AA byte fill ~10–14× faster than the same span as a horizontal DOT_LINE (~13 cyc/byte vs ~194) HUD separators, grid/ruler lines, table rules, dotted underlines

All of them are image-only — there is no _BG variant of any line or pixel op. Setting an individual bit requires a read-modify-write and the VRAM-background window is write-only. Background layers are built from whole-byte writes: LOAD, TEXT_BG, TILE_BG, CLEAR_BG.

DOT_LINES shares each interior vertex between adjacent segments, so the expensive start-address computation runs once per chain; branching models are sent as several chains plus the occasional lone DOT_LINE.

DOT_CIRCLE restrictions#

HDOT_LINE encoding and self-clipping#

Three guarantees collapse the whole line to a constant memory fill: the row never changes; every dot lands on an even full-res column so a full byte of dots is always $AA; and both start and length are byte-aligned, so no mask is needed. The renderer points at the start byte and stores $AA to NB consecutive VRAM bytes.

Arg Meaning Range
XB start byte column (full-res col = XB·8) 0–49
Y half-res row — drawn on full-res row 2·Y 0–149
NB run length in bytes (1 byte = 8 full-res columns) 1–50

It is one of only two ops that validate their own arguments: XB ≥ 50 or Y ≥ 150 drops the command, and NB is clamped to 50 − XB (a resulting NB = 0 drops it). All three argument bytes are consumed before any drop, so the stream stays aligned. That bounds the highest byte it can touch at $BA65 — safely inside the image and nowhere near the $BFE0 registers. Writes are a plain store, not read-modify-write, so the odd sub-columns of each touched byte are cleared.

The CPU builder gpu_hdotline takes ordinary half-res pixel coordinates and auto-aligns them to this encoding.


⚠ Coordinate validation is CPU1's responsibility#

The GPU drawing routines do not validate coordinates. Out-of-range values are not caught, clipped or rejected — the GPU computes whatever address the formula gives and writes there. CPU1 must ensure every coordinate is in range before it reaches the instruction list.

Instruction family Parameter Legal range Notes
PIXEL X, Y 0–399 / 0–299 (16-bit) Full-resolution
LINE, DOT_LINE, DOT_LINES, DOT_CIRCLE X, Y 0–199 / 0–149 Half-res, doubled internally
DOT_PIXEL, DOT_PIXELS X, Y 0–199 / 0–149 Same space as lines
TEXT, TEXT_BG, TILE, TILE_BG column, row 0–49 / 0–35 Tile grid units
SPRITE X, Y signed 16-bit Top-left pixel, (0,0) = screen origin; GPU clips all four edges
DOT_CIRCLE CX, CY 0–199 / 0–149 Centre must be on-screen (early-out if violated)
HDOT_LINE XB, Y, NB 0–49 / 0–149 / 1–50 Self-clipped — see above

What happens with illegal values#

The address formula for pixel and line instructions is:

VRAM address = $8000 + Y_full × 50 + X_full / 8

VRAM-image ends at $BFDF. Immediately above it sit write-only hardware registers: LED_GPU_REG at $BFC0 and VIDEO_REG at $BFE0 (BG_REG bit 0, BLINDER bit 1, COPY_DIS bit 2, SHADOW_MODE bit 3). If Y_full ≥ 326 the computed address reaches $BFC0 and a pixel write latches random bits into one of them — BLINDER blanks the screen instantly, BG_REG switches the displayed layer, COPY_DIS kills the background copy, and clearing SHADOW_MODE un-maps the shadow RAM the GPU is executing from. All persist until the next valid VIDEO_REG write, and the last one does not survive to make one. For half-res instructions the threshold is Y ≥ 163. Values above the legal maximum but below the threshold write into the unused VRAM tail — invisible, but still wrong.

The two register ranges are decoded separately (VIDEO_REG needs A7 & A6 & A5), so GPU_LED is safe to issue at any time, including during rendering. See MAD65_address_decoder.md §3/§4.

Why the GPU does not validate#

Bounds checking costs cycles on every draw call, every frame, for coordinates that are almost always valid. Validation belongs in the scene-composition logic on CPU1, where it runs once per frame instead of once per pixel.

DOT_CIRCLE's centre check, SPRITE's clipping and HDOT_LINE's row-end clamp are exceptions only because those routines need the range information to drive their inner loops — the circle skips entirely for an off-screen centre, the sprite blitter derives its loop bounds from the clip region, and HDOT_LINE derives its byte count directly from the clamp. Pure drawing instructions like PIXEL and LINE have no such internal need.


Writing to the background — two consecutive frames#

Any instruction that writes VRAM-background must be sent twice, on two consecutive frames: CLEAR_BG ($20), TEXT_BG ($61), TILE_BG ($71), and LOAD ($30) to a $C0–$FF page.

Why: VRAM is double-buffered in hardware, and a flip-flop swaps which physical chip is "the background" on every VSYNC. The GPU never knows which one it holds, so a single write lands in only one buffer; the next frame the hardware swaps to the other, which still holds the old content — the change flickers at 30 Hz or vanishes. Writing the same data on two consecutive frames reaches both buffers, after which it is stable.

Image-layer instructions do not need this. The image buffer is rebuilt every frame, so a one-frame write is correct there. Hardware copies the background onto the image each frame automatically, so the background only needs redrawing when its content actually changes — but when it does, across two frames.

The CPU OS automates this with an argument-capture replay engine, so a game issues a background write once — see MAD65_CPU_OS.md.


Graphic Data Structures#

Tiles#

Example (letter 'A'):
  00011100 = $1C      01111111 = $7F
  00110110 = $36      01100011 = $63
  01100011 = $63      01100011 = $63
  01100011 = $63      00000000 = $00

Font (TEXT)#

Horizontal scroll & the hidden last character#

TEXT/TEXT_BG take a scroll argument (0–7). The whole rendered row buffer is treated as one wide binary number and shifted left by scroll pixels, bits carrying right-to-left between adjacent cells, so the shift is pixel-continuous across the string.

The last character of the string is never written to VRAM — it is the scroll buffer. Its high bits do carry into the rightmost visible cell during the shift, so the next character scrolls in smoothly from the right. A left-scrolling marquee is therefore trivial: hold a window of N+1 characters (N visible + 1 guard), increment scroll 0→7 one pixel per frame, and when it wraps advance the string pointer by one character. Static text must end with a space.

Mechanics (draw_text_core): the visible draw count TEXT_LEN is total − 1, capped at the 50-cell row buffer and clipped at the right screen edge (50 − column). The load+shift passes run over TEXT_LEN + 1 whenever a guard cell was stored, while the copy-to-VRAM pass writes only TEXT_LEN cells. scroll = 0 skips the shift entirely (static-text fast path). A 51-byte string fills all 50 columns; longer strings are clipped, never overflowing onto the next row.

Sprites#

Page Address range Content
Page 0 $0300–$03FF SPR_TYPE
Page 1 $0400–$04FF SPR_PTR_LSB
Page 2 $0500–$05FF SPR_PTR_MSB
Page 3 $0600–$06FF SPR_HEIGHT

Type & height#

Width and overlay come from the type byte: low nibble = width in bytes W (1 = 8 px, 2 = 16 px, 4 = 32 px, 8 = 64 px); bit 4 ($10) set → no overlay plane, clear → overlay present.

Height is independent, in SPR_HEIGHT ($0600 + SPR_ID): any value 1–255 rows. It is not derived from width, so non-square sprites are allowed (an 8×64 column, a 32×8 bar). Height 0 marks an undefined slot and renders nothing — boot zeroes the table, so unused ids are inert. The blitter costs the same per row regardless of height, so tall sprites cost proportionally more only because they have more rows.

Type $01 $11 $02 $12 $04 $14 $08 $18
Width 8 px 8 px 16 px 16 px 32 px 32 px 64 px 64 px
Overlay yes no yes no yes no yes no

64 px (W=8) is the widest the type byte can encode: W=16 (128 px) would be $10, which collides with the overlay-absent bit. 128 px would need a re-encoded type byte.

Pixel data format#

1 bit per pixel, packed 8 px/byte, MSB = leftmost — same bit order as tiles and glyphs. Stored interleaved by row:

Two planes#

Plane Bit = 1 Bit = 0 Operation
Bitmap white transparent OR (screen \| bitmap)
Overlay black no change AND NOT (& ~overlay)

The composite is a single read-modify-write per byte:

screen = (screen OR bitmap) AND NOT(overlay)

The bitmap draws the white silhouette; the overlay punches black detail on top. Because both planes treat a 0 bit as "leave alone", a sprite shifted to a non-byte-aligned column needs no boundary masking — the zero bits shifted in on the spill side are inert, and partially-visible edge bytes come out correct without per-bit clipping.


Per-frame ISR#

The GPU never returns from its interrupt — VSYNC is the frame trigger, so frame_isr resets SP on entry and ends in WAI.

VSYNC interrupt fires
  Reset SP ← $FF               (discard the hardware-pushed frame; there is no caller)
  CLI                          (a late VSYNC must not be blocked)

  If NO_CPU_COUNT >= $40:      ← diagnostic mode check, before anything else
    PPWP ← DIAG_LIST ($FA00)
    goto dispatch_loop         (PPRAM status check skipped entirely)

  PPWP ← PPRAM ($7800)         ← normal mode
  Read status byte via PPWP    (PPWP advances to $7801)
  If status ≠ CPU_READY:
    NO_CPU_COUNT += 1
    status ← GPU_READY
    WAI                        (nothing rendered this frame — a visible blink)
  NO_CPU_COUNT ← 0
  If BOOT_BLINDED:             ← one-shot: first real frame drops boot's BLINDER
    clear BLINDER, clear BOOT_BLINDED
  status ← GPU_WORKING
                               (PPWP already at $7801 = first opcode)
dispatch_loop:
  Read opcode via PPWP, increment PPWP
  If opcode = $00 (WAI):
    status ← GPU_READY
    WAI
  Dispatch to the opcode's routine
    Routine reads its own arguments via PPWP and advances it
  goto dispatch_loop

Convention: whenever a byte is read via PPWP, increment PPWP immediately, so it always points at the next unread byte. Opcode routines end with JMP dispatch_loop, not RTS — this keeps the call depth flat.

Dispatch is a linear CMP chain ordered by call frequency, hottest first (dotted geometry → pixel/sprite → text/tile → solid line → LOADWAI → rare control ops). Cost is 30 + N×4 cycles for the Nth entry. All BEQ targets are 3-byte JMP trampolines listed in the same order as the comparisons, because BEQ only reaches ±128 bytes; the assembler errors if one falls out of range.


GPU ROM Layout ($C000–$FFFF, 16 kB)#

$C000–$C002   JMP stub — RESET → boot_main
$C003–$C005   JMP stub — IRQ   → frame_isr
$C006–$????   Boot procedure                            (boot_main)
$????–$F3FF   Frame handler, dispatch loop, op_* routines, helpers
$F400–$F4FF   Default sprite 0              256 B       32×32 overlay (SPRITE0 segment)
$F500–$F6FF   Font glyph pointer tables     2×          (FONT_GLYPH_LO / _HI)
$F700–$F747   Text row-start tables                     (TEXT_ROW_LO at $F700, _HI at $F724)
$F800–$F9FF   VRAM row-start tables         2×256 B     row_lo ($F800) / row_hi ($F900)
$FA00–$FBFF   Diagnostic instruction list   512 B       (gpu_diag_list.s, DIAG_LIST)
$FC00–$FEFF   Font definitions              768 B       96 glyphs ($20–$7F) × 8 B
$FF00–$FFF9   Unused                        122 B       filled $EA
$FFFA–$FFFB   NMI vector                                (unused — points to irq_stub)
$FFFC–$FFFD   RESET vector                              → $C000
$FFFE–$FFFF   IRQ/BRK vector                            → $C003

Segment starts are pinned in roms/gpu.cfg; the CODE end moves with every build (see gpu_os.map). The two JMP stubs give the hardware vectors fixed targets while the real handlers live anywhere in the image. row_lo/row_hi and the font are page-aligned deliberately — see Implementation invariants.

Diagnostic mode#

If frame_isr counts 64 consecutive frames without CPU_READY (~1 s), NO_CPU_COUNT reaches $40 and the GPU enters diagnostic mode. From then on frame_isr points PPWP at DIAG_LIST ($FA00) instead of PPRAM and jumps straight into the dispatch loop, which is oblivious to where its opcodes come from. NO_CPU_COUNT is never reset in diag mode — the CPU is not expected to recover; a missing CPU signal after boot is fatal.

The list (roms/gpu_diag_list.s) uses the same opcode format as PPRAM but has no status byte — opcodes begin at byte 0 — and must fit 512 B and end with WAI. Current content: BLINDER_OFF (the diagnostic screen reveals itself, since boot leaves the screen blinded), sprite 0, and three TEXT lines — MAD-65 PROTOTYPE V0.8 / CPU NOT READY / GPU IN DIAGNOSTIC MODE. It doubles as a graphics self-test with no CPU involvement; widening it (border, full ASCII sweep, VIDEO_REG flag cycling) is a matter of adding opcodes to that file.


GPU Boot Procedure#

SEI + CLD                          disable interrupts, force binary mode
VIDEO_REG ← BLINDER set, COPY_DIS + BG_REG clear    (still SHADOW_MODE=0 boot mode)
LED_GPU_REG ← $01                  stage 0: boot started
PPRAM[$7800] ← GPU_BOOTING ($B0)   + ZP mirror — the FIRST PPRAM write of boot,
                                   deliberately BEFORE the ~1-frame shadow copy
NO_CPU_COUNT ← 0                   (the ZP clear below re-zeroes it anyway)
init stack (S ← $FF)
copy ROM $C000–$FFFF → shadow code-RAM    boot mode: read hits ROM, write hits shadow.
                                   SHADOW_MODE stays 0 until just before the first
                                   $C000–$FFFF write (the VRAM-background clear)
init zero page                     wipes GPU_STATUS / VIDEO_REG_SHADOW — intentional
PPRAM[$7800] ← GPU_BOOTING ($B0)   + ZP mirror, AGAIN: re-establishes the wiped mirror
                                   and re-asserts the byte on the chip owned by now
init RAM ($0100–$77FF)
LED_GPU_REG ← $03                  stage 1: RAM cleared
install default sprite 0 into the definition table
VIDEO_REG ← BLINDER | SHADOW_MODE  enter run mode: code now runs from shadow RAM and
                                   $C000–$FFFF writes reach VRAM-background. Must land
                                   AFTER the ZP clear (which wiped VIDEO_REG_SHADOW)
                                   and BEFORE the first $C000+ write below
clear VRAM-background × BG_CLEAR_PASSES   back-to-back loop, NO waits between
re-assert BLINDER                  (local invariant: blind while stamping the bg)
draw build-date stamp              → the background bank owned right now
wait one VSYNC                     bg bank ownership swaps
draw build-date stamp              → the other bank
wait one VSYNC                     let the bg→image copy populate both image banks
LED_GPU_REG ← $07                  stage 2: VRAM cleared and stamped
BOOT_BLINDED ← 1                   arm the reveal — the screen stays DARK for now
PPRAM[$7800] ← GPU_READY ($B2)     + ZP mirror
LED_GPU_REG ← $0F                  stage 3: GPU ready
CLI                                enable interrupts
WAI

The screen is not unblanked at the end of boot. Revealing here would flash the bare datestamp on an empty background for every frame the CPU takes to come up. Instead boot arms BOOT_BLINDED, and the reveal happens on the first CPU_READY frame, inside frame_isr, just before the first PPRAM list is drawn. It is a one-shot, so a game that later drives BLINDER itself is never overridden. The diagnostic screen reveals itself via DIAG_LIST's own BLINDER_OFF.

Why the background clear is a tight loop. Only the background is cleared, never the image: the video circuit copies background → image every frame (COPY_DIS = 0) and that copy runs even while BLINDER is set, so a clean background blanks both displayed image buffers on its own. Clearing the image directly is pointless.

The clear must fully zero both double-buffered background buffers. One clear_vram_bg pass takes ~0.6 of a frame; if it straddles a VSYNC swap it is split across both buffers and zeroes neither completely. An older "clear; wait exactly one frame; repeat" scheme phase-locked that straddle — every pass started at the same phase, so one buffer kept a fixed orphaned region forever, and after a reset (SRAM retains pre-reset content) the copy displayed that stale region on alternate frames: a 30 Hz flicker. The fix is the back-to-back loop with no waits: ownership still flips once per VSYNC, but the clears land at every sub-frame phase, so each buffer gets at least one complete clear. BG_CLEAR_PASSES is currently 12, ≈ 7 frames (~0.12 s) of blanked clearing; measured, both background buffers are zero by frame ~2 and both image buffers by frame ~3, so this carries wide margin.

The build-date stamp is drawn after the clear loop (so no pass can erase it) and before the reveal. It lives in the background, so the free hardware copy re-paints it every frame and it survives into whatever the CPU draws, as long as the program leaves the top-right corner alone. It is tiny (14 cells × 8 rows) and finishes well inside one bank's ownership window, so unlike the full clear it never straddles a swap — hence the simple draw / wait one VSYNC / draw again. BUILD_DATE is a "YYMMDD" literal the Makefile regenerates into build_date.inc before every assembly.

Sanitising the status handshake (why GPU_BOOTING is written twice)#

PPRAM survives a warm reset and powers up as random garbage on real SRAM, so after a reset both ping-pong chips still hold the previous run's status bytes — one ends with CPU_READY, the other with GPU_READY. Each core can only write the chip it currently owns, and the chips swap every VSYNC, so a single BOOTING write sanitises only one of the two.

If a stale byte survives until the handshake runs, either direction can false-trigger: a stale GPU_READY lets the CPU's boot-tail wait pass while the GPU is still clearing RAM — the CPU's first command lists, and any one-shot LOADs in them such as a game's sprite-definition upload, are silently lost. A stale CPU_READY makes the GPU's first frame_isr execute a leftover pre-reset command list.

The defence: both cores announce BOOTING immediately at boot entry, before their slow ~1-frame shadow copies. The cores own opposite chips at any instant, so the two immediate writes together sanitise both chips within the first frame after reset. Each then writes BOOTING a second time after its ZP clear, restoring the wiped ZP mirror and re-asserting the byte on whichever chip it owns by then — which covers resets landing mid-frame on real hardware, where the shadow copy may straddle a swap.

CPU1's side of the same handshake is in MAD65_CPU_OS.md.


Implementation invariants (drawing routines)#

Constraints that are easy to break by accident. The reasoning behind each is in the comments in roms/gpu_os.s.

Row-start tables must stay page-aligned. addr = $8000 + Y × 50 + X/8 needs a multiply the 65C02 does not have, so the row start comes from two 256-byte ROM tables baked at assemble time (row_lo[Y], row_hi[Y]). An indexed lda abs,x costs 4 cycles only while the load stays inside one page — row_lo at $F800 and row_hi at $F900 guarantee that for every row. Moving either table off a page boundary silently costs a cycle on every address computation in the system.

Rows 256–299 need the high-byte correction. The tables have 256 entries indexed by the low byte of PX_Y. When PX_Y+1 = 1, add 50 to the high byte (256 × 50 = $3200). This is why a 300-entry table was not used — 16-bit indexing does not exist on the 65C02.

Line direction is dispatched once, not tested per pixel. line_core picks one of four octant-specialised loops (lc_xm_R/L, lc_ym_R/L) at entry; the loops contain no direction test. The Y direction is folded into LN_ROWSTEP, a signed 16-bit stride (±50 for line_core, ±100 for dotline_core) added branchlessly. The step counter lives in the X register, not a ZP variable. LN_ROWSTEP is shared between line_core and dotline_core — safe only because the dispatch loop is single-threaded and the two never run concurrently.

Inner-loop labels must be non-local. ca65 resets the @-local label scope at every .local inside a macro expansion, and LC_ADVX_R/LC_ADVX_L use .local internally. Loop-back targets therefore use function-name-prefixed labels (lc_xm_R_loop:), not @loop:.

The sprite blitter is eight specialised paths. spr_blit_{8,16,32,64}_{ov,noov}, generated from a single SPR_BLITTER macro; the wbytes ≥ 8 paths use jmp for the two intra-row branches the unrolled 64 px body outgrows. Picking the path once — on width and the overlay flag — keeps every per-row branch out of the inner loop, and the no-overlay paths compile the overlay load/shift/AND away entirely. Do not collapse them into a runtime test.

spr_setup order matters: coarse 16-bit reject per axis first (off-right when pixel_X ≥ 400, off-left when pixel_X + 8·W ≤ 0, mirrored for Y), so pixel_X is inside −(8·W)…399 before any byte arithmetic and a far coordinate can never alias back on-screen. Vertical clip then reuses the same row_lo/row_hi tables and adds +50 per row; horizontal clip reduces to "skip any span byte outside column [0,49]", with no partial-byte masking, because out-of-boundary bits are inert (see Two planes).

Shift direction is chosen to minimise passes and place the spill byte correctly:

N (pixel_X & 7) Direction Passes Spill byte
0 none 0 right (zero)
14 right (LSR/ROR, low→high) N right
57 left (ASL/ROL, high→low) 8 − N left

so a non-aligned sprite never runs more than 4 shift passes. The overlay buffer stays raw (1 = black) through the shift — shifted-in zeros must remain "no change" — and the NOT is folded into the composite's AND step. A byte-aligned fast path was considered and not added: sprites move to any pixel, so N = 0 is rare.


Open items#