The CPU OS is the firmware in CPU1's 16 kB ROM ($C000–$FFFF). It boots the
system, decides between a cartridge and the built-in demo, and exposes a
KERNAL-style API that game code (cartridge or demo) calls to talk to the
GPU, the sound chips and the joysticks. It is the only place where the GPU
command stream is validated — the GPU itself performs no bounds checking
(see MAD65_GPU_OS.md). Source:
roms/cpu_os.s + roms/cpu_demo.s.
Status: pre-1.0 — call it v0.8→v0.9. Every jump-table entry is real code (no stubs remain) and the math/vector, VGM and cart-loader paths are unit-tested in py65 against independent references, but a few features are still outstanding and nothing has run on real hardware yet — only in
madsimand the Verilator sim. The ABI below is stable enough to write cartridges against; it is not frozen until a board boots.
Design constraints that shape everything below:
$C000–$DFFF is
the built-in demo, and a cartridge owns that range as RAM the moment it boots
(DEMO_RAM), so no OS code, table or string may live there — the OS has
$E000–$FDFF, 7 680 bytes, with SINTAB and the jump table pinned above it.
That budget is now nearly spent: 186 bytes free (2.4%) since the speech
synthesiser landed. Treat a new subsystem as needing a home, not a gap: the
speech engine's phoneme table can be moved into the cartridge (a reserved
scale value takes a caller-supplied table pointer) to buy back 170 B without
breaking the ABI, and that is the first lever to pull.$0000–$77FF lower RAM ~30.75 kB — zero page, stack, OS state, game data
$7800–$7FFF PPRAM ping-pong shared RAM 2 kB — GPU command list (CPU1 writes);
byte 0 is PP_STATUS, the handshake byte
$8000–$9FFF Cartridge 8 kB banked window (or RAM when cartridge disabled)
$A000–$BEFF upper RAM ~7.75 kB — game data, buffers
$BF00–$BFFF I/O sound, joysticks, cartridge bank register, LED
$C000–$DFFF DEMO_RAM 8 kB — the DEMO's half of the ROM. Once a cartridge boots
(from cart_init on) the demo never runs, and these 8 kB
are plain full-speed RAM that belong to the GAME. Contents
on entry: the demo image — initialise what you use.
$E000–$FFFF OS code 8 kB — the CPU OS (this document). Read from the EPROM
only during boot; once the OS has copied itself into the
shadow RAM and set SHADOW_MODE ($BF70 bit 0), reads come
from RAM and writes here land in that same RAM — so DO NOT
write to $E000–$FFFF, you would corrupt the running OS
Naming. The three regions a game may use freely are lower RAM (
$0400–$77FF), upper RAM ($A000–$BEFF) and DEMO_RAM ($C000–$DFFF).DEMO_RAMis named for what it holds at boot, not for who ends up owning it. Do not call any of them "HIRAM" — cartridge link configs use that name for their own upper-RAM run areas, and it means different addresses in different games.
DEMO_RAM is ABI. The ROM is two 8 kB halves (roms/cpu.cfg): the built-in demo
at $C000–$DFFF and nothing else, the OS at $E000–$FFFF. Boot copies both into shadow
RAM, and the demo runs only when no cartridge is found — so a cartridge can use
$C000–$DFFF as ordinary RAM from cart_init onward, for code as well as data, at full
speed. The OS never reads, writes or executes there once it has handed over
(roms/test_cart_hiram.py proves it instruction by instruction). Never write
SHADOW_REG ($BF70): run mode is what makes this RAM. A reset copies the EPROM back in,
demo and all. The price is a fixed ceiling on the OS itself — its code must fit
$E000–$FDFF for good (6 426 B used, 1 254 B left).
$BF00–$BFFF)#$BF00–$BF0F AUDIO_SN1_PORT WO SN76489 #1 (tone 0–2, noise 3)
$BF10–$BF1F AUDIO_SN2_PORT WO SN76489 #2 (tone 4–6, noise 7)
$BF20–$BF2F AUDIO_YM_ADDR WO YM2413 register select (CPU A4=0 → A0=0)
$BF30–$BF3F AUDIO_YM_DATA WO YM2413 data write (CPU A4=1 → A0=1)
$BF40–$BF4F JOY1_REG RO joystick port 1 (active low)
$BF50–$BF5F JOY2_REG RO joystick port 2 (active low)
$BF60–$BF6F CART_BANK_REG WO bit7 = CART_EN, bits6–0 = bank (0–127)
$BF70–$BF7F SHADOW_REG WO bit0 = SHADOW_MODE (0 = boot/EPROM, 1 = run/shadow);
bits7–1 reserved, write 0. Cleared by /RESET. The OS
sets it once at boot; games must never touch it —
writing $00 un-maps the OS they are executing from
$BF80–$BFBF (reserved)
$BFC0–$BFDF LED_CPU_REG WO 8 diagnostic LEDs (optional module)
$BFE0–$BFFF (reserved — the GPU's VIDEO_REG sits here on PCB2; nothing on PCB1)
SHADOW_REGis a real register bit, not a strobe — the value written matters. Write$01, not whatever happens to be in A.CPU1 always writes its GPU command list to
$7800–$7FFF. The hardware decides which physical SRAM chip that is and swaps it each VSYNC — the OS never tracks chip identity. The GPU reads the previous frame's list. One frame of latency by design (see architecture doc §6/§12).
$0000–$BEFF)#$0000–$00FF Zero page 256 B OS variables + free (see ZP layout)
$0100–$01FF Stack 256 B 65C02 hardware stack
$0200–$02FF OS state 256 B OS_STATE — audio channel table, scratch tables,
cart→GPU loader cursor, frame-abort hook,
the speech engine ($024D–$0277) and its
64-byte phoneme buffer ($02C0–$02FF)
$0300–$03FF BG replay page 256 B two-frame background auto-replay:
flags + 41 six-byte command records
$0400–$77FF lower RAM ~29.0 kB fully available to game/cart
$7800–$7FFF PPRAM 2 kB shared command list (not general RAM)
$8000–$9FFF Cartridge / RAM 8 kB banked cart window (RAM when cart disabled)
$A000–$BEFF upper RAM ~7.75 kB fully available to game/cart
The CPU has far more RAM than the GPU, so the OS footprint is deliberately tiny:
zero page $00–$3F plus two pages: $0200 (OS state) and $0300 (BG replay —
this page is separate from $0200 because its records must survive intact into
the next frame, while $0200 may be rewritten every frame by the audio
engine). Everything else is the game's.
Above the I/O page sits the third free region, DEMO_RAM ($C000–$DFFF) — see
the memory map. It is not listed in the table above because it only becomes the
game's at cart_init.
$8000–$9FFF is RAM when the cartridge is disabled#The upper RAM chip (u_ram_hi, an CY7C199) physically covers the entire $8000–$FFFF
half — its /CE is just A15. The cartridge window and the OS ROM only overlay
that RAM on reads; the RAM cells underneath always exist and always latch writes.
So the $8000–$9FFF window behaves two ways depending on CART_EN:
CART_EN |
$8000–$9FFF reads |
Free upper RAM |
|---|---|---|
1 (cartridge enabled) |
active bank of cartridge ROM | $A000–$BEFF (~7.75 kB) |
0 (cartridge disabled) |
RAM | $8000–$BEFF contiguous (~15.75 kB) |
With no cartridge present, the boot code disables CART_EN (cart_bank ← $00), so the
built-in demo — and any program that turns the cartridge off — sees one contiguous
$8000–$BEFF upper RAM block. Boot clears $8000–$9FFF as RAM before enabling the
cartridge (see Boot Procedure), so the region is already zeroed if a cartridge later
disables itself. Writes to $8000–$9FFF always reach RAM even while a cartridge is
enabled (the cart ROM is read-only), but those bytes are hidden behind the bank on read
until CART_EN goes back to 0.
$0000–$00FF)#$00 CPU_STATUS 1 B ZP mirror of PPRAM status byte
$01 FRAME_COUNT 1 B incremented by the IRQ handler every VSYNC
$02 VSYNC_FLAG 1 B set by the IRQ handler every VSYNC; cleared by gpu_begin
when a frame build starts (doubles as the deadline detector
every builder consults — see "Frame budget & overrun")
$03 OVERRUN_FLAG 1 B set the moment a build is found to have crossed the swap,
and by os_run if the frame was late for any other reason
(sticky — the game clears it itself for per-frame checks)
$04–$05 PPWP 2 B PPRAM write pointer — builder append cursor
$06–$07 FRAME_VEC 2 B pointer to the active program's per-frame routine
$08 PP_OVERFLOW 1 B set when a builder dropped a command (PPRAM full);
cleared by gpu_begin → reports on the current frame
$09 CART_BANK_MIR 1 B software mirror of the write-only CART_BANK_REG ($BF60)
— current bank + enable (was CART_SHADOW before 2026-09-21)
$0A JOY1 1 B port 1 current state (this frame)
$0B JOY1_PREV 1 B port 1 previous frame
$0C JOY1_PRESS 1 B port 1 edges (newly pressed this frame)
$0D JOY2 1 B port 2 current state
$0E JOY2_PREV 1 B port 2 previous frame
$0F JOY2_PRESS 1 B port 2 edges
$10–$11 RNG_SEED 2 B 16-bit LFSR state
$12–$1F OS_SCRATCH 14 B API working temporaries — clobbered by any API call
$20–$2F OS_ARG 16 B API argument block (A0–A15) — caller fills before JSR
$30–$3F OS_AUDIO 16 B audio engine zero-page state / pointers
$40–$7F reserved 64 B OS expansion — do not use
$80–$FF FREE 128 B game / cartridge zero page
Contract for cartridges: ZP
$00–$7Fbelongs to the OS (some published, some reserved). ZP$80–$FFis yours.OS_SCRATCH($12–$1F) is volatile — assume any OS API call destroys it. TheOS_ARGblock ($20–$2F) is how multi-argument API calls receive their parameters.
PP_STATUS, PPRAM $7800)#The first byte of PPRAM is the status byte, shared with the GPU. It has its own
equate, PP_STATUS — PPRAM names the 2 kB region, PP_STATUS the handshake
byte inside it, so a load or store that means the byte says so. The OS owns it on
CPU1's side and keeps a mirror in CPU_STATUS ($00). The full code table is in
MAD65_GPU_OS.md; CPU1 writes three of them:
CPU_BOOTING ($A0) at boot, CPU_WORKING ($A1) when it begins building a frame,
CPU_READY ($A2) when the list is terminated.
The GPU reads the byte after each VSYNC swap; if it is not CPU_READY, the GPU draws
nothing that frame (a visible blink) — see "Frame Budget & Overrun" below.
All OS services are reached through a fixed table of JMP entries at the top of
ROM. The addresses below are frozen — a cartridge built against ABI v1 calls
these and nothing else. Internal routine addresses may move between OS revisions;
jump-table addresses may not.
── system / lifecycle ──
$FF00 os_run enter the main frame loop (boot tail-calls this; rarely called by carts)
$FF03 cart_bank select cartridge bank (A: bit7=enable, bits6–0=bank); updates CART_BANK_MIR
$FF06 cart_load copy cartridge data → RAM (OS_ARG: bank8 + addr16, DST16, LEN16); bank-aware
── GPU command builders ──
$FF09 gpu_begin start a new frame's list: PPWP←$7801, status←CPU_WORKING, PP_OVERFLOW←0,
VSYNC_FLAG←0 (arm the deadline detector), FRAME_SP←stack watermark,
then re-emits pending BG replay records (two-frame rule)
$FF0C gpu_end terminate list: append WAI, status←CPU_READY — but only if the build
did not cross a ping-pong swap; if it did, status←CPU_WORKING and the
frame is deliberately not shown (see "The frame-abort path")
$FF0F gpu_pixel append PIXEL (OS_ARG: X16, Y16 — signed-16, dropped if off-screen)
$FF12 (reserved) was gpu_pixel_bg — REMOVED; slot is now a no-op stub (ABI kept fixed)
$FF15 gpu_line append LINE (OS_ARG: X1,Y1,X2,Y2 half-res)
$FF18 (reserved) was gpu_line_bg — REMOVED; slot is now a no-op stub (ABI kept fixed)
$FF1B gpu_dotline append DOT_LINE (OS_ARG: X1,Y1,X2,Y2)
$FF1E gpu_dotlines append DOT_LINES (OS_ARG: ptr→{N,x0,y0,…})
$FF21 gpu_dotpixel append DOT_PIXEL (OS_ARG: X,Y half-res)
$FF24 gpu_dotpixels append DOT_PIXELS (OS_ARG: ptr→{N,x0,y0,…})
$FF27 gpu_dotcircle append DOT_CIRCLE (OS_ARG: CX,CY,R half-res)
$FF2A gpu_sprite append SPRITE (OS_ARG: SPR_ID, X16, Y16 — signed-16 pixel)
$FF2D gpu_text append TEXT (OS_ARG: col,row,scroll, ptr→string)
$FF30 gpu_text_bg append TEXT_BG (OS_ARG: col,row,scroll, ptr→string)
$FF33 gpu_tile append TILE (OS_ARG: col,row,scroll, ptr→tileids)
$FF36 gpu_tile_bg append TILE_BG (OS_ARG: col,row,scroll, ptr→tileids)
$FF39 gpu_load append LOAD (OS_ARG: page_MSB, ptr→256 bytes)
$FF3C gpu_clearbg append CLEAR_BG (—)
$FF3F gpu_vreg append a VIDEO_REG op (A = sub-op, see below)
$FF42 gpu_led append GPU_LED (A = pattern)
$FF45 gpu_raw append one literal byte (A) — escape hatch for new opcodes
── audio: effects (SN76489 #2) ──
$FF48 snd_init mute both SN76489 + run YM2413 init sequence
$FF4B snd_tone tone on channel (A=channel, X=note, Y=volume 0–15)
$FF4E snd_off silence a channel (A=channel 0–7)
$FF51 snd_noise noise (A=channel 3 or 7, X=mode, Y=volume)
$FF54 snd_beep one-call UI beep (A=note)
$FF57 sfx_play trigger sound effect by id (A=sfx_id)
$FF5A audio_tick advance SFX one frame (IRQ calls this; exposed for custom loops)
── music: YM2413 / FM ──
$FF5D ym_inst select instrument on an FM channel (A=channel 0–8, X=instrument 0–15)
$FF60 ym_note_on key-on a MIDI note (A=channel 0–8, X=midi_note, Y=volume 0–15)
$FF63 ym_note_off key-off an FM channel (A=channel 0–8)
── music: VGM player ──
$FF66 vgm_play start a VGM stream (OS_ARG: bank8 [$FF=flat] + addr16)
$FF69 vgm_stop stop playback, silence music chips
$FF6C vgm_tick advance playback one frame (IRQ calls this; exposed)
── input ──
$FF6F joy_read latch both joysticks + compute edges (IRQ calls this; exposed)
── math ──
$FF72 mul16 16×16→32 unsigned multiply (OS_ARG)
$FF75 div16 16/16 unsigned divide → quotient + remainder (OS_ARG)
$FF78 rng next pseudo-random byte (returns A, advances RNG_SEED)
$FF7B sin sin(A) → A, signed −127..127, angle in brad (0–255 = full circle)
$FF7E cos cos(A) → A, signed −127..127
── vector / 3-D ──
$FF81 rot3d rotate (x,y,z) by (ax,ay,az) brad (OS_ARG)
$FF84 project perspective project world (x,y,z) → (sx,sy) byte + flag (OS_ARG)
$FF87 mesh_xform transform + project a vertex list → 3-byte screen coords (OS_ARG)
$FF8A face_facing backface test of 3 projected byte points → A (1 front / 0 cull)
── vector / 3-D: off-screen-aware (signed-16) variants ──
$FF8D project_raw project world (x,y,z) → signed-16 (sx,sy) + flag (OS_ARG)
$FF90 mesh_xform_raw transform + project a vertex list → 5-byte signed-16 coords (OS_ARG)
$FF93 gpu_dotline_clip append a Cohen–Sutherland-clipped DOT_LINE (OS_ARG: signed-16)
$FF96 face_facing_raw backface test of 3 signed-16 points → A (1 front / 0 cull)
$FF99 gpu_dotpixels_clip append DOT_PIXELS, dropping off-screen points (signed-16 cloud)
── cartridge → GPU data loading ──
$FF9C gpu_load_cart emit one LOAD page streamed cart→PPRAM (OS_ARG: bank, addr, dest)
$FF9F gpu_load_cart_begin arm a multi-page load job (OS_ARG: bank, addr, destStart, count)
$FFA2 gpu_load_cart_n drain the armed job by the PPRAM left this frame → LOAD_REM
$FFA5 gpu_load_cart_bg emit one cart→VRAM-bg LOAD page + arm two-frame replay
$FFA8 gpu_load_cart_bg_n drain the armed job into VRAM-bg (auto two-frame replay)
── audio: effects (cont.) ──
$FFAB sfx_play_ptr trigger an effect from a caller's step-program pointer
(A=ptr lo, X=ptr hi, Y=tone-voice hint 0–2 / $FF auto)
── music (cont.) ──
$FFAE vgm_play_loop start a VGM stream with a SEPARATE loop anchor
(OS_ARG+0/1/2 start bank/addr, +3/4/5 loop bank/addr)
── GPU command builders (cont.) ──
$FFB1 gpu_hdotline append HDOT_LINE — byte-aligned horizontal dotted rule
(OS_ARG: X1,Y,X2 half-res; auto-aligned to bytes)
── vertical (TATE) text ──
$FFB4 gpu_vtext append VTEXT — vertical text on the image, for a monitor stood
on its side (OS_ARG: char cell 0–36, line 0–49, scroll, ptr)
$FFB7 gpu_vtext_bg the same on the background (auto-replayed — two-frame rule)
── comb ("ghost") sprites ──
$FFBA gpu_comb_sprite append COMB_SPRITE — a sprite blitted with a gapped vertical
step (OS_ARG: SPR_ID, X16, Y16, GAP 0–15). Bitmap-only
── squaring off the line family (see the 3x3 matrix) ──
$FFBD gpu_line16_clip append LINE16 — a solid line whose ENDPOINTS are full-res
(OS_ARG: X1,Y1,X2,Y2 signed 16), Cohen–Sutherland-clipped to
0..399/0..299. Same clipper as gpu_dotline_clip
$FFC0 gpu_line16 the same line CLAMPED instead of clipped — the cheap entry
when you already know the coordinates are on-screen
$FFC3 gpu_line_clip append LINE ($42) through the SAME clipper, against the
half-res rectangle (OS_ARG: X1,Y1,X2,Y2 signed 16)
$FFC6 gpu_lines append LINES ($4A) — solid half-res polyline, the solid twin
of gpu_dotlines (OS_ARG: ptr -> {N, x0,y0 … xN,yN})
$FFC9 gpu_lines16 append LINES16 ($4B) — solid full-res polyline
(OS_ARG: ptr -> {N, x0.16,y0.16 … xN.16,yN.16})
── the FULL-RES projection: what feeds $43/$4B ──
$FFCC mesh_xform_raw16 as mesh_xform_raw, but the projected coordinates land
on the 400x300 grid instead of 200x150
$FFCF project_raw16 as project_raw, same change
── figures/polylines: anchored, rotated, scaled, ALWAYS clipped ──
$FFD2 gpu_dotpolygon append DOT_POLYGON ($4C) — a figure round a signed-16
centre, rotated and scaled BY THE GPU (OS_ARG: ptr -> block).
N's bit 7 picks CLOSED (0, default) or OPEN (a polyline)
$FFD5 gpu_polygon append POLYGON ($4D) — the solid half-res twin
$FFD8 gpu_polygon16 append POLYGON16 ($4E) — the solid full-res twin
$FFDB gpu_circle16 append CIRCLE16 ($4F) — a SOLID circle outline round a
signed-16 FULL-RES centre that may be off screen, always
clipped (OS_ARG: CX.16, CY.16, R). No ANGLE/SCALE.
--- the transport block: compressed loading + rectangles on the background ----
$FFDE gpu_rect_bg_begin arm a rect-background job; emits nothing
(OS_ARG: XB, WB, GAP, H). XB/WB are BYTE columns.
$FFE1 gpu_rect_bg_cart append RECT_BG_RLE ($32) for ONE band, streamed
compressed straight from the cartridge, and record it for
the two-frame replay (OS_ARG: bank, addr.16, Y.16).
Carry SET = PPRAM full, nothing emitted or recorded.
$FFE4 gpu_load_rle_cart append LOAD_RLE ($33) — one compressed page load from
the cartridge (OS_ARG: bank, addr.16, dest page). Recorded
for replay only when the destination is background ($C0+).
$FFE7 api_decrunch expand an LZ blob (roms/lz.py) into CPU1 RAM, from the
cartridge or — bank $FF — from any flat address
(OS_ARG: bank, addr.16, dest.16 -> +5/+6 = length).
BLOCKING — a loading-state call, not a per-frame one.
$FFEA spk_say speak a phoneme string (OS_ARG: chip, tempo, volume,
scale, ptr.16). Copies the string and returns; the frame
IRQ does the rest. See Speech below.
$FFED spk_stop stop speaking and silence the four voices it was using.
$FFDE–$FFE7)#Four calls added together for one job: getting compressed data out of the cartridge and into the right memory, on the right side of PPRAM.
The rule they implement. Compress at the source, decompress at the sink. A payload
whose sink is GPU memory crosses PPRAM compressed and the GPU expands it ($32/$33)
— decompressing it here would push raw bytes through the one bottleneck that binds
(~3.5 pages a frame) and win nothing at all. A payload whose sink is CPU1's own RAM never
touches PPRAM, so the GPU cannot help and api_decrunch does the work here.
Each side has its own codec, because the sinks differ. The GPU writes a background
plane it can never read back, so its decoder has to be stateless: RLE
(roms/rle.py). CPU1 writes ordinary RAM, so api_decrunch can copy
from its own output — LZ (roms/lz.py), LZSS with a 4 KB window.
Measured, in bytes:
| data | raw | RLE | LZ |
|---|---|---|---|
| demo VGM song | 6 432 | 6 464 | 1 025 |
1-bit art (the rect_bg_test planet) |
2 816 | 1 147 | 977 |
| 6502 code (the OS's own) | 5 557 | 5 579 | 4 500 |
Until 2026-09-11 api_decrunch expanded the GPU's RLE as well; it was switched before any
shipped cartridge depended on it (only carts/rect_bg_test did). The decoder is 174 B
against the RLE one's 135 and runs at 73–95 cycles per output byte — the 6.4 KB demo song
in two frames. Bank $FF is a flat source: ROM, RAM, or the $8000–$9FFF window
with the cartridge off, read straight through $9FFF→$A000, and the bank register is
never touched. Contracts: the destination must be readable RAM (a match copies back
out of it) and must not overlap the source; like the RLE decoder it runs on the output
count, so a stream too short for its count is read on past its end — lz.py never makes
one. Incompressible data grows by up to an eighth; use cart_load for that.
The cartridge blob format, produced by both roms/rle.py and
roms/lz.py — one header walker serves both codecs:
[clen_lo][clen_hi] compressed bytes that follow this header
[dlen_lo][dlen_hi] decoded byte count
[ clen compressed bytes ]
The length lives in the cartridge, not in the replay record — which is exactly what
keeps that record at six bytes. A band's compressed size varies, so carrying it
per-record would need a seventh argument; reading it back off the cartridge costs two
byte fetches. Bands are concatenated blobs: walk them with addr += 4 + clen.
The job contract. XB, WB, GAP and H are constant for a whole background load,
so they live in job state ($0249–$024C) rather than in every record. The consequence is
worth stating plainly: do not re-arm with gpu_rect_bg_begin while a band is still
waiting to be replayed. Leave one clear frame after the last gpu_rect_bg_cart.
Re-arming early makes the pending replay re-emit that band with the new geometry, which
lands it in the wrong place in the second buffer only and reads on screen as a tear that
survives exactly one frame. It is the same class of contract gpu_load carries about its
source RAM staying stable.
Every band must encode exactly WB·H bytes. The GPU drives its decoder by that output
count and has no idea how many compressed bytes a band holds, so a short final band leaves
it reading past the end of its stream and into the next command — the failure is not a
corrupt picture but arbitrary bytes executed as opcodes. rle.py pads the last band; see
MAD65_GPU_OS.md The transport block and the note in
carts/rect_bg_test/README.md, which is where this
bit during bring-up.
Full-res projection.
project*centres on half-res (100,75) and scales by 128 — doubling that result can only ever yield an EVEN full-res coordinate ($42's quantisation).mesh_xform_raw16/project_raw16($FFCC/$FFCF) run the same divide with the centre/focal length doubled instead, so the quotient can land on any full-res pixel. Cost: 44 B ROM, one ZP byte (VEC_RES16$7F), +10–22 cycles/vertex (~1%). ReadVEC_SX16/VEC_SY16, not the byte outputs, in full-res mode.persp_divsaturates at|coord|·D ≥ 65536, so doublingDhalves the pre-saturation world-coordinate range to|coord| ≥ 256— comfortably above model-space geometry (a byte per axis).ABI rule: the table is append-only — 74 entries,
$FF00–$FFDD, asserted at assemble time. Never reorder, remove, or insert in the middle; new services go on the end.$FF12and$FF18held the removedgpu_pixel_bg/gpu_line_bg; they are permanent no-op stubs so every address below them stays valid.The signed-16 convention.
gpu_pixel,gpu_sprite,gpu_comb_spriteand the five*_raw/*_clipentries all take true signed-16 screen coordinates that may be negative or past the right/bottom edge. The olderproject/mesh_xform/face_facing/gpu_dotlineinstead clamp to the0–199 / 0–149field, which is fine while every vertex is on-screen but bends any edge touching a clamped corner (and an off-left/off-top point, byte-wrapped, flips to the opposite edge). See "Off-screen-aware variants" for when to use which.Note vs the GPU OS: the builder mnemonics map 1:1 onto GPU opcodes. A builder's job is to validate its arguments, then emit
[opcode][args…]into PPRAM atPPWP. Argument formats are exactly the GPU opcode arguments — see the GPU OS doc's instruction set table for the byte-level meaning.
gpu_vreg sub-ops (in A)#| A | Effect | GPU opcode emitted |
|---|---|---|
0 |
COPY_DIS off | $11 |
1 |
COPY_DIS on | $10 |
2 |
BG_REG off | $13 |
3 |
BG_REG on | $12 |
4 |
BLINDER off | $15 |
5 |
BLINDER on | $14 |
A, X, Y), as annotated above.OS_ARG block ($20–$2F) before JSR. 16-bit values are little-endian pairs.LOAD data, DOT_LINES/DOT_PIXELS lists) →
passed as a 16-bit value in OS_ARG; the data itself stays in game RAM and is
copied into PPRAM by the builder.PPWP. The caller never touches PPWP directly.OS_SCRATCH ($12–$1F) is clobbered by every call. Preserve nothing there.OS_ARG is also clobbered by gpu_begin when a background replay is pending
(the replay loads its records into OS_ARG) — fill OS_ARG after
gpu_begin, never before it.The builder argument order in OS_ARG deliberately equals the GPU wire-format
byte order, so each builder emits its arguments as a straight copy. 16-bit
values are little-endian (lo byte at the lower address).
| Builder | +0 |
+1 |
+2 |
+3 |
+4 |
|---|---|---|---|---|---|
gpu_pixel |
X lo | X hi | Y lo | Y hi | — |
gpu_line, gpu_dotline |
X1 | Y1 | X2 | Y2 | — |
gpu_hdotline |
X1 | Y | X2 | — | — |
gpu_dotline_clip |
X1 lo/hi | Y1 lo/hi | X2 lo/hi | Y2 lo/hi | (+0..+7, signed-16) |
gpu_dotpixel |
X | Y | — | — | — |
gpu_dotcircle |
CX | CY | R | — | — |
gpu_dotlines, gpu_dotpixels, gpu_dotpixels_clip |
ptr lo | ptr hi | — | — | — |
gpu_sprite |
SPR_ID | X lo | X hi | Y lo | Y hi |
gpu_comb_sprite |
SPR_ID | X lo | X hi | Y lo | Y hi |
gpu_text, gpu_text_bg, gpu_tile, gpu_tile_bg |
col | row | scroll | ptr lo | ptr hi |
gpu_vtext, gpu_vtext_bg |
char cell (0–36) | line (0–49) | scroll | ptr lo | ptr hi |
gpu_load |
page MSB | ptr lo | ptr hi | — | — |
gpu_dotpolygon, gpu_polygon, gpu_polygon16 |
ptr lo | ptr hi | — | — | — |
gpu_circle16 |
CX lo | CX hi | CY lo | CY hi | (+4 = R, full-res px) |
(cart_load and vgm_play keep the layouts given in their own sections.)
The builders are the heart of the OS. They turn a validated, type-checked call into correctly-formatted bytes in the shared command list, so the developer never writes PPRAM by hand, never manages the write pointer, and never ships an out-of-range coordinate to the GPU.
Every frame's list is bracketed:
jsr gpu_begin ; PPWP ← $7801, status ← CPU_WORKING, PP_OVERFLOW ← 0,
; VSYNC_FLAG ← 0, FRAME_SP ← stack watermark,
; + re-emit last frame's BG commands (auto-replay)
… emit commands …
jsr gpu_end ; append WAI ($00), status ← CPU_READY (if still on time)
gpu_begin does not clear PPRAM — it only resets PPWP to $7801. The list is
overwritten in place each frame and re-terminated by the WAI that gpu_end
appends; any stale bytes beyond WAI are ignored by the GPU. (This answers the GPU
OS doc's open question — the CPU does not zero PPRAM per frame.)
Each builder validates its arguments against the legal ranges in the GPU OS doc ("Coordinate validation is CPU1's responsibility") before emitting:
| Argument kind | Action on out-of-range |
|---|---|
Full-res pixel X/Y (PIXEL) |
drop the pixel if outside 0–399 / 0–299 (signed-16; off-screen → nothing emitted, not clamped to the edge) |
| Half-res X/Y (line/dot/circle family) | clamp to 0–199 / 0–149 |
HDOT_LINE half-res X1/Y/X2 (gpu_hdotline) |
clamp to 0–199 / 0–149, order X1≤X2, then auto-align to bytes (XB = X1>>2, NB covers X1..X2 inclusive); the GPU additionally clamps NB to the row's right edge |
DOT_PIXELS_CLIP point cloud |
drop each signed-16 point outside 0–199 / 0–149; emit nothing if none survive |
| Text/tile column/row | clamp to 0–49 / 0–36 (scroll masked with AND #$07) |
VTEXT char cell / line |
clamp to 0–36 / 0–49 — the same pair of limits TEXT uses, in the opposite order, because the two grids are transposes (scroll masked with AND #$07 either way). Both are plain unsigned screen coordinates; there is no signed or off-grid cell. A string that runs past cell 36 spills onto the GPU's two guard cells and is clipped there, which is how a character enters from the right edge |
TEXT string bytes outside $20–$7F |
substitute a space ($20) — the GPU glyph lookup has no range check and would draw garbage; tile ids need no filter (1–255 all valid) |
Empty string / N = 0 vertex or pixel list |
skip — command not emitted (draws nothing; GPU behaviour for N = 0 is undefined) |
DOT_CIRCLE off-screen centre or R = 0 |
skip — command not emitted (GPU would skip / draw nothing anyway) |
SPRITE X/Y |
pass through, signed-16 top-left pixel ((0,0) = screen origin) — the GPU clips all four edges and rejects a fully off-screen sprite |
LOAD destination page |
skip if a blocked page ($00, $01, $78–$7F — the whole PPRAM window is excluded, a superset of the GPU doc's $78) |
Polygon block (gpu_dotpolygon / gpu_polygon / gpu_polygon16) |
pass through, unvalidated — the centre is signed-16 and is meant to be able to sit off screen, and the GPU enforces the rest at its own end (SCALE > 128 clamped to 128, SCALE = 0 and N = 0 draw nothing, offsets clamped to ±127). Only N = 0 short-circuits here, because there is nothing to emit |
gpu_circle16 |
pass through, unvalidated — same reasoning: the centre is signed-16 and is meant to be able to sit off screen. Only R = 0 short-circuits here |
Clamping or dropping out-of-range coordinates guarantees no write ever reaches the
$BFC0/$BFE0 hardware-register region described in the GPU OS doc. A builder that
clamps, drops, or skips sets no error flag (it is silent); only a PPRAM overflow
is flagged.
gpu_dotpolygon / gpu_polygon / gpu_polygon16)#OS_ARG+0..1 = pointer to ONE argument block:
CX lo, CX hi, CY lo, CY hi, ANGLE, SCALE, N,
dx0, dy0, dx1, dy1, … , dxN-1, dyN-1
emits: [$4C | $4D | $4E] followed by that block verbatim (2N + 8 bytes)
These three are the shortest builders in the OS and that is the entire point: a straight copy, with no clamping, no transform and no per-vertex arithmetic at all. The rotation, the scale, the sign handling, the 16-bit centre add, the outcodes and the clip all happen on the GPU. CPU1 keeps about 12 cycles a vertex to move two bytes, where transforming one costs ~320.
| Field | Meaning |
|---|---|
CX, CY |
Signed-16 centre, passed through unclamped — it is meant to be able to sit off screen, which is why the family is always clipped. Half-res units for $4C/$4D, full-res for $4E. |
ANGLE |
brad; 256 = a full turn. Same convention and direction as API_SIN/API_COS. |
SCALE |
Q0.7 unsigned, 128 = 1:1. Shrinking only — the GPU clamps above 128 and treats 0 as "draw nothing", so this builder does not need to. |
N |
Vertex count. The figure is closed: N vertices, N segments. N = 0 emits nothing. |
dx, dy |
Signed byte offsets from the centre, unrotated and unscaled. Keep the shape inside ±127; the GPU clamps −128. |
One buffer, not a shape pointer plus a header: a caller doing level-of-detail sends only
some of a shape's vertices, and compacting those into a buffer is a pass it has to make
either way — so the header may as well be its first seven bytes, and the builder becomes
one memcpy. Shape data stays static in ROM; only the compacted copy is per-frame.
PPRAM cost: 2N + 8 bytes — five more per figure than $45 DOT_LINES's 2N + 3,
and half of what a signed-16 chain (4N + 6) would need.
There is no clamped variant and no open ("polyline") variant. For a path, use
gpu_lines / gpu_lines16 / gpu_dotlines. For the full picture — the cost table, the
clipper, the trig tables and the ±127 / 254-px limits — see
MAD65_GPU_OS.md The polygon family.
gpu_circle16 ($FFDB) — a solid circle whose centre may be off screen#OS_ARG+0..1 = CX (signed 16, FULL-RES — may be OFF SCREEN)
OS_ARG+2..3 = CY (signed 16, full-res)
OS_ARG+4 = R (radius in FULL-RES pixels, 0–255)
emits: [$4F][CX.16][CY.16][R] (6 bytes)
The circle member of the anchored, always-clipped family — hence no _clip suffix and no
clamped variant. Nothing is validated here beyond R = 0: there is nothing sensible to
clamp a signed-16 anchor to, and the GPU sizes the circle from its bounding box in
~200 cycles, dropping it if it can't touch the screen — cheaper than testing it here.
No ANGLE/SCALE (nothing to rotate; scale would cost an extra multiply to save the
caller one it already does per object) and no half-res twin (both forms cost 6 stream
bytes and the same cycles here, unlike a line where half-res genuinely halves the bytes).
gpu_dotcircle ($FF27) remains the dotted, half-res, on-screen-only call, at about
half the cycles ($4F runs the same midpoint walk at full resolution). Cost table in
MAD65_GPU_OS.md CIRCLE16.
The list region is $7801–$7FFF = 2047 bytes. Before emitting, a builder checks that
PPWP + command_length ≤ $7FFF. If it would overflow, the command is dropped and
PP_OVERFLOW ($08) is set. The frame still terminates cleanly (gpu_end always
fits — one byte is reserved for the closing WAI). A game that trips PP_OVERFLOW is
asking the GPU to draw more than 2 kB of commands and must thin its scene.
gpu_begin re-arms (clears) the flag each frame, so after gpu_end it answers
"did this frame drop a command?".
The room check is also where the deadline is enforced: pp_room is the one gate
every builder passes before its first emitted byte, so extending its question from
"does it fit?" to "does it fit and am I still on time?" covers all 21 builders
without touching a single call site. A refusal for either reason has the same shape —
carry set, command dropped — which is why the frame-abort path lands here too.
Any builder that targets VRAM-background (gpu_text_bg, gpu_vtext_bg,
gpu_tile_bg, gpu_clearbg, and gpu_load to a $C0–$FF page) must reach the GPU on two
consecutive frames to land in both physical buffers — the why is in
MAD65_GPU_OS.md, "Writing to the background". The OS handles the
second frame automatically, so the developer issues a background write once.
(Image-layer writes are never replayed — they are rebuilt every frame by definition.)
Model: argument capture. When a background-targeting builder successfully emits
its command, it appends a fixed 6-byte record — [gpu_opcode][OS_ARG+0..4] — to the
BG replay page ($0300). On the next gpu_begin (note: inside gpu_begin, not
os_run, so cartridges running their own loop are covered too) the OS re-loads each
record's arguments into OS_ARG and re-runs the same public builder entry point
— revalidating and re-emitting through the normal room-checked path — then clears
the records. The replayed commands are the first bytes of the new frame's list, so
the background lands under everything else.
BG replay page layout ($0300–$03FF):
| Address | Name | Meaning |
|---|---|---|
$0300 |
RP_DIS |
published — nonzero: capture off, for games managing the two-frame rule themselves (boot clears it → auto-replay on by default) |
$0301 |
RP_DROP |
published — set when a BG command could not be recorded (record area full); the command drew once and will flicker until reissued. Sticky — the game clears it |
$0302–$0305 |
— | internal (record write index, replay cursor, re-capture suppressor) |
$0306–$03FF |
records | 250 B = 41 records of 6 bytes |
Rules that follow from the argument-capture model:
gpu_text_bg/gpu_tile_bg strings,
gpu_load 256-byte pages) are re-read on the replay frame — the source data
must stay unmodified until after the next gpu_begin.LOAD
replays from a tiny record (but it does cost the full 258 bytes of PPRAM again
on the replay frame — budget for it).PP_OVERFLOW reports it).PP_OVERFLOW; its background write reached only one buffer (flicker) until the
game redraws.OS_ARG, gpu_begin clobbers OS_ARG
whenever a replay is pending — fill OS_ARG after gpu_begin, never before.RP_DIS and manage its own two frames, or simply
redraw on two consecutive frames and ignore the replay machinery.The three chips are split statically by role — no dynamic channel juggling needed, because we have two PSGs:
| Chip(s) | Role | Driven by |
|---|---|---|
| SN76489 #1 + YM2413 | music | the VGM player (and snd_*/ym_* when no song plays) |
| SN76489 #2 | sound effects | the SFX engine + snd_*/snd_beep — never touched by VGM |
This mirrors how a typical VGM file is authored — one PSG (0x50) plus an FM chip
(0x51). Because MAD-65 has a second PSG, that one is reserved for effects, so music
and effects never contend and there is no ducking. (The rare dual-PSG VGM command
0x30 is ignored by default — see the VGM player.)
audio_tick (SFX state) and vgm_tick (music) both advance once per frame from the
IRQ handler, so audio keeps steady time regardless of game-logic load.
| Channel | Chip / port | Type | Default role |
|---|---|---|---|
| 0–2 | SN76489 #1 $BF00 |
tone | music (VGM) |
| 3 | SN76489 #1 | noise | music (VGM) |
| 4–6 | SN76489 #2 $BF10 |
tone | effects |
| 7 | SN76489 #2 | noise | effects |
| 0–8 FM | YM2413 $BF20/$30 |
FM | music (VGM) |
sfx_play / snd_beep default to the SN76489 #2 channels (4–7), which are always free
for effects. When no VGM song is playing, all channels — including SN76489 #1 and
the YM2413 — are freely available to snd_* / ym_* as well.
snd_init — write max attenuation to all SN76489 channels (silence) and run
the YM2413 init sequence (write 0 to regs $00–$3F with the datasheet spacing —
see Register write pacing below — then melody mode). Called once at boot.snd_tone (A=channel, X=note, Y=volume) — note is a MIDI note number;
look up the 10-bit divisor in the note table, write the SN76489 latch+data pair,
and set 4-bit attenuation from volume (0 = silent, 15 = loudest; the chip's
attenuation is inverted internally by the helper).snd_off (A=channel) — set the channel's attenuation to silent.snd_noise (A=channel, X=mode, Y=volume) — periodic/white noise select + rate.snd_beep (A=note) — convenience: a short fixed-duration tone on a free tone
channel via the SFX engine. For menus/UI.note is a standard MIDI note number (69 = A4 = 440 Hz, 60 = middle C, +1 per
semitone). The table holds the precomputed 10-bit SN76489 divisor for each note (no
runtime divide):
f(note) = 440 × 2^((note − 69) / 12)
N(note) = round(3 579 545 / (32 × f(note))) = round(111 860 / f(note)) ; 1–1023
| Note | MIDI | f [Hz] | N |
|---|---|---|---|
| A2 (lowest) | 45 | 110 | 1017 |
| C4 (middle) | 60 | 261.6 | 428 |
| A4 | 69 | 440 | 254 |
| A5 | 81 | 880 | 127 |
| C8 (highest) | 108 | 4186 | 27 |
Range — 64 notes, MIDI 45–108 (A2–C8, 5⅓ octaves):
N = 1017; one semitone lower would need
N > 1023, beyond the chip's 10-bit divisor. Notes below 45 are not representable.N shrinks.Storage: 64 notes × 2 bytes (raw 10-bit N) = 128 bytes of ROM, in the ROM
data region (placed by the assembler alongside the other tables). The table is indexed
by note − 45; snd_tone subtracts 45 and clamps notes outside 45–108. Lookup is a
single 4-cycle lda tab,x, then the divisor is split into the SN76489 latch (N[3:0])
and data (N[9:4]) writes.
A sound effect is a tiny per-frame program. sfx_play (A=sfx_id) allocates a channel
of the required type and starts the effect; audio_tick advances it once per frame.
Caller-supplied effects — sfx_play_ptr ($FFAB, A=ptr lo, X=ptr hi) plays the
same kind of step program from any RAM/ROM address instead of a built-in sfx_id,
so a cartridge can keep its own library of effects (the data format is identical;
see below). The pointer is dereferenced live by audio_tick (which runs in the IRQ),
so the program must stay resident for the effect's duration — system RAM is always
mapped, so a RAM pointer is safe regardless of the cartridge bank selected. There is no
id range to validate, so malformed data is the caller's responsibility.
sfx_play_ptr also takes a tone-voice hint in Y: 0–2 forces tone voice 4/5/6,
any other value ($FF by convention) auto-allocates exactly like sfx_play. This lets
a game pin a category of effects to its own voice — e.g. weapon shots to voice 4 and
impacts to voice 5 — so the two never steal each other even when the 3-voice tone pool
is busy. The hint is ignored for noise effects (they always own the single noise voice
7). sfx_play is unchanged — it always auto-allocates (Y = $FF internally).
SFX data format (ROM table of effects, or any caller-supplied program):
effect := type, step, step, …, $FF
type := $00 tone | $01 noise
step := duration_frames, note_or_mode, volume
Each audio_tick decrements the active step's duration_frames; at zero it advances
to the next step (writing the new note/volume), and at the $FF terminator it
silences and frees the channel. This expresses sweeps, blips, decays and explosions
without a sequencer. Effects are short (a handful of steps).
Channel allocation: sfx_play allocates from SN76489 #2 (channels 4–7) — the
dedicated effects chip. It picks a free channel of the needed type (tone 4–6 / noise 7);
if none is free it steals the oldest. (sfx_play_ptr can instead force a specific tone
voice via its Y hint — see above — to keep effect categories on separate channels.) Because SN76489 #2 is never driven by the VGM
player, effects and music never collide and no ducking is needed — an effect can
fire at any time without disturbing the song. (When no VGM song is playing, a game may
also route effects to SN76489 #1, but the default keeps them on #2 so they are always
safe over music.)
audio_tick#Called once per frame by the OS loop (from the IRQ handler — see below) so audio
advances on a steady cadence even if the game's frame logic overruns. It walks the
SFX channel state table ($0200 page): advances each active SFX/beep on SN76489 #2,
applies decay, silences finished channels. Exposed in the jump table for games that run
their own loop. (audio_tick services only the SFX state; VGM music is advanced
separately by vgm_tick, and YM2413 direct notes sound until explicitly keyed off.)
Three small helpers expose the YM2413's nine melodic FM channels with the 15 built-in
instruments. No sequencer — the caller (or a cartridge music routine) keys notes on and
off each frame. After snd_init has run the YM init sequence, FM channels are silent
and ready.
ym_inst (A=channel 0–8, X=instrument 0–15) — select a built-in instrument for
the channel (instrument 0 = the user/custom patch programmed in regs $00–$07;
1–15 = the ROM presets). Writes the instrument nibble of reg $30+ch.ym_note_on (A=channel 0–8, X=midi_note, Y=volume 0–15) — start a note: look up
the F-number, derive the block (octave), and write regs $10+ch / $20+ch with
key-on, plus the volume nibble of $30+ch.ym_note_off (A=channel 0–8) — clear the key-on bit in $20+ch; the note
releases per the instrument's envelope.Unlike the SN76489's single divisor, the YM2413 sets pitch with a 9-bit F-number plus a 3-bit block (octave, 0–7):
f_out = fnum × (fM / 72) / 2^(18 − block) fM = 3 579 545 Hz, fM/72 ≈ 49 716 Hz
fnum = round( f_out × 2^(18 − block) / 49 716 ) ; 0–511
Because block is the octave, the same twelve F-numbers repeat every octave —
only block changes. So the table is just one octave: 12 notes × 2 bytes = 24 bytes
of ROM. ym_note_on does:
semitone = midi_note mod 12
octave = midi_note div 12
fnum = FNUM_TABLE[semitone] ; 12-entry table, ~256–511 range for resolution
block = octave − OCTAVE_BASE ; clamped to 0–7
reg $10+ch ← fnum[7:0]
reg $20+ch ← (1<<4) | (block<<1) | fnum[8] ; bit4 = key-on
reg $30+ch ← (instrument<<4) | volume
Example: A4 (MIDI 69, 440 Hz) at block = 3 → fnum = round(440 × 32768 / 49716) =
290. The twelve stored F-numbers are computed at assemble time (like the SN76489 note
table). OCTAVE_BASE is chosen so the playable MIDI range maps to blocks 0–7.
The
ym_*helpers and the VGM player both target the YM2413. Don't drive the same FM channel from both at once — useym_*for games without VGM music, and let the VGM player own the FM channels while a song plays.
sn_write (~130 CPU cycles
per SN76489 byte; the chip needs ~32 chip clocks and its READY pin is not
wired to stall the CPU) and ym_write (~48 CPU cycles after register select,
~340 after data, per the YM2413 datasheet). A full FM note-on costs ~1100
cycles ≈ 0.5 % of the frame budget. (The chip clock is a quarter of the CPU
clock, so a wait given in chip clocks is ×4 in CPU cycles, not ÷4.)ym_note_on included).OS_AUDIO ZP block
($30–$3F), never OS_SCRATCH/OS_ARG, so the IRQ-driven audio_tick can
never corrupt a builder mid-run; the main-loop entries (snd_*, sfx_play,
snd_beep) mask IRQs around their critical sections.$0200–$023F: the 4-voice SFX channel table
(mode/counter/step-pointer/age per voice), and the YM2413 reg $20/$30
shadows that ym_note_off/ym_inst need (the chip is write-only).sfx_play ids): 0 SFX_ZAP falling laser sweep
(tone), 1 SFX_COIN two-note pickup chime (tone), 2 SFX_BOOM white-noise
explosion decay (noise), 3 SFX_BLIP 2-frame UI tick (tone).snd_beep: fixed 8 frames at volume 12, allocated like a tone effect
(steals the oldest voice if channels 4–6 are all busy).N & $0F latch nibbles and N >> 4 data
bytes, 128 B total), so playing a note involves no runtime shifting.The music engine plays a VGM stream — a log of sound-chip register writes interleaved with wait commands. VGM is a natural fit because the SN76489 and YM2413 are native VGM chips: a song is just the bytes that would have been written to the chips in real time, and the player replays them on schedule.
vgm_play — start playback of the VGM stream at a given source. Arguments
(OS_ARG):| Field | Bytes | Meaning |
|---|---|---|
VGM_BANK |
1 ($20) |
cartridge bank 0–127 of the first byte, or $FF = flat (RAM/ROM, no banking) |
VGM_ADDR |
2 ($21–$22) |
start address — a $8000–$9FFF window address for cartridge sources, or any CPU address when VGM_BANK = $FF |
Parses the header (or accepts a pre-stripped command stream — see below), sets the data
and loop cursors, marks the player active. Does not block. The song may live anywhere:
ROM/RAM ($FF) or inside the cartridge, and a cartridge song may span any number of
banks (see "Bank-aware source"). The 0x66 end command loops back to the start
address given here (the whole stream repeats).
vgm_play_loop ($FFAE) — identical to vgm_play, but the 0x66 end command
loops back to a separate anchor rather than the start, so a song can play a one-shot
intro once and then repeat only its body. Takes the same start source in OS_ARG+0/1/2
plus the loop anchor in OS_ARG+3 (bank) and OS_ARG+4/+5 (address). The anchor is
the song's own VGM loop point; for a cartridge song it is baked at assemble time from the
loop offset (the CETAS vgmstrip.py computes it). Internally it just writes a different
(VGM_BBANK, VGM_BASE) — the loop mechanism is otherwise unchanged.
vgm_stop — stop playback and silence all chips the song was using.
vgm_tick — advance playback by one frame. Called automatically every frame
from the IRQ handler (alongside audio_tick), so tempo stays rock-steady regardless
of game-logic load. Exposed in the jump table for custom loops.| Bytes | Meaning | Target |
|---|---|---|
50 dd |
PSG write | SN76489 #1 ($BF00) — the music PSG |
51 aa dd |
register aa ← dd |
YM2413 ($BF20/$BF30, via the paced ym_write) |
30 dd |
second-PSG write (dual-chip) | ignored by default — SN76489 #2 is reserved for SFX |
61 nn nn |
wait nnnn samples (44.1 kHz) |
— |
62 |
wait one frame (735 samples) | — |
63 |
wait 882 samples (1/50 s) | — |
70–7F |
wait 1–16 samples | — |
66 |
end of data → loop point, or stop | — |
The player drives SN76489 #1 + YM2413 (music); SN76489 #2 is never touched so it
stays free for effects. The dual-PSG command 0x30 is skipped by default (it appears
only in the rare dual-PSG VGM); a song that genuinely wants both PSGs for music would
give up the dedicated effects chip. Unrecognised commands with a known length are
skipped.
VGM is sample-accurate at 44 100 Hz. Each frame the player adds ≈731 samples to a
budget (44 100 / 60.317 — MAD-65's real frame rate, a ~0.5 % tempo error vs the VGM
nominal 1/60, inaudible). vgm_tick then:
budget += SAMPLES_PER_FRAME
loop:
if pending_wait > 0:
take = min(pending_wait, budget); pending_wait -= take; budget -= take
if pending_wait > 0: return ; budget spent, resume next frame
cmd = next stream byte
dispatch cmd → chip write, or set pending_wait, or loop/stop
goto loop
So all register writes due this frame are flushed, then the player parks on the next
wait. Back-to-back YM2413 writes are naturally separated by the dispatch loop, and
ym_write enforces the datasheet spacing regardless.
The player reads its stream in place — it does not pre-copy the song into RAM — so a
large cartridge VGM plays directly from its banks. Two source modes, selected by
VGM_BANK:
VGM_BANK = $FF): the stream sits at a fixed CPU address — in ROM, or in RAM
(the demo song, which demo_init expands there from its LZ-packed ROM copy with
api_decrunch). No banking; the read cursor is just a 16-bit pointer.VGM_BANK = 0–127): the stream lives in the cartridge, read through the
$8000–$9FFF window. The player keeps a (bank, addr) cursor; when the cursor passes
$9FFF it increments the bank and wraps to $8000, so the song may span any number
of banks (same rule as cart_load).Per-frame bank dance — the important part. vgm_tick runs in the IRQ handler,
which can fire while the main loop / cartridge code is executing from some bank in the
window. For a cartridge song, vgm_tick therefore:
saved = CART_BANK_MIR ; whatever bank the interrupted code was using
cart_bank(VGM_cursor_bank | $80) ; map the song's current bank
… read this frame's VGM bytes (advancing the cursor / bank across $9FFF) …
cart_bank(saved) ; restore, so the interrupted code resumes correctly
This is the same save/select/restore discipline as cart_load, run every frame. It is
safe as long as every bank change goes through cart_bank/CART_BANK_MIR (which the
OS mandates): vgm_tick borrows the bank for its reads and always hands it back before
RTI. Flat songs ($FF) skip the dance entirely.
cart_loadinteraction. A longcart_loadand the IRQ-timevgm_tickboth move the bank register. They compose because each saves/restoresCART_BANK_MIR; keepcart_load's shadow current per copied segment (or mask the IRQ around a short copy). Practically: do big loads at init (music off), stream small chunks in-game.A full VGM header is 256 bytes.
vgm_playalso accepts a pre-stripped stream (commands + loop offset only), which is how the demo song is kept — and in ROM it is LZ-packed on top of that (mad65_theme.lzs, 1 029 B for 6 432), expanded into RAM bydemo_initbefore it plays. The player itself never sees LZ.
The player keeps its hot state in the OS_AUDIO zero-page block: the read cursor
(bank + 16-bit addr), the loop cursor (bank + addr), the sample budget and the
active flag. No channel shadow or ducking is required: because SFX live exclusively
on SN76489 #2 and the player only touches SN76489 #1 + YM2413, music and effects occupy
disjoint hardware. An effect can fire mid-song with no effect on the music and nothing to
restore afterwards.
spk_say ($FFEA) makes the machine talk. Not with samples — with formants.
Three tone voices of one SN76489 carry the first three formants of an utterance and the noise voice carries the bursts in the stops and the aspiration in the fricatives; the engine glides between phoneme targets once a frame. It is sine-wave speech (Remez 1981) with squares instead of sines, which is what a PSG can be asked for. A cartridge supplies a string of phoneme indices and nothing else: "ENEMY DETECTED" is nineteen bytes.
Speech built this way is recognisable but not always decodable cold. It is meant to ride on a message the game is already displaying — put the words on screen and the same audio reads as speech at once.
SPK_CURexists so the caption can arrive with the voice.
spk_say ($FFEA) OS_ARG+0 chip 0 = SN76489 #1, non-zero = SN76489 #2
OS_ARG+1 tempo signed −2..+2, 0 = normal (clamped)
OS_ARG+2 volume 0 = full, 1..6 = quieter in 2 dB steps
OS_ARG+3 scale signed −2..+2, 0 = normal (clamped)
OS_ARG+4/5 pointer to the phoneme string
spk_stop ($FFED) no arguments
All three voice parameters default to 0, so an ordinary call is four stz and
two sta. Calling spk_say while already speaking replaces the utterance:
last call wins, and there is no priority arbitration — test SPK_BUSY first if
a warning must not cut off a briefing.
spk_say copies the string into the OS's own buffer and returns, so the
engine needs no knowledge of cartridge banks. The pointer may address ROM, RAM
or an open cartridge window; the only requirement is that the bytes are readable
at the moment of the call, which satisfies itself, because a game already has
its message bank open in order to display the text it is about to speak.
SPK_BUSY |
$024D |
0 idle, non-zero speaking. Long text is spoken a sentence at a time, the caller starting the next one when this goes to 0 |
SPK_CUR |
$0252 |
index of the next phoneme, 0 up — what a caption steps from, so the words arrive with the voice instead of on a guessed timer |
One byte per phoneme, %SSPPPPPP. PPPPPP is the index; a value of 0 in those
six bits ends the string. SS is length and stress:
SS |
||
|---|---|---|
$00 |
SPK_SHORT |
half the table's duration |
$40 |
SPK_NORM |
the table's own duration |
$80 |
SPK_LONG |
half as long again |
$C0 |
SPK_STRESS |
…and 2 dB louder with it |
The buffer holds 63 phonemes — about 5.8 seconds at the measured ~11 phonemes a second. A longer string is truncated, which is a symptom and never a feature: enforce the limit in whatever tool generates the phonemes.
Cartridges carry these numbers, so they are frozen. The formant values behind them are not: retuning the table improves every game already shipped, renumbering would silently make them all say something else.
1 I /i/ bEAt |
2 IH /I/ bIt |
3 EH /E/ bEt |
4 AE /ae/ bAt |
5 AA /a/ fAther |
6 AO /O/ bOught |
7 UH /U/ bOOk |
8 UW /u/ bOOt |
9 AH /V/ bUt |
10 AX schwa |
11 ER /R/ bIRd |
12 M nasal |
13 N nasal |
14 NG nasal |
15 L |
16 R |
17 W |
18 Y /j/ |
19 CLS closure |
20 CLZ closure |
21 PAU word gap |
22 P burst |
23 B burst |
24 T burst |
25 D burst |
26 K burst |
27 G burst |
28 HH /h/ |
29 S |
30 Z |
31 SH |
32 F |
33 V |
34 TH |
A stop is a silence and then a burst. Put CLS before P/T/K and CLZ
before B/D/G, or the consonant is a click instead of a letter. The formants
keep gliding through that silence, so the burst already sits at the locus of the
vowel behind it — which is most of what tells /d/ from /k/.
scale is, and what it is not#There is no pitch here. The three tones are the formants; there is no
fundamental to transpose. scale moves all three together, which the ear reads
as the length of the vocal tract — the size of the speaker. Measured across
its range on one vowel:
| F1 | F2 | F3 | |
|---|---|---|---|
scale −2 |
470 | 1645 | 2237 Hz |
scale 0 |
528 | 1834 | 2486 Hz |
scale +2 |
601 | 2071 | 2797 Hz |
28% end to end, roughly an adult male against a child. Note the direction:
scale acts on the divisor, so minus is lower formants and a bigger head.
Speech takes three tone voices and the noise voice of the PSG it was given, for as long as it is speaking. Two rules in the engine make that safe:
spk_stop always silences. Music writes are edge events, so an utterance
cutting across them leaves the chip holding whatever speech left: a channel
left silent recovers at its next note-on (which writes both divisor and
volume), a channel left loud would hang until then. Exit silence makes the
second case impossible — which is why the VGM player needs no register shadow.The tick runs inside the VSYNC IRQ after audio_tick and vgm_tick, so the
write order is deterministic and speech wins on the voices it owns.
| ROM | 1068 B — 898 B of engine, 170 B of phoneme table |
| RAM | $024D–$0277 state, $02C0–$02FF the 64-byte buffer |
| CPU | ~2.1 k cycles a frame while speaking, nothing when idle |
| voices | 3 tone + noise, on one PSG |
A worked example — the phonemes, the call, and a caption stepped from SPK_CUR
— is carts/speech_test, which also carries the
end-to-end check that decodes the chip writes back and compares the formant
track against this table.
Two DE-9 ports at $BF40 / $BF50, active-low, 6 bits each
(UP DOWN LEFT RIGHT FIRE FIRE2). Polled — no interrupts. FIRE2 is the
Amiga-style second fire on DE-9 pin 9; b0–b4 keep their original
positions so single-fire software is unaffected.
joy_read latches both ports into the ZP shadows and computes edges, once per
frame from the IRQ handler (so input sampling is locked to VSYNC):
JOY1_PREV ← JOY1
JOY1 ← (read $BF40, inverted so 1 = pressed)
JOY1_PRESS ← JOY1 AND NOT(JOY1_PREV) ; newly pressed this frame
(… same for port 2 …)
Game code reads the shadows directly (they are published ZP, $0A–$0F):
| Variable | Bit layout |
|---|---|
JOY1 / JOY2 |
held state: b5=FIRE2 b4=FIRE b3=RIGHT b2=LEFT b1=DOWN b0=UP |
JOY1_PRESS / JOY2_PRESS |
edge: bit set only on the frame the input was newly pressed |
So a game gets held, just-pressed (edge) for free; just-released is
JOY_PREV AND NOT(JOY) if needed.
The 65C02 has no multiply, divide, or trig. These are reusable by every cartridge and
by the demo's vector graphics. All OS_ARG operand layouts below are fixed.
mul16 — 16×16 → 32 unsigned multiply (shift-and-add). Layout:
OS_ARG+0..1 = multiplicand (in, preserved), +2..3 = multiplier (in, consumed),
+4..7 = 32-bit product (out, little-endian). A signed 8×8 → 16 helper (mul_s8,
internal) backs the vector math.div16 — 16 / 16 → quotient + remainder, unsigned (restoring division). Layout:
OS_ARG+0..1 = dividend (in, preserved), +2..3 = divisor (in, preserved),
+4..5 = quotient (out), +6..7 = remainder (out). Needed for the perspective
divide. No divide-by-zero guard (no spare cycles in the inner loop): a zero
divisor yields quotient $FFFF — callers must avoid it (project does).rng — 16-bit Galois LFSR (tap mask $B400, full 65535 period). Returns the
next byte in A, advancing RNG_SEED by eight shifts per call so the returned
byte is fully churned. Boot seeds RNG_SEED to a non-zero value ($ACE1) — a zero
state is a fixed point that would lock the generator.sin / cos — angle in brad (0–255 = one full turn), result signed
−127..127 (Q0.7 fixed point). Backed by a full 256-entry signed table
(SINTAB, page-aligned at $FE00) — a branchless tax; lda SINTAB,x lookup
(~7 cycles), chosen over a mirrored quarter-wave for speed; cos(a) = sin(a + 64),
the +64 wrapping mod 256 for free.Built on mul16 / div16 / sin / cos, this is the CPU-side geometry pipeline that
feeds the GPU's wireframe primitives (gpu_dotlines, or gpu_dotline_clip for
off-screen-aware drawing). All arithmetic is fixed-point; coordinates land in the GPU
half-res space (X 0–199, Y 0–149), or signed-16 around it for the clipping variants.
(x, y, z), origin-centred, constrained to
x² + y² + z² ≤ 127² (|vertex| ≤ 127). Because rotation preserves length and each
rotated component is at most the vector's length, this guarantees every rotated
coordinate still fits a signed byte — so no 16-bit coordinates are needed through the
rotation stage. For a cube that means a half-side ≤ 73 (corner distance ≈ 126).Rotation uses Q0.7 trig: a coordinate × sin/cos is a signed 8×8 → 16 product
(mul_s8), and the rescale back to coordinate units is a rounded >> 7 (a +64
before the shift). Worst-case rotation error ≈ 4–5 half-res pixels — invisible on a
tumbling wireframe.
rot3d — rotate one point by Euler angles (ax, ay, az), applied in the fixed
order X, then Y, then Z, each a planar rotation of the form:
a' = round((a·cos − b·sin) >> 7)
b' = round((a·sin + b·cos) >> 7)
Layout: OS_ARG+0..2 = x, y, z (signed bytes, in → overwritten with x', y', z'
out); OS_ARG+3..5 = ax, ay, az (brad, preserved).
project — perspective-project a world point (x, y, z) to screen (sx, sy):sx = CX + (x · D) / (z + D) ; D = focal length / camera distance
sy = CY − (y · D) / (z + D) ; Y negated: model "up" = screen up
D, CX, CY are compile-time constants (PROJ_D = 128, PROJ_CX = 100,
PROJ_CY = 75), not runtime arguments — a fixed camera needs none, and "zoom" is done
by moving an object in Z. Layout: OS_ARG+0..1 / +2..3 / +4..5 = world x / y / z
(16-bit signed, in) → OS_ARG+0 = sx, +1 = sy (bytes), +2 = flag
(0 visible, $80 behind camera). A point with z + D ≤ 0 is rejected up front, so
the div16 denominator is always positive. The screen offset and numerator are
saturated so any out-of-range point clamps cleanly to a screen edge instead of
wrapping.
mesh_xform — the workhorse: transform a whole vertex list in one call.
Layout (OS_ARG): +0..1 vertex-array pointer, +2 count N, +3..5 angles,
+6..11 object world position (posX, posY, posZ) (16-bit signed each), +12..13
output pointer. It computes the six sin/cos values once for the whole mesh,
then per vertex rotates it, adds the object position, projects it, and writes a
[sx, sy, flag] triple (3 bytes) to the output. The caller then walks its face
list, culls back faces with face_facing, and emits the front faces' outlines with
gpu_dotlines. Doing the per-vertex loop in tuned ROM keeps the heavy math out of
every cartridge.
face_facing — backface test for hidden-line removal. Given three projected screen
points (OS_ARG+0..5 = x0,y0,x1,y1,x2,y2, half-res bytes), it returns
A = 1 (front, draw) or A = 0 (back / degenerate, cull) from the sign of the
screen-space cross product (x1−x0)(y2−y0) − (x2−x0)(y1−y0) (> 0 → CCW → front).
Internally a signed 16×16 → 32 multiply and 32-bit subtraction, because the cross
reaches ~±80 000. Correct hidden-line removal for convex solids; the caller winds
each face so its outward side comes out positive.
project / mesh_xform / face_facing / gpu_dotline all work in the
clamped half-res byte space (0–199 / 0–149). That is perfect while every
vertex is on-screen, but the moment a projected point leaves the frame the
clamp bends any edge touching it (the corner sticks to the screen edge), and
a point off the left/top — byte-wrapped through the unsigned API — clamps to
the opposite edge, flinging the line across the screen. For objects that may
spill past the frame (a wireframe pulled close, or translated off-screen), use
the off-screen-aware twins, which carry the true signed-16 screen coordinate
and clip the geometry instead of the coordinate:
project_raw ($FF8D) — same inputs as project, but the screen point is
returned unclamped, signed-16: OS_ARG+0..1 = sx, +2..3 = sy,
+4 = flag. The value may be negative or > 199. (Internally it is the exact
CX + offset that project computes right before its byte clamp; the offset
itself still saturates at ±255, bounding sx to roughly [−155, 355].)
mesh_xform_raw ($FF90) — identical arguments to mesh_xform, but each
output vertex is a 5-byte record [sx_lo, sx_hi, sy_lo, sy_hi, flag]
(signed-16) instead of the 3-byte [sx, sy, flag]. Size the output array
accordingly (5 × N bytes).
face_facing_raw ($FF96) — the winding test on signed-16 corners
(OS_ARG+0..11 = x0,y0,x1,y1,x2,y2, little-endian pairs). Same cross-product
and result as face_facing. Culling must use the raw coordinates when the
object can spill off-screen: clamped corners would flip a face's front/back
sign and the wireframe would flicker.
gpu_dotline_clip ($FF93) — the builder that ties it together. It takes
four signed-16 endpoints (OS_ARG+0..7 = X1,Y1,X2,Y2) and runs
Cohen–Sutherland clipping against the 0–199 / 0–149 rectangle: a line
fully inside is emitted verbatim, a line fully outside emits nothing (and
costs zero PPRAM), and a crossing line is trimmed to the true intersection with
the screen edge — slope preserved. It emits a normal DOT_LINE ($44) of
in-range bytes, so the GPU side is unchanged. (gpu_dotline_clip has no solid
or background variant; it is the wireframe-clipping path.)
gpu_dotpixels_clip ($FF99) — the point-cloud twin of gpu_dotpixels.
OS_ARG+0..1 point at a list { N, x0,y0, x1,y1, … } whose coordinates are
signed-16 half-res pairs (4 bytes per point, so 1 + 4·N bytes total). Each
point is dropped if it falls outside 0–199 / 0–149; the survivors are emitted
as half-res byte pairs in a DOT_PIXELS ($47) command whose count is the
number that survived (back-patched once known). If no point survives, nothing is
emitted. Use it for clouds that may drift off-screen (starfields, particles) so
the caller need not pre-filter — the off-screen points simply don't draw instead
of being clamped onto the screen edges.
The natural pipeline is therefore mesh_xform_raw → face_facing_raw (cull) →
gpu_dotline_clip per visible edge — the built-in cube demo uses exactly this.
One limitation: screen-space clipping assumes every vertex is in front of the
camera. An edge that crosses the eye plane (one endpoint with the behind-camera
flag) cannot be reconstructed in 2-D and should be skipped by the caller; true
near-plane clipping would have to happen in 3-D before projection.
Model format (caller-supplied, in RAM/ROM/cart): a vertex array (x,y,z signed
bytes, interleaved) and a face list (each face an ordered loop of vertex
indices, wound consistently). Edges are implied by the face loops — no separate edge
list is needed: draw each front face's outline and shared edges simply redraw
(harmless for dotted lines).
The camera is fixed at the origin looking down +Z and never moves or rotates — the
single biggest efficiency decision (a rotating camera would double the per-vertex cost).
"Camera dolly/strafe" is faked by translating every object the opposite way; "zoom" by
moving an object in Z (closer = bigger). The visible volume is a frustum: exactly the
200×150 screen at Z = 0, widening with depth (~400×300 at Z = 128). Usable depth is
roughly Z = −64 … +512; the eye/near wall is Z = −128 (nothing exists at or behind
it). Keep coordinates within ~±511 so the projection numerator stays 16-bit.
The pipeline is wireframe-oriented (matches the GPU's dotted-line strength): no Z-buffer, no filled-polygon rasteriser, backface culling only. See Open items.
Rough budget: the full per-vertex transform (
rot3d+ translate +project) costs ~2–2.5 k cycles, so ≈ 50–80 vertices/frame fit comfortably alongside game logic and drawing — a few simple objects, which is the wireframe aesthetic anyway.
A cartridge is identified by a signature at the very start of its first bank
(bank 0), in the cartridge window $8000:
$8000–$8004 "MAD65" 5 ASCII bytes (4D 41 44 36 35)
$8005–$8006 init vector 16-bit, little-endian — one-time setup entry
$8007–$8008 frame vector 16-bit, little-endian — per-frame routine (→ FRAME_VEC)
$8009–… cartridge code / data
JSR-ed once at boot, after the OS is fully initialised. The
cartridge sets up its own RAM/ZP ($80–$FF / $0400+), uploads sprites/tiles via
gpu_load, draws its initial background, etc.FRAME_VEC ($06). The OS loop JSRs it every
frame to build that frame's command list.cart_bank (A: bit7 = CART_EN, bits6–0 = bank 0–127) writes CART_BANK_REG ($BF60)
and updates CART_BANK_MIR ($09). The active 8 kB bank appears at $8000–$9FFF.
A cartridge maps its ≤1 MB across the window by switching banks; the OS makes no
assumption about a cartridge's internal layout beyond bank 0's signature.
Banking note: the bank register also gates whether
$8000–$9FFFis cartridge or RAM. WithCART_EN=0the window is RAM. The OS leaves the cartridge enabled on bank 0 after a successful signature match unless the cartridge switches it.
CART_BANK_REGis write-only (a register insidecpld_cpu1— the hardware can't read it back).CART_BANK_MIRis therefore the only record of the current bank. All bank changes go throughcart_bankso the shadow stays in sync — never write$BF60directly. This mirrors the GPU'sVIDEO_REG_SHADOWdiscipline.
cart_load)#cart_load copies a span of cartridge data into RAM, transparently crossing bank
boundaries. A cartridge keeps its assets (sprite/tile bitmaps, level data, tables) out
in its banks and pulls the pieces it needs into RAM, then pushes graphics to the GPU
with gpu_load.
Arguments (OS_ARG):
| Field | Bytes | Meaning |
|---|---|---|
SRC_BANK |
1 ($20) |
cartridge bank number (0–127) |
SRC_ADDR |
2 ($21–$22) |
16-bit window address ($8000–$9FFF) of the first byte |
DST |
2 ($23–$24) |
16-bit RAM destination |
LEN |
2 ($25–$26) |
16-bit byte count |
The source is a [bank : 8][address : 16] pair — the address is the literal
$8000–$9FFF window address as it appears in the memory map, so the caller points
directly at "bank N, $8xxx/$9xxx" with no offset arithmetic.
Behaviour:
cart_load enables the cartridge, selects SRC_BANK, and copies LEN bytes from
SRC_ADDR onward. When the window pointer would pass $9FFF it increments the bank
and wraps the pointer back to $8000, so a copy may span any number of banks.SRC_ADDR is expected to be inside the window ($8000–$9FFF); the copy walks
forward from there across bank boundaries until LEN bytes are done.cart_load saves CART_BANK_MIR
on entry and re-selects it on exit, so the rts lands back in the bank the cartridge
was executing from. Without this, returning into cart code from the wrong bank would
crash.⚠ Never switch the bank of code you are currently executing. Cartridge code lives in the
$8000–$9FFFwindow; only ROM-resident routines (cart_load, the rest of the OS) may change banks freely. A cartridge that spans multiple code banks must arrange its own trampolines —cart_loadonly guarantees safe banking for the data copy it performs.
Budget: cart_load is a blocking byte copy (a page-fast path is used internally).
Large transfers belong in cartridge init, not in the per-frame routine; streaming a
big asset during gameplay should be split into small per-frame chunks to stay within
the frame budget.
gpu_load_cart / _begin / _n)#cart_load lands cart data in CPU RAM. To get data into GPU memory (sprite
definition tables, tile bank, sprite/tile bitmaps) it must travel through PPRAM as a
LOAD command (gpu_load). These three helpers stream straight from the cartridge
into the LOAD command — no cart→RAM→PPRAM double copy — and manage the fact that
PPRAM only holds ~7 LOAD pages per frame.
The only CPU→GPU data path is PPRAM. There is no DMA or shared bus into GPU RAM; every byte reaches the GPU as
LOADpayload ($30+ dest page + 256 bytes = 258 PPRAM bytes/page). At ~7 pages/frame the whole 26 kB GPU graphics pool ($1000–$77FF) reloads in ~15 frames (~0.25 s) — a full asset swap is a sub-second loading screen.
gpu_load_cart ($FF9C) — emit one LOAD page. OS_ARG+0 = bank,
OS_ARG+1/+2 = window address, OS_ARG+3 = destination GPU page. The 256 data bytes
are read directly from the cart window (crossing $9FFF→next bank if needed) and the
caller's bank is saved/restored. Returns carry set if it didn't fit in the PPRAM
left this frame (normal builder convention). Use for one-off pages — e.g. re-uploading
a single edited definition page from a RAM shadow.
gpu_load_cart_begin ($FF9F) — arm a multi-page job. OS_ARG+0 = bank,
OS_ARG+1/+2 = window address of the first page, OS_ARG+3 = destination start
page, OS_ARG+4 = page count. Records the cursor in OS state and sets LOAD_REM;
emits nothing. A count of 0 leaves the job idle.
gpu_load_cart_n ($FFA2) — drain the armed job: load as many pages as fit in
the PPRAM remaining this frame, advance the cursor, update LOAD_REM. No
arguments. When the job is finished it does nothing (it never re-seeds — that is why
begin is separate, so you can call it every frame forever without it restarting).
LOAD_REM ($0240, published) is the page count still to load — 0 = done/idle.
Read it to gate a loading screen.
Usage — a non-blocking "LOADING…" loop the game drives:
; once, when entering the loading state — set OS_ARG (bank, addr, destStart, count):
jsr gpu_load_cart_begin
; then EVERY frame, inside gpu_begin … gpu_end (ideally just before gpu_end):
jsr gpu_load_cart_n
lda LOAD_REM
bne still_loading ; nonzero → draw your "LOADING…" screen; else proceed
Rules / rationale:
LOADs to the current frame's list and returns. The game keeps running and
draws its own loading screen; there is no frame-loop takeover and no WAI inside
the OS. Music (vgm_tick) and input keep ticking normally.gpu_end). The drain only fills the PPRAM your scene
left unused, so a busy frame loads fewer pages and a light frame loads more — and your
own draw commands are never dropped. (Drain first and a large scene could
overflow PPRAM and lose the game's commands.)LOAD_REM == 0 — a sprite referencing
data still mid-stream would render stale bytes.$0240–$0244), not OS_ARG, precisely because OS_ARG
is scratch every other builder (and your loading-screen gpu_text) overwrites within
the frame. gpu_load_cart is independent of an active _n job (it uses only
OS_SCRATCH), so one-off page loads won't disturb a running drain.gpu_load_cart_bg / _bg_n)#The loaders above target GPU RAM (sprite/tile pools, image pages) — a one-shot
LOAD is enough there. VRAM-background pages ($C0–$FF) are different: the layer is
double-buffered, so every write must reach the GPU on two consecutive frames or it
lands in just one of the two physical buffers and flickers (see the GPU OS doc's
two-frame rule). The plain gpu_load_cart* calls do not do that, so they must not be
pointed at background pages.
gpu_load_cart_bg ($FFA5) — the background twin of gpu_load_cart: emit one
LOAD page from the cart to a bg page (OS_ARG+0 = bank, +1/+2 = window address,
+3 = dest bg page) and capture a replay record so gpu_begin re-issues it next
frame into the other buffer. Carry set = PPRAM was full (nothing emitted/recorded).
gpu_load_cart_bg_n ($FFA8) — the background twin of gpu_load_cart_n: drain the
armed job into VRAM-bg, replaying each page automatically. Arm it the same way with
gpu_load_cart_begin (the begin is source-agnostic; the drain flavour — _n for
RAM/image, _bg_n for background — is what decides replay), then call every frame until
LOAD_REM == 0.
; once: OS_ARG = bank, addr, destStart ($C0 = top of screen), page count
jsr gpu_load_cart_begin
; every frame until done:
jsr gpu_load_cart_bg_n
lda LOAD_REM
bne still_loading
No RAM staging needed: a gpu_load to a bg page re-reads its source from CPU RAM on
replay (the "replay contract" — source must stay unchanged until the next gpu_begin),
but the cart variants re-read the immutable cartridge ROM, so bytes go straight from
the bank to VRAM-bg with no buffer.
Self-throttling. Each frame the previous frame's replay runs first (at gpu_begin)
and consumes PPRAM, so the drain simply loads fewer pages — no replay is ever dropped and
the per-frame page count stays far below the 41-record replay limit. A 400×300 screen is
59 pages and streams in ~20 frames behind a "LOADING…" screen. The hardware then copies
the finished background under the image layer for free every frame.
SEI + CLD disable interrupts, force binary mode
set stack pointer (S ← $FF) FIRST — SP is undefined at reset and the very
next step is a JSR, which needs a valid stack
LED_CPU_REG ← $01 POST stage 0
CPU_STATUS ← CPU_BOOTING ($A0) PPRAM $7800 + ZP mirror — the FIRST PPRAM write of
boot, deliberately BEFORE the ~1-frame shadow copy
(see "Sanitising the status handshake" below)
copy EPROM $C000–$FFFF → shadow RAM SC_PTR loop, LDA (SC_PTR),Y / STA (SC_PTR),Y — in
boot mode the read hits the EPROM and the write
hits the upper RAM at the same address
SHADOW_REG ← $01 ($BF70) set SHADOW_MODE=1 (bit 0). The EPROM is deselected;
execution continues from the byte-identical shadow
copy, and everything below runs with no wait states
LED_CPU_REG ← $03 POST stage 1: running from shadow RAM
jsr snd_init mute SN76489 ×2, YM2413 init
cart_bank ← $00 disable cartridge
init zero page (OS region $00–$7F)
CPU_STATUS ← CPU_BOOTING ($A0) write AGAIN after the ZP clear: re-establishes the
wiped ZP mirror and re-asserts the PPRAM byte (by
now on the other ping-pong chip — see below)
clear lower RAM $0200–$77FF, cartridge window $8000–$9FFF and upper RAM $A000–$BEFF (must happen BEFORE enabling the cart; note it stops at $BF00 and so never touches the shadow at $C000–$FFFF)
init OS state:
PPWP ← $7801, FRAME_COUNT ← 0, joystick shadows ← 0
RNG_SEED ← $ACE1 (any non-zero value — a zero seed locks the LFSR)
audio channel table cleared, PP_OVERFLOW/OVERRUN_FLAG ← 0, CART_BANK_MIR ← 0
LED_CPU_REG ← $07 POST stage 2: RAM ready
cart_bank ← $80 enable bank 0 (CART_EN=1, bank 0)
check $8000–$8004 == "MAD65"
match:
FRAME_VEC ← [$8007] cartridge per-frame routine
jsr [$8005] cartridge one-time init
no match:
cart_bank ← $00 disable cartridge ($8000–$9FFF back to RAM)
FRAME_VEC ← demo_frame
jsr demo_init
LED_CPU_REG ← $0F POST stage 3: program selected
wait for PPRAM[$7800] == GPU_READY poll until the GPU's frame loop is live — the first
command lists (and any one-shot LOADs in them) would
otherwise be sent to a GPU that isn't reading PPRAM
CLI enable VSYNC IRQ
jmp os_run enter the main frame loop (never returns)
CPU boot is faster than GPU boot (no triple VRAM clear). The selected program (cart or
demo) produces the first command list inside the loop; CPU_READY is first set by
the loop's gpu_end, not by boot.
PPRAM survives a warm reset, so after a reset both ping-pong chips still hold the
previous run's status bytes, and each core can only write the chip it currently owns.
Both cores therefore announce BOOTING immediately at boot entry (before their slow
shadow copies) and again after their ZP clear — the first pair of writes sanitises
both chips, the second restores the wiped ZP mirror. Without it, the boot-tail
wait for GPU_READY here false-triggers on a stale byte and CPU1's first command lists
— including one-shot LOADs such as a game's sprite-definition upload — are silently
lost. Full reasoning in MAD65_GPU_OS.md.
CPU1 is VSYNC-interrupt-driven, but — unlike the GPU — game logic does not run in
the interrupt. The IRQ handler does only mandatory housekeeping and returns
(RTI); per-frame logic runs in a WAI-synced main loop. (The GPU's ISR-is-
everything / never-RTI model would re-enter game logic mid-update on an overrun and
corrupt game state — the CPU avoids that by keeping the ISR thin.)
irq_stub:
push A / X / Y (65C02 auto-pushes only P and PC; A must be
saved before it can be used for the test below)
check the pushed P on the stack for the B flag — if set it is a BRK → fatal trap
inc FRAME_COUNT
jsr joy_read latch joysticks + edges
jsr audio_tick advance SFX one frame (SN76489 #2)
jsr vgm_tick advance VGM music one frame (SN76489 #1 + YM2413)
VSYNC_FLAG ← $01
pull A / X / Y
RTI ← returns to the instruction after WAI
os_run)#os_run:
WAI align to a VSYNC edge first, so the very first
build starts at the top of a frame
os_frame:
jsr gpu_begin FRAME_SP←stack watermark, VSYNC_FLAG←0,
PPWP←$7801, status←CPU_WORKING, PP_OVERFLOW←0
jsr (FRAME_VEC) ← the program's per-frame logic
os_frame_tail: ← the frame-abort path re-enters HERE
jsr gpu_end append WAI + status←CPU_READY, but ONLY if the
build stayed on one side of the ping-pong swap
if VSYNC_FLAG ≠ 0: OVERRUN_FLAG ← 1 a VSYNC fired mid-build — the GPU blinked
WAI sleep until next VSYNC IRQ
bra os_frame
(The background auto-replay runs inside gpu_begin — not as a separate os_run
step — so cartridges driving their own loop replay correctly too. Arming
VSYNC_FLAG moved into gpu_begin for the same reason: a cart-owned loop, and an
abort handler opening a fresh scene, both arm the detector by construction.)
WAI with interrupts enabled halts the CPU; the VSYNC IRQ wakes it, the handler runs
housekeeping, and RTI lands on the instruction after WAI → bra os_run. The stack
stays balanced (no SP reset needed — the opposite of the GPU).
The program's FRAME_VEC routine does the game's work: read joystick shadows, update
state, and emit the scene with the gpu_* builders. It must finish — and the loop
must reach gpu_end — before the next VSYNC.
BRK shares the IRQ vector on the 65C02. Since VSYNC is the only hardware IRQ source,
the handler checks the B flag in the pushed status byte: if set, it is a software
BRK (a bug in game/cart code — most often the CPU crashed into a region of $00
bytes), and the OS jumps to a fatal trap: interrupts are masked and an
alternating pattern ($AA/$55, ~2.5 Hz) flashes on LED_CPU_REG forever. No
error status is written to PPRAM — once the CPU stops delivering CPU_READY, the
GPU's own diagnostic mode trips after 64 silent frames and reports the failure on
screen. A stray BRK is never mistaken for a frame tick.
Frame budget ≈ 237,400 CPU cycles at 14.318 MHz / 60.317 Hz. The contract is hard:
Every frame, deliver a complete
WAI-terminated list and setCPU_READYbefore the next VSYNC.
If the game misses it, the GPU draws nothing that frame — a visible blink. The OS
does not paper over this (no last-frame save on the GPU — that would cost GPU
cycles the design refuses to spend). Avoiding the blink is the game developer's
responsibility: keep per-frame work within budget. To help, the OS exposes
OVERRUN_FLAG ($03) — so a debug build can detect "I blew the budget this
frame" and the developer can thin the scene. The flag is sticky: the OS never
clears it; a game that wants per-frame detection clears it after reading.
A blink is the intended cost of an overrun, but it isn't automatic: the ping-pong
swap is unconditional hardware, so on the VSYNC that catches a build mid-flight, the
CPU changes chips while PPWP keeps its address. Anything emitted after that lands
spliced into the list the GPU reads next frame — and the GPU can't tell: it
decodes the splice from a misaligned boundary, so argument bytes become opcodes and
the frame writes garbage into VRAM-background and the $BFE0 video registers. That's
a corrupted screen, not a blink, and it doesn't heal on its own. So the OS enforces
one invariant: a command list that straddles a swap is never published.
gpu_begin clears VSYNC_FLAG (arm) and records the caller's stack pointer in
FRAME_SP.pp_room — the single gate every builder passes before its first emitted byte —
refuses once VSYNC_FLAG is set, and sets OVERRUN_FLAG at that moment.gpu_end withholds CPU_READY (it writes CPU_WORKING instead, and does not
even lay down the WAI, since PPWP is pointing into the other buffer).With no handler registered this degenerates to a sink: every remaining builder drops its command exactly as if PPRAM were full, the game's render pass runs to its end producing nothing, and two frames go undrawn — the late one and the one whose list was the casualty.
ABORT_VEC ($0246, published, 2 B, $0000 = none) turns that into one. When
it is non-zero, pp_room restores the stack to FRAME_SP, JSRs through it, and
on return continues at os_frame_tail, so the frame closes normally. The handler is
an ordinary subroutine: it may call gpu_begin (which re-arms the detector, so its
own emissions do not abort) and build a fresh, cheap scene in the buffer the CPU now
owns; it may also simply RTS and let the frame be skipped. The bytes the abandoned
list left behind sit beyond the new list's WAI, where the GPU's dispatch loop
can never reach them.
Rules and limits:
RTS, and is only supported under os_run. A
cartridge running its own frame loop still gets the invariant (nothing incomplete
is published) but must leave ABORT_VEC at $0000.gpu_begin, so a *_bg command issued on that frame may need re-issuing.OVERRUN_FLAG is set at detection, before the handler runs, precisely so a
handler that clears VSYNC_FLAG (by calling gpu_begin) can still see why it was
called.Regression: roms/test_frame_abort.py.
This is a net, not a governor. VSYNC_FLAG only reports that the deadline has
already passed. A game whose scene weight varies also needs a predictive measure
it consults while building (its own transformed-vertex count; PPWP's distance is a
cheap GPU-load proxy but under-measures CPU load) so it sheds detail before the
deadline, not after. See the Software Developer's Guide, Chapter 23. A hardware
measure — a read-only frame-position counter at $BF80 — is proposed (not built) in
MAD65_future_features.md.
Runs when no cartridge signature is found (roms/cpu_demo.s). It
is the reference implementation of the API — jump-table calls only, nothing
privileged — and exercises every subsystem: a rotating wireframe cube through the full
off-screen-aware vector pipeline (mesh_xform_raw → face_facing_raw →
gpu_dotline_clip), a scrolling credits line, live joystick-arrow readout, a looping
VGM title track (flat-mode vgm_play, SN76489 #1 + YM2413) with a joystick-driven SFX
test running concurrently on the disjoint SN76489 #2 (round-robins tone voices 4–6 per
d-pad blip, adds a noise whoosh on voices 6+7 while FIRE/FIRE2 is held), and a
gpu_text_bg build-date stamp as a worked example of the two-frame replay contract.
Pinned by roms/test_psg2_demo.py (exact SN76489 #2 byte
stream).
$C000–$FFFF, 16 kB)#$C000–$DFFF DEMO (segment DEMO) the built-in demo and NOTHING else — demo_init,
demo_frame, demo assets, the VGM song LZ-packed
(demo_init expands it to $A000). 2 995 B used,
the rest filled $EA. The cartridge's RAM once one
boots (DEMO_RAM, ABI) — see the memory map.
$E000–$E002 JMP boot_main RESET stub
$E003–$E005 JMP irq_handler IRQ/BRK stub
$E006–$???? boot_main boot procedure
$????–$???? os_run + frame loop main loop, overrun check
$????–$???? gpu_* command builders one emit routine per GPU opcode + validation
$????–$???? audio snd_*, sfx engine (SN#2), ym_* (FM), audio_tick,
SN76489 note table (128 B), YM F-number table (24 B)
$????–$???? VGM player vgm_play / vgm_play_loop / vgm_stop / vgm_tick (SN#1 + YM)
$????–$???? speech spk_say / spk_stop / spk_tick, formant interpolation,
+ the 170 B phoneme table (spk_phon). 1 068 B total
$????–$???? joystick joy_read
$????–$???? math + vector/3D mul16, div16, rng, sin/cos, mul_s8, rot3d, project,
mesh_xform, face_facing, project_raw, mesh_xform_raw,
gpu_dotline_clip, gpu_line16_clip, face_facing_raw
$????–$???? demo demo_init, demo_frame, demo assets (+ the VGM song,
LZ-packed; demo_init expands it to $A000)
---- 186 B free ---- all that is left of $E000–$FDFF; CODE running
into SINTAB is a link error, which is the guard
$FE00–$FEFF sin/cos table 256 B full signed sine table (page-aligned, SINTAB)
$FF00–$FFEF OS API jump table ABI v1 — 80 entries × 3 B (frozen addresses)
$FFF0–$FFF9 unused (10 B spare — 3 more entries)
$FFFA–$FFFB NMI vector (unused — points to irq_stub)
$FFFC–$FFFD RESET vector → $C000
$FFFE–$FFFF IRQ/BRK vector → $C003
The two budgets to watch are the 186 free bytes and the 3 free jump slots, and
they are independent: a subsystem that fits in ROM can still be unreachable if the
ABI has no slot left for it. New entries are APPENDED so existing vectors keep
their addresses; an .assert on the table's length fires if one is inserted or
lost.
The two JMP stubs at the start of ROM give the hardware vectors fixed targets while
the real handlers live anywhere in the image. The jump table is page-aligned at
$FF00 and is the only stable entry surface for cartridges. Exact internal
boundaries are fixed by the assembler once the routines are implemented.
os_run) and reaches the
program via FRAME_VEC, but every service is also a standalone jump-table call — a
cartridge may run its own loop and use the OS purely as a library. The demo uses the
loop; carts choose.madsim, the Verilator sim or py65 — not from a board.rp_replay
clears RP_IDX unconditionally as it re-issues records, but if that frame's gpu_end
misses CPU_READY (overrun), the GPU draws nothing — so the replayed command reached
one buffer only and the record that could repair it is already gone. Reported from
CETAS (level 4: an indicator line blinks at 30 Hz on piranha-school + message overlap);
not yet reproduced under instrumentation, root cause (CPU spike vs. PPRAM exhaustion)
unconfirmed. Fix: have rp_replay keep the record count it consumed and let
os_run restore RP_IDX when it sets OVERRUN_FLAG, so records replay again next
on-time frame (~a dozen bytes, no ABI change). Workaround meanwhile: keep heavy spawns
off the frame carrying a message's replay.