MAD-65
Contents

MAD-65 CPU OS — Reference#

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 madsim and 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:


Memory Map (CPU1 perspective)#

$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

I/O page ($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_REG is 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).


CPU1 RAM Layout ($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.

Zero Page Layout ($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–$7F belongs to the OS (some published, some reserved). ZP $80–$FF is yours. OS_SCRATCH ($12–$1F) is volatile — assume any OS API call destroys it. The OS_ARG block ($20–$2F) is how multi-argument API calls receive their parameters.


Status Codes & Handshake (PPRAM $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.


The OS API — Jump Table (ABI v1)#

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. $FF12 and $FF18 held the removed gpu_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 and the five *_raw/*_clip entries all take true signed-16 screen coordinates that may be negative or past the right/bottom edge. The older project / mesh_xform / face_facing / gpu_dotline instead clamp to the 0–199 / 0–149 field, 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 at PPWP. 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

Calling convention#

OS_ARG layouts (frozen, ABI v1)#

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.)


PPRAM Command Builders#

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.

Frame bracket#

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.)

Validation policy#

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.

PPRAM overflow guard#

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?".

Two-frame background rule — auto-replay#

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:


Audio Subsystem#

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_beepnever 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.

Channels#

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.

Register helpers#

Note table & MIDI encoding#

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):

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.

SFX engine#

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 effectssfx_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.)

YM2413 / FM music API#

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 frequency encoding (F-number + block)#

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 = 3fnum = 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 — use ym_* for games without VGM music, and let the VGM player own the FM channels while a song plays.

Implementation notes#


VGM Music Player#

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.

API#

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).

Command subset#

Bytes Meaning Target
50 dd PSG write SN76489 #1 ($BF00) — the music PSG
51 aa dd register aadd 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)
707F 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.

Timing model#

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.

Bank-aware source#

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:

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_load interaction. A long cart_load and the IRQ-time vgm_tick both move the bank register. They compose because each saves/restores CART_SHADOW; keep cart_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_play also accepts a pre-stripped stream (commands + loop offset only), which is how the ROM-embedded demo song is stored.

State & interaction with SFX#

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.


Joystick API#

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.


Math / Utility Library#

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.


Vector / 3-D Graphics Library#

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.

Fixed-point & coordinate conventions#

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).

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.

Off-screen-aware variants (signed-16 + clipping)#

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:

The natural pipeline is therefore mesh_xform_rawface_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 world ("camera space")#

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.


Cartridge Interface#

Signature & vectors#

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

Banking#

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–$9FFF is cartridge or RAM. With CART_EN=0 the window is RAM. The OS leaves the cartridge enabled on bank 0 after a successful signature match unless the cartridge switches it.

CART_BANK is write-only (a register inside cpld_cpu1 — the hardware can't read it back). CART_SHADOW is therefore the only record of the current bank. All bank changes go through cart_bank so the shadow stays in sync — never write $BF60 directly. This mirrors the GPU's VIDEO_REG_SHADOW discipline.

Loading cartridge data → RAM (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:

Never switch the bank of code you are currently executing. Cartridge code lives in the $8000–$9FFF window; 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_load only 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.

Loading cartridge data → GPU (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 LOAD payload ($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.

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:

Loading a full-screen background straight from a bank (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.

; 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.


Boot Procedure#

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.

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

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.


Frame Loop & Sync Model#

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 handler (fixed in ROM, shared by every program)#

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

Main loop (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 WAIbra 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 handling#

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 & overrun#

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 set CPU_READY before 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.


Built-in Demo#

Status: the current demo (roms/cpu_demo.s) runs the full vector pipeline — a spinning wireframe cube (flying in from depth, FIRE pulls 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 −32 offset) — 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:


CPU1 ROM Layout ($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.


Notes & Open Items#