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.8on its diagnostic screen. Several features are still outstanding and nothing has run on real hardware yet — only inmadsimand 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.
$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.
$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.
$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_SHADOWis the only record of the currentVIDEO_REGstate (the hardware register is write-only). All code must read the shadow and write to both — never writeVIDEO_REGwithout updating the shadow.
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.
| 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.
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#CX > 199 or CY > 149 → the whole circle is
skipped. An off-screen centre is a caller error, not a request to draw a far-off arc.
R = 0 also draws nothing.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.
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 |
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.
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.
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.
$0700–$0EFF ($0600–$06FF is
SPR_HEIGHT, the 4th sprite-table page).$00 is the end-of-string sentinel — never define or use it as a drawable tile.TILE_BANK + tile_index × 8 = data address.Example (letter 'A'):
00011100 = $1C 01111111 = $7F
00110110 = $36 01100011 = $63
01100011 = $63 01100011 = $63
01100011 = $63 00000000 = $00
TEXT)#$20–$7F, 8 bytes each = 768 B, in ROM at $FC00–$FEFF.font_data + (char − $20) × 8.$60, $61, $64, $73, $77 are repurposed as symbols (copyright
sign, arrow glyphs), not lowercase letters.roms/fonts.s.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.
$0300, 4 pages, one parameter per page for fast indexed
access. Up to 256 sprites, indexed by SPR_ID.roms/sprite_zero.s, $F400). boot_main installs it so
id 0 is usable immediately; a developer may overwrite the slot at any time.(0,0) = screen origin — the same zero as PIXEL and the clip builders.spr_setup does a coarse 16-bit reject per axis first, which also guards the
signed-byte internal column count against aliasing for far-off coordinates. Visible
range: X in −(8·W)+1 … 399, Y in −H+1 … 299.$1000 onwards, uploaded via LOAD.| 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 |
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.
1 bit per pixel, packed 8 px/byte, MSB = leftmost — same bit order as tiles and glyphs. Stored interleaved by row:
W bitmap bytes then W overlay bytes (stride 2W).W bitmap bytes (stride W).| 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.
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, incrementPPWPimmediately, so it always points at the next unread byte. Opcode routines end withJMP dispatch_loop, notRTS— this keeps the call depth flat.Dispatch is a linear
CMPchain ordered by call frequency, hottest first (dotted geometry → pixel/sprite → text/tile → solid line →LOAD→WAI→ rare control ops). Cost is30 + N×4cycles for the Nth entry. AllBEQtargets are 3-byteJMPtrampolines listed in the same order as the comparisons, becauseBEQonly reaches ±128 bytes; the assembler errors if one falls out of range.
$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.
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.
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.
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.
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) |
1–4 |
right (LSR/ROR, low→high) |
N |
right |
5–7 |
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.
ROWCNT (≤32) comment in spr_setup is stale — sprite heights grew to 255.
Harmless today, but any change to the row loop must reason with 255, not 32.frame_isr does cli on
entry so a second VSYNC can nest cleanly, but there is no policy for what a nested
frame should do to a half-drawn image.