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:
$0000–$77FF RAM ~30.75 kB — zero page, stack, OS state, game data (lower)
$7800–$7FFF PPRAM ping-pong RAM 2 kB — GPU command list (CPU1 writes)
$8000–$9FFF Cartridge 8 kB banked window (or RAM when cartridge disabled)
$A000–$BEFF RAM ~7.75 kB — game data, buffers (upper)
$BF00–$BFFF I/O sound, joysticks, cartridge bank register, LED
$C000–$FFFF OS code 16 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 $C000–$FFFF, you would corrupt the running OS
$BF00–$BFFF)#$BF00–$BF0F AUDIO_SN1_REG WO SN76489 #1 (tone 0–2, noise 3)
$BF10–$BF1F AUDIO_SN2_REG WO SN76489 #2 (tone 4–6, noise 7)
$BF20–$BF2F AUDIO_AY_REG WO YM2413 register select (CPU A4=0 → A0=0)
$BF30–$BF3F AUDIO_AY_DATA WO YM2413 data write (CPU A4=1 → A0=1)
$BF40–$BF4F JOY_REG #1 RO joystick port 1 (active low)
$BF50–$BF5F JOY_REG #2 RO joystick port 2 (active low)
$BF60–$BF6F CART_BANK 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)
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 audio channel table, scratch tables
$0300–$03FF BG replay page 256 B two-frame background auto-replay:
flags + 41 six-byte command records
$0400–$77FF Free game RAM ~29.0 kB lower — 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 Free game RAM ~7.75 kB upper — 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.
$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 os_run
when a frame build starts (doubles as the overrun detector)
$03 OVERRUN_FLAG 1 B set when a frame missed CPU_READY before VSYNC
(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_SHADOW 1 B write-only mirror of CART_BANK ($BF60) — current bank + enable
$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.
$7800)#The first byte of PPRAM is the status byte, shared with the GPU. 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_SHADOW
$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,
then re-emits pending BG replay records (two-frame rule)
$FF0C gpu_end terminate list: append WAI, status←CPU_READY
$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)
ABI rule: the table is append-only — 60 entries,
$FF00–$FFB3, 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_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_text, gpu_text_bg, gpu_tile, gpu_tile_bg |
col | row | scroll | ptr lo | ptr hi |
gpu_load |
page MSB | ptr lo | ptr hi | — | — |
(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,
; + re-emit last frame's BG commands (auto-replay)
… emit commands …
jsr gpu_end ; append WAI ($00), status ← CPU_READY
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–35 (scroll masked with AND #$07) |
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) |
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.
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?".
Any builder that targets VRAM-background (gpu_text_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 — an early
"≥18 CPU cycles" figure had that inverted and is wrong wherever it still appears.)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 (ROM demo song, or
data the game placed in RAM). 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_SHADOW ; 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_SHADOW (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_SHADOW; 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 ROM-embedded demo song is stored.
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.
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 ($BF60)
and updates CART_SHADOW ($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_BANKis write-only (a register insidecpld_cpu1— the hardware can't read it back).CART_SHADOWis 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_SHADOW
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
Why no RAM staging. A gpu_load to a bg page also replays, but it re-reads its source
from CPU RAM next frame — so the source must stay unchanged until after the next
gpu_begin (the "replay contract"), forcing a RAM buffer. The cart variants re-read the
cartridge ROM, which is immutable, so there is no contract and no buffer: the bytes go
straight from the bank to VRAM-bg.
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_SHADOW ← 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
loop:
VSYNC_FLAG ← 0 arm the overrun detector for this frame
jsr gpu_begin PPWP←$7801, status←CPU_WORKING, PP_OVERFLOW←0
jsr (FRAME_VEC) ← the program's per-frame logic
jsr gpu_end append WAI, status←CPU_READY
if VSYNC_FLAG ≠ 0: OVERRUN_FLAG ← 1 a VSYNC fired mid-build — the GPU blinked
WAI sleep until next VSYNC IRQ
bra loop
(The background auto-replay runs inside gpu_begin — not as a separate os_run
step — so cartridges driving their own loop replay correctly too.)
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) — set whenever a VSYNC IRQ fired (VSYNC_FLAG went up)
before gpu_end completed — 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.
Status: the current demo (
roms/cpu_demo.s) runs the full vector pipeline — a spinning wireframe cube (flying in from depth,FIREpulls it closer) over a drifting 3-D starfield, a bottom-row scroller, on-screen joystick arrows, and the GPU's built-in sprite 0 which the player moves with the JOY1 d-pad. The sprite starts at screen pixel(0, 0)— the new signed-16 sprite origin (no−32offset) — and can be pushed off any edge, where the GPU clips it. A looping VGM title track plays throughout.
Runs when no cartridge signature is found. It is also the reference implementation of the API — it uses only jump-table calls, nothing privileged, so it doubles as worked example code. The demo may be visually modest (it shares the 16 kB ROM with the whole OS), but it shows every subsystem, including all three sound chips:
gpu_text and its per-cell scroll byte (smooth 8-step shift).mesh_xform_raw (→ rot3d + project to signed-16) transforms the 8
vertices, then each of the 6 faces is tested with face_facing_raw and every edge
bordering a front face is emitted once with gpu_dotline_clip (cheap wireframe +
hidden-line removal, clipped to the screen so a corner reaching past the frame
stays correct). Demonstrates the off-screen-aware vector library end to end.
(Wired into the demo.)JOY1 d-pad bits. It starts at screen pixel (0, 0) (the signed-16 sprite
origin) and may be driven off any edge, where gpu_sprite lets the GPU clip it.
Demonstrates the input path and the signed-16 sprite coordinates end to end.
(Wired into the demo.)vgm_play
in flat mode (VGM_BANK = $FF), driving SN76489 #1 + YM2413 (music). A
cartridge would instead pass its (bank, addr).FIRE-triggered zap via sfx_play plays on SN76489 #2 over the music —
no ducking, no interaction, because the effects chip is separate.CPU OS: YYMMDD at background row 1, cols 36–49, directly
under the GPU's own GPU OS: stamp on row 0. Emitted through gpu_text_bg
exactly once (a DEMO_STAMP_DONE latch), relying on the auto-replay to land it
in both buffers — a worked example of the two-frame rule and of the replay contract
(the string lives in ROM, so the stored pointer stays valid). BUILD_DATE is a
"YYMMDD" literal the Makefile regenerates into build_date.inc before every
assembly.$C000–$FFFF, 16 kB)#$C000–$C002 JMP reset_stub RESET → boot_main
$C003–$C005 JMP irq_stub IRQ/BRK → frame handler
$C006–$???? 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)
$????–$???? 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, face_facing_raw
$????–$???? demo demo_init, demo_frame, demo assets (+ embedded VGM song)
$FE00–$FEFF sin/cos table 256 B full signed sine table (page-aligned, SINTAB)
$FF00–$FFB3 OS API jump table ABI v1 — 60 entries × 3 B (frozen addresses)
$FFB4–$FFF9 unused (~70 B spare)
$FFFA–$FFFB NMI vector (unused — points to irq_stub)
$FFFC–$FFFD RESET vector → $C000
$FFFE–$FFFF IRQ/BRK vector → $C003
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 the records, but the
frame it re-issued them into is only shown if gpu_end raised CPU_READY before the
next VSYNC. If that frame was late (os_run sets OVERRUN_FLAG) the GPU draws nothing,
so the replayed command reached one buffer only — the exact flicker the replay engine
exists to prevent — and the record that could have repaired it is already gone.
os_run detects the overrun and does nothing with it.
Proposed fix: have rp_replay keep the record count it consumed, and let os_run
restore RP_IDX when it sets OVERRUN_FLAG, so the records replay again on the next
on-time frame. Roughly a dozen bytes, entirely inside the OS; no ABI change. The same
restore covers any other reason a replayed builder failed to emit (e.g. pp_room
refusing it on a frame that was already full).
Not yet reproduced under instrumentation: the reporter's frame meter showed no CPU
overload, which may just mean a single-frame spike averages away, and PPRAM exhaustion
(the list is 2 047 B and a 51-column TEXT_BG is ~56 B of it) is an untested
alternative trigger. Worth confirming by watching OVERRUN_FLAG ($03, sticky — the OS
never clears it) and PP_OVERFLOW ($08, re-armed per frame) from the cartridge before
changing anything. A game-side workaround exists (keep heavy spawns off the frame that
carries a message's replay), so this is not urgent.