MAD-65
Contents

MAD-65 — Monochrome Arcade Device#

Hardware Architecture — v6.0#


1. System Overview#

The MAD-65 console is built around two independent 65C02 processors running in parallel. Each has dedicated memory and a clearly defined role. Communication between them uses two 2 kB SRAMs operating in a ping-pong (double-buffer) scheme — one chip per CPU per frame, swapped each VSYNC. Design philosophy: maximum hardware simplicity, minimum component count, hobbyist-friendly (TTL/GAL, through-hole).

Three PCB design (v8):#


2. System Block Diagram#

A chip-level whole-system diagram is in MAD65_system_v6_blockdiagram.svg; the PCB2 video/VRAM detail is in MAD65_video_v6_blockdiagram.svg. ASCII summary:

┌──────────────────────────────────────────────────────────────────┐
│                          MAD-65 CONSOLE                          │
│                                                                  │
│  PCB1 ─────────────────────────┐  PCB2 ────────────────────────┐ │
│  │       CPU1 (main)           │  │       CPU2 (GPU)           │ │
│  │       65C02 @ 14.318 MHz    │  │       65C02 @ 14.318 MHz   │ │
│  │                             │  │                            │ │
│  │  - game logic               │  │  - sprite rendering        │ │
│  │  - joystick handling        │  │  - line drawing            │ │
│  │  - sound control            │  │  - tile rendering          │ │
│  │  - cartridge banking        │  │  - VRAM filling            │ │
│  │                             │  │                            │ │
│  │  RAM:  2× CY7C199-15PC(64kB)│  │  RAM:  1× CY7C199-15PC(32kB)│ │
│  │  ROM:  27C256/AT28C256 16kB │  │  ROM:  27C256/AT28C256 16kB│ │
│  │  CART: banked 8 kB (1 MB)   │  │  VRAM: 4× CY7C199-15PC(128kB)││
│  └────────────┬────────────────┘  └──────────────┬─────────────┘ │
│               │ /CS_SRAM_CPU1                    │               │
│          ┌────┴──────────────────────────────────┴─────┐         │
│          │    PING-PONG SHARED RAM  2× 2kB (PCB2)      │         │
│          │    SRAM_A ←swap→ SRAM_B each VSYNC          │         │
│          │    ($7800–$7FFF both CPUs, 1 frame latency) │         │
│          └─────────────────────────────────────────────┘         │
│                                                                  │
│  PCB1 ─────────────────┐         PCB2 ────────────────────────┐  │
│  │  2× SN76489         │         │   VIDEO CIRCUIT (CPLD)     │  │
│  │  1× YM2413          │         │   400×300 / 800×600 @60Hz  │  │
│  │  stereo mixer       │         │   3× ATF1508AS CPLD        │  │
│  │  [STEREO AUDIO OUT] │         │   + 74HC166/245 glue       │  │
│  │  [JOYSTICK ×2]      │         │   BLINDER + BG_REG         │  │
│  └─────────────────────┘         │   background copy          │  │
│                                  │   [VGA OUT]  [VSYNC IRQ]   │  │
│                                  └────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────┘
                                   IRQ (VSYNC) → CPU1 and CPU2

3. Processors#

Parameter CPU1 (main) CPU2 (GPU)
Model WDC W65C02S WDC W65C02S
Clock 14.318 MHz (shared crystal) 14.318 MHz (shared crystal)
RAM 32 kB (lower) + ~32 kB (upper) ~30.75 kB lower only ($0000–$77FF)
ROM 16 kB (system/OS/demo) 16 kB (firmware + graphics routines)
Cartridge yes (8 kB banked, 1 MB max) no
IRQ VSYNC VSYNC
Role game logic, I/O graphics generation

Clock note: WDC W65C02S is specified up to 14 MHz. The 14.318 MHz crystal (NTSC standard, widely available) is marginally over spec but works. The real memory constraint is the datasheet read window tACC = tCYC − tADS − tDSR ≈ 69.8 − 30 − 10 ≈ **30 ns** (the earlier "≤55 ns" figure dropped the tADS+tDSR terms and is wrong). At 14.318 MHz a single-cycle read therefore needs ≤~30 ns from address-valid to data, i.e. fast (~10–15 ns) SRAM plus a fast decoder — no 45–55 ns parallel ROM/EPROM meets it. All system RAM is CY7C199-15PC (32K×8, 15 ns), comfortably inside that window on its own, so the remaining constraint is purely the EPROM/cartridge. Two mitigations are used, and as of v10 both CPUs use both: each runs its code from a fast shadow code-RAM (GPU: a dedicated 2nd CY7C199-15PC, see §5; CPU1: the existing upper RAM chip, which already covered $C000–$FFFF — see §4), and each slow EPROM read gets three wait states (~239 ns of access time). Because the EPROM is only read during the boot copy, those wait states cost nothing at run time — total boot-to-first-frame is ~215 ms. The one path that keeps its wait states permanently is the cartridge, which is banked and so cannot be shadowed. Other slow-memory read paths similarly want fast SRAM or a wait state at full clock. (Alternatively, halving the CPU clock to 7.159 MHz relaxes the window to ~100 ns and lets slower parts run without wait states.)

Write timing — /WE = !(PHI2 & !R/W) (v16). Reads are the demanding direction, but writes have their own rule and it is not optional. On the 65xx bus the cycle boundary is PHI2's falling edge: address and R/W go valid tADS (~30 ns) after it, write data tMDS after it, and all are released only ~10 ns (tAH/tDHW) after the next falling edge. An async SRAM commits the addressed cell at the end of the write pulse — the rising edge of /WE — so /WE must be qualified with PHI2 to place that edge where the data is still valid. Every write strobe in the system now is: each board CPLD emits one /WE feeding that board's RAM, sound chips, VRAM and its PCB3 port, and cpld_ppr merely muxes the already-qualified strobes. (The video-side WE_COPY is exempt — it belongs to the 20 MHz pixel-clock domain, not the CPU bus.) Through v15 every /WE was raw R/W, which put the commit edge ~20 ns after the CPU had stopped driving and, on back-to-back pushes (JSR, IRQ/BRK/NMI), never released /WE between cycles at all. See the address-decoder doc §3a.

Clock sharing: CPU1 and CPU2 share one 14.318 MHz crystal — signal buffered through 74HC04. Despite the shared clock both CPUs are functionally asynchronous — the ping-pong shared RAM scheme guarantees exclusive access per frame without any arbitration logic. The video circuit has its own independent 20.000 MHz crystal (SVGA pixel clock).

Clock domain crossing (v14): The only cross-domain signal is VSYNC (20 MHz video domain → 14.318 MHz CPU domain), and it is synchronized once, on PCB2 — the board that generates it. cpld_video hands vsync_n to cpld_ctrl over a single on-board CPLD-to-CPLD track; cpld_ctrl clocks it through two flip-flops (VSYNC_S1VSYNC_S2) on CLK_14318 and inverts stage 2 into the active-low VSYNC_CPU_N. That one clean, CPU-clock-domain signal goes to all three boards: CPU2's /IRQ locally, CPU1's /IRQ over the connector to PCB1, and PCB3, where cpld_ppr recovers the frame edge with a single flip-flop (VSYNC_CPU_N_D) to toggle the registered SWAP_SEL. So the frame IRQ and the ping-pong swap still derive from one edge detector, and no board other than PCB2 contains a clock-domain crossing. No other CDC issues exist; double-buffered VRAM physically separates the two clock domains.

Why VSYNC_CPU_N and not IRQ_N. The net has two different meanings depending on who is reading it. On PCB1 and PCB2 it is genuinely an interrupt request — it wires straight to the CPU's /IRQ pin. On PCB3 it is nothing of the sort: cpld_ppr never interrupts anything, it just needs to know where a frame begins. Naming the wire after one consumer's use made the shared-RAM board look like it cared about interrupts. VSYNC_CPU_N names what the signal is — VSYNC, re-timed into the CPU clock domain — and leaves each consumer's use to show at the connection point. The _CPU suffix matches CLK_CPU/CLK_PIX elsewhere in the design.

Why v14 moved it. Through v13 the crossing lived on PCB3: raw VSYNC was cabled from PCB2 to PCB3, synchronized there, and the resulting /IRQ cabled back to both CPUs. PCB3 owns the shared-RAM mailbox, not the video timebase — it was hosting flip-flops on the other boards' behalf, and the round trip meant PCB2 could not raise its own frame interrupt without PCB3 populated and cabled, which is a pointless dependency for video bring-up. v14 also takes /IRQ off sync stage 2 rather than stage 1: the old IRQ_OUT_N = ~IRQ_S1 was effectively a one-flip-flop crossing, so a metastable sample could reach both CPUs' /IRQ pins. The extra stage costs one PHI2 (~70 ns) against a 16.6 ms frame. SWAP_SEL still toggles on the same clock edge as before — v8 spent 2 PHI2 reaching its edge-detect condition and 1 acting on it; v14 spends the first 2 in cpld_ctrl and the third in cpld_ppr. Net wiring change: two backplane signals (vsync_n out to PCB3, irq_out_n back) collapse into one (vsync_cpu_n, PCB2 → PCB1/PCB3), and raw VSYNC no longer leaves PCB2 except to the VGA connector.


4. Memory Map — CPU1 (main)#

$0000–$77FF   RAM        ~30.75 kB  (lower RAM — Zero Page, Stack, game data)
$7800–$7FFF   Shared RAM   2 kB    (Ping-Pong SRAM, active chip for this frame)
$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          256 B   (SN76489 ×2, YM2413, joysticks, cartridge bank register)
$C000–$FFFF   ROM         16 kB    (boot, init, OS API, demo)

Usable RAM: ~38.5 kB with the cartridge enabled (lower ~30.75 kB + upper ~7.75 kB); ~46.5 kB with the cartridge disabled ($8000–$9FFF returns as RAM).

Address decoding:#

NOT(A15) AND NOT(SRAM_SEL)   → lower RAM ($0000–$77FF, /CE from GAL /CS_RAM_LO)
NOT(A15) AND SRAM_SEL        → Shared RAM ($7800–$7FFF)
A15=1, A14=0, A13=0          → Cartridge ($8000–$9FFF, when CART_EN=1; else RAM)
A15=1, A14=0, A13=1          → upper RAM + I/O ($A000–$BFFF)
  $BF00–$BFFF                → I/O (GAL CPU1 decoder)
  remainder ($A000–$BEFF)    → upper RAM
A15=1, A14=1                 → ROM ($C000–$FFFF)

I/O page $BF00–$BFFF:#

$BF00–$BF0F   AUDIO_SN1_REG   (write)
$BF10–$BF1F   AUDIO_SN2_REG   (write)
$BF20–$BF2F   AUDIO_AY_REG    (write; CPU A4=0 → YM2413 A0=0)
$BF30–$BF3F   AUDIO_AY_DATA   (write; CPU A4=1 → YM2413 A0=1)
$BF40–$BF4F   JOY_REG #1      (read, polling)
$BF50–$BF5F   JOY_REG #2      (read, polling)
$BF60–$BF6F   CART_BANK       (write)
$BF70–$BF7F   SHADOW_REG      (write; bit 0 = SHADOW_MODE)
$BF80–$BFBF   (reserved I/O)
$BFC0–$BFDF   LED_CPU_REG     (optional module; LED_STRB from the CPLD since v11,
                              PHI1-gated since v16)
$BFE0–$BFFF   (reserved — mirrors the GPU's VIDEO_REG window)

v11: A7 is decoded at last, so $BF80–$BFFF no longer aliases onto $BF00–$BF7F. Through v10 a stray write to $BFE0–$BFFF would have clocked CART_BANK or latched SHADOW_MODE — a latent hazard no firmware ever hit. The reserved ranges are now genuinely free.

PCB1 register summary:#

Register Address R/W Description
AUDIO_SN1_REG $BF00–$BF0F WO SN76489 #1 — write data/register to PSG #1
AUDIO_SN2_REG $BF10–$BF1F WO SN76489 #2 — write data/register to PSG #2
AUDIO_AY_REG $BF20–$BF2F WO YM2413 — write internal register number (A4=0 → A0=0)
AUDIO_AY_DATA $BF30–$BF3F WO YM2413 — write data value to previously selected register (A4=1 → A0=1)
JOY_REG $BF40–$BF5F RO Read joystick port state (2× DE-9, active low; b0–b4 = UP/DOWN/LEFT/RIGHT/FIRE, b5 = FIRE2 on pin 9)
CART_BANK $BF60–$BF6F WO Cartridge bank register: bit 7=CART_EN, bits 6–0=BANK[6:0]
SHADOW_REG $BF70–$BF7F WO Shadow code-RAM: bit 0 = SHADOW_MODE (0 = boot, read the EPROM; 1 = run, read the shadow). Bits 7–1 reserved, write 0. Cleared by /RESET. v11 made this a real register bit — through v10 it was a one-way strobe. See "$C000–$FFFF overlay (CPU1)" below
LED_CPU_REG $BFC0–$BFDF WO POST diagnostic: 8 bits → 8 LEDs (optional module)

RAM — two CY7C199-15PC chips (2× 32 kB = 64 kB):#

CY7C199-15PC #1: $0000–$77FF — /CE from GAL CPU1 output /CS_RAM_LO
              (disabled at $7800–$7FFF to avoid conflict with Shared RAM)
CY7C199-15PC #2: $8000–$FFFF — /CE driven directly by A15 (chip selected across the
              whole upper half, so writes always reach RAM)
              /OE = !(RAM_RD_REGION & R/W) — GAL read-region output combined with
              R/W in one external gate (drives the bus only on reads not overlaid
              by ROM / active cartridge / I/O page)
              v10: this chip is ALSO the shadow code-RAM — its $C000–$FFFF cells
              serve the OS once SHADOW_MODE is latched (see the overlay below)

Lower RAM /CE driven by the CPLD (not 74HC04) to deselect at $7800–$7FFF (Shared RAM).

Upper RAM is overlaid by the EPROM, I/O and the cartridge window. Because its /CE is tied to !A15, a read in an overlay region would otherwise leave both the RAM and the overlay device driving the data bus. So cpld_cpu1 forms an active-high read-region term RAM_RD_REGION (A15 · !ROM_SEL · !CART_SEL · !BF_PAGE) and AND-s in R/W to drive the RAM /OE (/OE_RAM_HI = !(R/W & RAM_RD_REGION)), so the RAM sources the bus only on non-overlay reads. /CE is deliberately left = !A15 so that writes still land in RAM under the cartridge window (the cartridge is read-only ROM), across $A000–$BEFF, and — in boot mode — under the EPROM, which is what makes the shadow copy below work at all. See the address-decoder doc for the equations.

v11: all of this is internal to one chip now. Through v10 CPU1 needed two ATF22V10s: the decoder had no free input pin (all 12 taken by 11 address lines + CART_EN), so it could not see R/W or SHADOW_MODE, and the R/W-gated glue plus every mode-dependent term lived in a second GAL, gal_cpu1_ctrl. Both were 10/10 macrocells — PCB1 had no room left for anything. cpld_cpu1 (an ATF1508AS-10JU84, the part already used on PCB2/PCB3) replaced both, and absorbed the cartridge bank register and ÷4 audio divider as well: 14 → 10 ICs, with ~14 spare I/O and ~90 spare macrocells.

$C000–$FFFF overlay (CPU1) — EPROM / shadow code-RAM (v10):#

CPU1 runs its OS from a shadow code-RAM for the same reason the GPU does (§5): a 45–70 ns EPROM cannot meet the ~30 ns read window at 14.318 MHz. Unlike the GPU, PCB1 needs no extra chip — upper RAM (CY7C199-15PC #2) already spans $C000–$FFFF with /CE = A15 and already latched writes there; those cells were simply never read back, because RAM_RD_REGION excluded the ROM region. v10 turns that dormant write-through shadow into the real code store:

Boot mode (SHADOW_MODE=0):   READ  → EPROM 16 kB (CPU1 firmware, +3 wait states)
                             WRITE → upper RAM  (the shadow)
Run  mode (SHADOW_MODE=1):   READ  → upper RAM  (full speed, no wait states)
                             WRITE → upper RAM  (plain RAM)

Boot flow: the reset vector (mode 0) fetches from EPROM; cpu_os copies EPROM→shadow with a LDA (SC_PTR),Y / STA (SC_PTR),Y loop (read hits EPROM, write hits RAM at the same address), then writes $01 to SHADOW_REG ($BF70). The copy includes the reset/IRQ vectors, so shadow is byte-perfect and PC continuity across the switch is seamless. Nothing afterwards writes $C000–$FFFF (the boot RAM clear deliberately stops at $BF00), so the OS image is safe.

SHADOW_MODE is bit 0 of SHADOW_REG, a write-only register inside cpld_cpu1, async-cleared by /RESET. Reset value 0 is mandatory: the reset vector must fetch from EPROM. Only one bit lives in this register, so firmware needs no zero-page mirror — just write the literal. (The GPU's VIDEO_REG packs four bits and does need a mirror, which is exactly what made it fragile: the v9 work found a read-modify-write of that mirror could clobber SHADOW_MODE. Don't repeat that here.)

v11 made this a real register bit. Through v10 $BF70 was a one-way strobe — any write set SHADOW_MODE, and only /RESET cleared it — because gal_cpu1_ctrl had no data-bus pins, so a strobe-set latch was the only option that added none. The CPLD has D7–D0, so the EPROM can be re-selected after boot (e.g. to verify the shadow copy) at the cost of the "nothing can un-map the running OS" property the strobe gave for free.

Decode. ROM_SEL = A15 & A14 & !SHADOW_MODE carries the mode gate at its source, so RAM_RD_REGION = A15 & !ROM_SEL & !CART_SEL & !BF_PAGE becomes shadow-aware for free and OE_RAM_HI_N = !(R/W & RAM_RD_REGION) needs no special case:

Boot (SHADOW_MODE=0):  ROM_SEL=1 in $C000+  → RAM_RD_REGION=0 → EPROM drives reads
Run  (SHADOW_MODE=1):  ROM_SEL=0 in $C000+  → RAM_RD_REGION=1 → shadow drives reads

This was much uglier in v10, and the reason is worth recording. An ATF22V10's input pins are {1, 2–11, 13} (pin 12 is GND) and all 12 were consumed by the 11 address lines + CART_EN — PIN 1 included — so the decoder GAL could not see SHADOW_MODE at all. Every mode-dependent term had to live in gal_cpu1_ctrl: CS_ROM moved out of the decoder and RAM_HI_CE_N moved in purely to rebalance the macrocell count (an exact 20/20 across the pair), RAM_RD_REGION stayed shadow-unaware, and the ctrl GAL OR-ed the shadow region back in with OE_RAM_HI_N = !(R/W & (RAM_RD_REGION # (A15 & A14 & SHADOW_MODE))). v11 deleted the whole contortion. (v10 also fixed a long-standing pin-map bug — Open Issue 10 in the address-decoder doc — where revisions up to v5.2a assigned A4 to pin 12, which is GND, and therefore mislabelled PIN 1 as "spare". Believing that claim would have produced a pinout that cannot physically exist.)

Wait states (v10): cpld_cpu1 holds RDY low for 3 PHI2 on every EPROM or cartridge read (SLOW = R/W & (ROM_SEL # CART_SEL)), giving ~239 ns of access time — ample for any grade. It is free for the EPROM (boot-only once SHADOW_MODE is set). The R/W & term matters: neither ROM_SEL nor CART_SEL carries a R/W term, so both also assert on writes to those ranges (harmlessly — the EPROM's /OE is held high by N_RW and the write lands in the upper RAM underneath); without the gate every EPROM→shadow copy write would stall for nothing and double the copy. Measured cost: first frame moves from ~165 ms (v9: 1 wait state, no CPU1 shadow) to ~215 ms — all of it boot-time, with steady-state behaviour unchanged.

v11 removed a timing risk here. RDY is combinational, and through v10 the cartridge path crossed two GALs in series/CS_CART came from the decoder, so 30 ns (tADS) + 15 + 15 = 60 ns of a 69.8 ns cycle, leaving only ~10 ns for the W65C02S RDY setup. That is why bom.md demanded a -10PU part for gal_cpu1_ctrl. Inside one CPLD the whole path is 30 + 10 = 40 ns (~30 ns of margin) and the speed-grade requirement is gone.

Cartridge caveat: the cartridge is banked (8 kB window over 1 MB) and therefore cannot be shadowed, so cart_frame keeps its 3 wait states. A game whose inner loop must run at full speed should copy that code into RAM.

Cartridge banking:#

Boot sequence:#

0. RESET → SHADOW_MODE=0 → reset vector fetched from EPROM (+3 wait states)
0b. OS copies EPROM $C000–$FFFF → shadow RAM, then writes $01 to SHADOW_REG ($BF70)
    → from here the OS executes from RAM at full speed (POST LED stage 1)
1. RESET → CART_EN=0 → RAM at $8000–$9FFF
2. OS clears all RAM (including $8000–$9FFF; the clear stops at $BF00 and never
   touches the shadow at $C000–$FFFF)
3. OS enables bank 0: write $80 to CART_BANK ($BF60) → CART_EN=1
4. OS reads $8000 → checks for cartridge signature
5a. Signature OK  → cartridge present → jump to $8000
5b. Signature =$00 → no cartridge → write $00 to CART_BANK ($BF60) → CART_EN=0
                   → $8000–$9FFF returns as RAM → start demo

Note: RAM must be cleared BEFORE enabling cartridge (step 2→3).

5. Memory Map — CPU2 (GPU)#

$0000–$77FF   RAM         ~30.75 kB  (lower RAM — Zero Page, Stack, scratch, bitmaps)
$7800–$7FFF   Shared RAM    2 kB     (Ping-Pong SRAM, active chip for this frame)
$8000–$BFDF   VRAM image  ~14.65 kB  (active write buffer, hardware-switched)
$BFE0–$BFFF   VIDEO_REG (in the CPLD): BG_REG (0) + BLINDER (1) + COPY_DIS (2) + SHADOW_MODE (3)
$C000–$FFFF   Boot mode (SHADOW_MODE=0): read ROM 16 kB / write shadow code-RAM
              Run  mode (SHADOW_MODE=1): read shadow code-RAM / write VRAM-background

PCB2 register summary:#

Register Address R/W Description
VIDEO_REG $BFE0–$BFFF WO Video output control: BG_REG (bit 0), BLINDER (bit 1), COPY_DIS (bit 2), SHADOW_MODE (bit 3)
LED_GPU_REG $BFC0–$BFDF WO POST diagnostic: 8 bits → 8 LEDs. v15: LED_STRB from cpld_ctrl clocks an on-board 74HC574 (was specced as an external 74HC133 + 74HC374 module)

Fixed (GAL v5.1, 2026-06-16): the GPU decoder's VIDEO_REG_SEL term used to span $BF80–$BFFF (A7 alone), overlapping LED_GPU_REG at $BFC0–$BFDF — every LED write also corrupted VIDEO_REG. Now requires A7 & A6 & A5, matching $BFE0–$BFFF exactly. See MAD65_address_decoder_v6.md §3/§4 for the full history.

VIDEO_REG — bits:#

Bit Name Description
0 BG_REG Background pixel colour (bitmap=0): 0=black 0V, 1=dark grey 0.18V
1 BLINDER Pixel blinder (bitmap=1): 0=white visible 0.70V, 1=forced black 0V
2 COPY_DIS 0=normal VRAM-background→VRAM-image copy, 1=VRAM-image frozen
3 SHADOW_MODE 0=boot (read ROM / write shadow code-RAM), 1=run (read shadow / write VRAM-background). Set once at boot; keep set.
7–4 Unused

$C000–$FFFF overlay — ROM / shadow code-RAM / VRAM-background (v9):#

The GPU runs its code from a shadow code-RAM (a 2nd CY7C199-15PC, low 16 kB) so the CPU fetches from fast SRAM instead of a slow parallel ROM. SHADOW_MODE (VIDEO_REG bit 3, reset = 0) selects a three-way overlay at $C000–$FFFF:

Boot mode (SHADOW_MODE=0):   READ  → ROM 16 kB (GPU firmware, +3 wait states)
                             WRITE → shadow code-RAM
Run  mode (SHADOW_MODE=1):   READ  → shadow code-RAM (full speed, no wait states)
                             WRITE → VRAM-background 16 kB

Boot flow: the reset vector (mode 0) fetches from ROM; gpu_os copies ROM→shadow with a plain LDA $C000,X / STA $C000,X loop (read hits ROM, write hits shadow), then sets SHADOW_MODE=1. Because shadow is a byte-perfect copy of ROM (including the reset/IRQ vectors), PC continuity across the switch is seamless. From then on the CPU runs from shadow at full 14.318 MHz and $C000–$FFFF writes reach VRAM-background as before.

Decode (in cpld_ctrl since v13, gated by the internal shadow_mode = VIDEO_REG bit 3): - /CS_ROM active when R/W=1 and SHADOW_MODE=0 - /CS_SHADOW active when (R/W=0, SHADOW_MODE=0) or (R/W=1, SHADOW_MODE=1) - /CS_VRAM_BG active when R/W=0 and SHADOW_MODE=1 - ROM /OE connected directly to R/W; ROM /CE is SHADOW_MODE-gated, so run-mode reads see only the shadow chip (no bus conflict)

ROM wait states: cpld_ctrl holds RDY low for three PHI2 on each ROM read (boot mode only) so any parallel ROM grade comfortably meets the W65C02S read timing; run-mode shadow reads run with no wait states. (This exercised the CPU RDY pin for the first time and required gating the address register in w65c02.sv with RDY — see the RTL comment there.) v10 widened this from one wait state to three: since cs_rom_n is SHADOW_MODE-gated, ROM is only read during the boot copy, so the extra margin is free.

"Free background" mechanism:#

The video circuit, when displaying the second physical line of each pair (V[0]=1), instead of writing $00 to VRAM-image, copies the corresponding byte from VRAM-background to VRAM-image. If background byte = $00 → black pixel (same as before). If background byte ≠ $00 → background pixel appears in the next frame.

GPU draws objects over existing background → VRAM-image = background + objects
Video circuit displays VRAM-image
At V[0]=1: copies VRAM-background → VRAM-image (restores background)
Next frame: GPU draws on ready background again — for free

Developer loads background bitmap into VRAM-background of both buffers once at init. Background can be changed at any time by writing to $C000–$FFFF.

Physical VRAM organisation (per buffer):#

VRAM-image chip  (CY7C199-15PC, 32 kB):
  Video addresses: $0000–$3A97 (15,000 bytes of display data used)
  GPU writes via:  $8000–$BFDF → chip addresses $0000–$3FDF (16,352 bytes available; 15,000 used)
  Video reads from this chip → serial output via 74HC166
  Unused capacity: $3A98–$7FFF (~17.4 kB)

VRAM-background chip (CY7C199-15PC, 32 kB):
  GPU writes via:  $C000–$FFFF → chip addresses $4000–$7FFF (via A14=1 hardwired from video side)
  Video reads at V[0]=1 (addresses $4000–$7A97) → copies via 74HC157 MUX to VRAM-image
  Video background A14 hardwired to VCC (100 Ω) from video address bus
  Unused capacity: $7A98–$7FFF (~1.4 kB)

Total VRAM: 4× CY7C199-15PC (2 buffers × 2 chips each)
Same chip type as CPU1 RAM, GPU RAM, and ping-pong shared RAM — one component for all 10 SRAM positions.

GPU memory summary:#

RAM:               ~30.75 kB (lower only, $0000–$77FF — /CE from GAL)
Shared RAM:         2 kB    ($7800–$7FFF)
Shadow code-RAM:   32 kB chip, low 16 kB used ($C000–$FFFF; run-mode code memory)
VRAM image:        32 kB chip, 15,000 bytes used ($0000–$3A97)
VRAM background:   32 kB chip, 15,000 bytes used ($4000–$7A97)
ROM:               16 kB (boot only — copied to shadow at power-on)
Total VRAM chips:  4× CY7C199-15PC (same type as CPU1 RAM and GPU RAM)

RAM — one CY7C199-15PC chip (32 kB):#

CY7C199-15PC #1: $0000–$77FF — /CE from GAL GPU output /CS_RAM_LO
              (disabled at $7800–$7FFF to avoid conflict with Shared RAM)

Only lower RAM exists on GPU side. The entire $8000–$FFFF is occupied by VRAM-image, video register, ROM and VRAM-background — no upper RAM chip. Lower RAM /CE driven by GAL (not 74HC04) to deselect at $7800–$7FFF.

ROM GPU (16 kB, 27C256 / AT28C256):#

Background loading procedure:#

Developer loads background bitmap by writing to $C000–$FFFF (goes to VRAM-background). This must be done twice — once when buffer A is active, once when buffer B is active. After loading, background is persistent — GPU does not need to refresh it each frame.


6. Ping-Pong Shared RAM — 2× CY7C199-15PC (2 kB used, $7800–$7FFF both sides)#

v8 note. The whole ping-pong subsystem now lives on its own board, PCB3. Its control — SWAP_SEL, the per-chip /CE, the address//WE crossbar and the 4× 74HC245 enables — is one CPLD, cpld_ppr (rtl/pcb3/cpld_ppr.sv), which also absorbs the 6× 74HC157 crossbar (both CPU address buses enter it, both SRAM addresses leave it). PCB1 and PCB2 each cable into a PCB3 port; PCB2's decoder just emits cs_sram_gpu_n. The logic below is unchanged — only its board and its housing moved. PCB3 = 1 CPLD + 2 SRAM + 4× 245 = 7 ICs.

v14 note. The VSYNC→CPU IRQ synchroniser is no longer here. It moved to PCB2's cpld_ctrl, next to the cpld_video that generates VSYNC (see §3, Clock domain crossing). PCB3 receives the already-synchronous vsync_cpu_n and edge-detects it with one flip-flop to toggle SWAP_SEL — so raw VSYNC no longer reaches this board, and PCB2 can raise its own frame interrupt standalone. Two connector signals became one; cpld_ppr drops to ≈59/64 I/O. Note that nothing on PCB3 treats vsync_cpu_n as an interrupt; that is why the net is not called irq_n.

Overview#

Two SRAM chips (SRAM_A and SRAM_B) replace the IDT7132 dual-port RAM. Each frame, one chip belongs exclusively to CPU1 and the other to GPU. At every VSYNC the assignment swaps. No bus arbitration is required — bus conflicts are impossible by construction.

Chips: 2× CY7C199-15PC (32K×8, 15 ns) — the same part used everywhere else in the system. Only A10:0 (the low 2 kB) is decoded/routed; the chip's upper address lines are unused. All chips are physically on PCB3 (v8; was PCB2).

Ping-pong swap mechanism#

   PCB2 · cpld_ctrl                                 PCB3 · cpld_ppr
   ──────────────────────────────                   ─────────────────────────────────
   VSYNC ─► 2-stage sync ─► VSYNC_CPU_N ─┬────────► 1 FF edge detect ─► SWAP_SEL FF
   (20 MHz)  (CLK=14.318 MHz,            │ backplane  (falling edge)          │
              metastability)             │ — already in                       │
                                         │   this domain              SWAP_SEL / !SWAP_SEL
                                         ├─► CPU2 /IRQ  (local)               │
                                         └─► CPU1 /IRQ  (PCB1)     ┌──────────┴──────────┐
                                                                   │                     │
                                                              /CE_SRAM_A            /CE_SRAM_B

SWAP_SEL is a registered output of cpld_ppr, clocked by the 14.318 MHz CPU clock, and drives the ping-pong /CE routing inside that same chip. Both CPUs share one crystal, and that oscillator drives PHI2 directly, so the CPU clock is PHI2 and both CPUs' bus cycles are edge-aligned. Because SWAP_SEL can only change on a clock (PHI2) edge, the /CE_SRAM_A//CE_SRAM_B re-mapping always lands between bus cycles — the swap is deterministic and glitch-free, with no in-progress access ever straddling it.

VSYNC (from the 20 MHz video domain) is passed through a 2-stage synchronizer on PCB2 (§3), so what arrives here is vsync_cpu_n — a level, low for the whole VSYNC pulse, already in this clock domain. cpld_ppr therefore needs no metastability guard of its own: one flip-flop holds the previous sample and SWAP_SEL toggles once per falling edge of vsync_cpu_n (= VSYNC going active). /RESET clears SWAP_SEL to 0 for a deterministic power-on phase. Counting flip-flops from VSYNC, the swap lands on exactly the same clock edge it did in v8 — only the board boundary moved.

This replaces the earlier scheme (a 74HC123 monostable delaying VSYNC ~2 µs to clock an external 74HC74) — both parts are removed. The RC one-shot was only a statistical guard against mid-cycle swaps; the synchronous FF removes that timing uncertainty entirely. (Earlier v5 hardware placed this FF in "video GAL #4"; v6 folds it, and all other video logic, into the ATF1508AS CPLD; v8 moves it to PCB3's cpld_ppr, where it still lives — v14 moved only the synchronizer feeding it.)

Frame ownership#

SWAP_SEL = 0:                      SWAP_SEL = 1:
  CPU1 → SRAM_A  (write commands)    CPU1 → SRAM_B  (write commands)
  GPU  → SRAM_B  (read commands)     GPU  → SRAM_A  (read commands)

SWAP_SEL toggles each VSYNC. CPU1 always writes its command list into "its" chip throughout the frame. GPU reads the previous frame's commands from "its" chip. After the swap, GPU reads exactly what CPU1 wrote in the previous frame — clean, stable, with no polling or arbitration.

One-frame latency#

Commands written by CPU1 in frame N are available to GPU in frame N+1. At 60 Hz this is ~16.6 ms. This is by design:

Frame N:   CPU1 writes new scene → SRAM_A
           GPU  reads scene (from frame N-1) ← SRAM_B
VSYNC: swap
Frame N+1: CPU1 writes new scene → SRAM_B
           GPU  reads scene (from frame N) ← SRAM_A

The programmer should write the full command list for the upcoming frame, not incremental updates. GPU always renders the previous frame's scene, which is the standard "game logic prepares next frame while GPU draws current frame" model.

Bus architecture on PCB2 (v6 — concurrent per-chip crossbar)#

Both 2 kB SRAM chips reside on PCB2. Because ownership swaps every VSYNC, at any instant one chip belongs to CPU1 and the other to the GPU — and both masters may access their chip in the same bus cycle. So each chip gets its own address//WE crossbar and its own pair of data transceivers, steered by swap_sel:

CPU1 A10:0,/WE ─┐                          ┌─ GPU A10:0,/WE
                ├─ 3× 74HC157 (per chip) ──┤   y = swap_sel ? b : a
GPU  A10:0,/WE ─┘   → SRAM_A / SRAM_B addr └─ CPU1 A10:0,/WE

CPU1 D7:0 ◄─[connector]─► 74HC245(CPU1) ─┐
                                         ├─ SRAM_A DQ / SRAM_B DQ
GPU  D7:0 ◄────────────► 74HC245(GPU)  ──┘

(In sim: rtl/pcb2/pcb2.svu_pa*/u_pb* are the six 157s, u_245_ga/ca/gb/cb the four 245s.)

Chip select generation (GPU GAL)#

The GPU GAL v5 receives SWAP_SEL and /CS_SRAM_CPU1 (from PCB1 interconnect) as additional inputs and generates the two chip enable signals:

; Chip A selected when: CPU1 requests AND SWAP=0, OR GPU requests AND SWAP=1
/CE_SRAM_A = NOT( (CPU1_SRAM_ACTIVE AND NOT(SWAP_SEL)) OR (SRAM_SEL_GPU AND SWAP_SEL) )

; Chip B selected when: CPU1 requests AND SWAP=1, OR GPU requests AND SWAP=0
/CE_SRAM_B = NOT( (CPU1_SRAM_ACTIVE AND SWAP_SEL) OR (SRAM_SEL_GPU AND NOT(SWAP_SEL)) )

Where CPU1_SRAM_ACTIVE = NOT(/CS_SRAM_CPU1) (from PCB1 GAL output across interconnect) and SRAM_SEL_GPU = NOT(A15) AND A14 AND A13 AND A12 AND A11 (GPU's local decode).

7. VRAM and Double Buffering#

Chips:#

Logical resolution:#

Buffer switching:#

EVEN FRAME:                   ODD FRAME:
  Video circuit reads: VRAM_A   Video circuit reads: VRAM_B
  GPU writes to:       VRAM_B   GPU writes to:       VRAM_A

Switching is done by the SEL flip-flop inside the ATF1508AS CPLD, toggled at VSYNC (no external synchronizer or delay — the video circuit is in blanking at VSYNC, so nothing is reading VRAM and no guard is needed; contrast the shared-RAM swap_sel, which is re-timed to the CPU clock because the CPUs can be mid-access at any time). SEL also steers the VRAM address crossbar (8× 74HC157). GPU always sees the free (non-displayed) buffer at $8000–$BFDF.

VRAM clear / background copy:#

Each logical line is displayed twice (physical lines 2N and 2N+1, V[0] distinguishes). - V[0]=0 (first read): VRAM-image read only — data untouched - V[0]=1 (second read): VRAM-image read to the serialiser, then the VRAM-background byte is copied to VRAM-image. The copy is over the buffer's own shared bus — the background chip's /OE drives the bus and the image chip's /WE (WE_COPY, sequenced by the CPLD) latches it; no external data MUX is involved (v5 used a discrete 74HC157 for this).


8. Video Circuit — VGA Generator (ATF1508AS CPLD)#

Output mode: logical 400×300, physical 800×600 @ 60.317 Hz (standard SVGA 800×600)#

Each logical pixel = 50 ns horizontal (1 pixel clock at 20 MHz). Each logical line displayed twice (vertical 2× doubling, V[0] distinction). Total line = 528 pixel clocks at 20.000 MHz → 60.317 Hz frame rate. Full-screen image — no black border at top or bottom.

Clock: independent 20.000 MHz crystal (standard SVGA, separate from CPU clock)#

Video circuit functions:#

Since v12 the entire video state machine lives inside three ATF1508AS-10JU84 CPLDscpld_video (rtl/pcb2/cpld_video.sv, counters/sync/addr-gen/SEL/pixel path), cpld_ctrl (rtl/pcb2/cpld_ctrl.sv, VIDEO_REG/decode/VRAM control/245) and cpld_xbar (rtl/pcb2/cpld_xbar.sv, the VRAM address crossbar — split out of cpld_video in v12 to give it I/O margin). Everything the v5 design spread across ~16 discrete parts — the H/V counters, the four video GALs, the VRAM address adder, the VIDEO_REG, and the SEL flip-flop — is inlined there, and the CPLDs also replace the 8× 74HC157 VRAM crossbar (none re-added). The only discrete video parts left are the 2× 74HC245 data transceivers and 2× 74HC166 serialisers. What the CPLDs do internally:

  1. H counter (0–527) and V counter (0–627) — were 6× 74HC163
  2. HSYNC / BLANK_H_RIGHT (H≥400) / LOAD_SR trigger / H_RESET / BG_OE — was GAL #1
  3. VSYNC / BLANK_V_BOTTOM (V≥600) / V_RESET / IRQ_RAW / V_ACTIVE — was GAL #2
  4. LOAD_SR (H[2:0]==0) / BLANK / PIXEL_OUT / BG_OUT / VIDEO — was GAL #3
  5. /CE·/OE·/WE VRAM steering, WE_COPY, VSYNC→PHI2 synchroniser (2 stages, registered on CLK_14318 — back on this board since v14; SWAP_SEL left for PCB3 in v8) — was GAL #4 + the 74HC74
  6. VRAM address generator addr = V[9:1]×50 + H[9:3] — was 5× 74HC283
  7. VIDEO_REG COPY_DIS(2) + BLINDER(1) + BG_REG(0) — was a 74HC374
  8. SEL double-buffer select (VSYNC-toggled) — was a 74HC74

Discrete video-path chips that remain on the board (structural, not logic):

(The VRAM address crossbar A/B-addr = SEL ? gpu : video is not discrete — it is the cpld_xbar CPLD since v12; through v6 it was 8× 74HC157.)

Pixel serialisation:#

The 74HC166 shift register is clocked by the full 20 MHz pixel clock. LOAD_SR fires every 8 pixel clocks (H[2:0]==0). Each bit from the shift register is output for exactly 1 pixel clock (50 ns) — 400 logical pixels per line. 50 bytes/line × 8 clocks/byte = 400 active pixel clocks → 400 logical pixels/line ✓

BLINDER and BG_REG registers:#

GPU can change BLINDER and BG_REG at any point in the frame (raster effects).

PIXEL_OUT = QH AND NOT(BLANK) AND NOT(BLINDER)
BG_OUT    = BG_REG AND NOT(QH) AND NOT(BLANK)
VIDEO     = PIXEL_OUT OR BG_OUT
BLINDER BG_REG bitmap=1 bitmap=0
0 0 0.70 V 0.00 V
0 1 0.70 V 0.18 V (grey)
1 0 0.00 V 0.00 V
1 1 0.00 V 0.18 V (grey)

BLANK area always 0V — VGA compliant.

COPY_DIS (bit 2): when set, WE_COPY (in the CPLD) is inhibited — VRAM-image is NOT modified by the video circuit at V[0]=1. GPU retains full control of VRAM-image content across multiple frames. Background data in VRAM-background is preserved. Recommended use: set during VBLANK; also write 0 during VBLANK to resume.

Background copy timing:#

V[0]=0 — first physical line of pair:
  OE VRAM-image → data to 74HC166 (LOAD_SR)
  no write — data in VRAM-image untouched

V[0]=1 — second physical line of pair:
  OE VRAM-image → data to 74HC166 (identical to V[0]=0)
  BG_OE (CPLD) → VRAM-background byte drives the buffer bus
  WE_COPY (CPLD) → background byte latched into VRAM-image

VSYNC → IRQ:#

The VSYNC level (20 MHz domain) is synchronised by two flip-flops in cpld_ctrl (clocked by CLK_14318); the result, VSYNC_CPU_N, drives /IRQ on both CPUs. On PCB2 since v14, on PCB3 through v13. See §3, Clock domain crossing.


9. Sound — 2× SN76489 + 1× YM2413#

SN76489 (PSG — Programmable Sound Generator)#

Each SN76489 provides: - 3 tone channels (square wave) - 1 noise channel

Total PSG: 6 tone channels + 2 noise channels

YM2413 (OPLL — FM sound)#

The YM2413 provides: - 9 FM melodic channels (or 6 melodic + 5 rhythm) - Built-in instrument ROM (15 presets + user patch) - Stereo output: MO pin (sum of all channels) + RO pin (rhythm channels only)

Audio clock: 3.5795 MHz (14.318 MHz ÷ 4)#

Full CPU frequency (14.318 MHz) exceeds the SN76489 maximum CLK spec of 4 MHz — a ÷4 divider is required. YM2413 nominal clock is 3.58 MHz (NTSC standard). Both chip types share the same ÷4 derived clock — no separate crystal needed for the YM2413.

÷4 divider (v11): a 2-bit counter inside cpld_cpu1, clocked by PHI2 on the CPLD's dedicated GCLK1 pin and async-cleared by /RESET. CLK_AUDIO is bit 1 of that counter:

PHI2 14.318 MHz → [CPLD ÷4 counter] → 3.5795 MHz CLK_AUDIO → CLK (SN76489 ×2), XIN (YM2413)

Through v10 this was two cascaded 74HC74 D flip-flops, each wired as a ÷2 toggle (D = /Q): FF1 used the spare half of the CART_EN package, FF2 needed a whole additional 74HC74. With CART_EN absorbed into the CPLD too, both 74HC74 packages are gone.

Note the audio chips straddle two domains: CLK/XIN come from CLK_AUDIO, while the bus side (/CE, /WE, data) stays in the PHI2 domain.

YM2413 XIN pin receives the 3.5795 MHz square wave from the ÷4 output. XOUT pin must be left unconnected (no crystal or capacitor needed when driving XIN directly).

Address map#

$BF00–$BF0F   AUDIO_SN1_REG  (write)
$BF10–$BF1F   AUDIO_SN2_REG  (write)
$BF20–$BF2F   AUDIO_AY_REG   (write; CPU A4=0 → YM2413 A0=0)
$BF30–$BF3F   AUDIO_AY_DATA  (write; CPU A4=1 → YM2413 A0=1)

Single GAL output /CS_YM covers $BF20–$BF3F; CPU address bit A4 wired directly to YM2413 A0 pin.

Stereo output mixing#

SN76489 #1 output ──┐
                    ├── resistor sum ──► L channel (centre)
SN76489 #2 output ──┘                   R channel (centre, same signal)

YM2413 MO pin ──► L channel rail (FM melodic + rhythm mix)
YM2413 RO pin ──► R channel rail (FM rhythm only — optional split)

Each source through 1 kΩ mixing resistor; DC-blocking capacitor on each output. SN76489 #1+#2 mixed to mono, fed to both L and R → perceived as centre. YM2413 can be wired MO→L, MO→R (mono centre) or MO→L, RO→R (stereo split).

CPU1 controls all audio at $BF00–$BF3F.

YM2413 output filter — datasheet errata#

The Yamaha YM2413 datasheet contains an error in the recommended output filter capacitor value. The application circuit specifies a large electrolytic capacitor (100 µF in some revisions of the datasheet) on the MO and RO output pins. The correct value for the DC-blocking capacitor is 10 µF (electrolytic, polarised towards output). Using the datasheet value causes excessive low-frequency emphasis and distorted bass response. Reference: YM2413 errata documented by reverse engineering community; confirmed against actual MSX hardware (Panasonic FS-A1, Yamaha CX5M) which use 10 µF.

YM2413 MO pin ──► 10 µF (DC block) ──► 1 kΩ (mix resistor) ──► audio L rail
YM2413 RO pin ──► 10 µF (DC block) ──► 1 kΩ (mix resistor) ──► audio R rail

Also add a 100 nF ceramic capacitor from each output pin to GND for high-frequency noise suppression (not specified in datasheet but present in reference hardware).

YM2413 initialisation sequence#

The YM2413 requires software initialisation after reset:

; Minimum YM2413 init (CPU1 ROM, called at startup)
; Write 0 to all registers to silence outputs and set known state.
;
; Timing (CORRECTED): the YM2413 runs at 3.5795 MHz — one QUARTER of the
; 14.318 MHz CPU clock — so a wait specified in YM clocks is MULTIPLIED by 4
; when counted in CPU cycles, not divided. Per the datasheet the chip needs
; ≥12 YM clocks after a register-select write (≈ 48 CPU cycles) and ≥84 YM
; clocks after a data write (≈ 336 CPU cycles). A few NOPs are far too
; short — use small busy-wait loops. (An earlier revision of this section
; claimed "~18 CPU cycles"; that inverted the clock ratio.)
;
; Also note: the data write must be a LITERAL 0 (LDA #$00 / STA) — writing
; the register number back as data (STX $BF30) would program garbage patches.

ym_init:
    LDX #$00
loop:
    STX $BF20       ; select register X (address write, A0=0)
    LDY #8          ; ≥12 YM clocks ≈ 48 CPU cycles (5×8+overhead)
d1: DEY
    BNE d1
    LDA #$00
    STA $BF30       ; write data 0 to register X (data write, A0=1)
    LDY #66         ; ≥84 YM clocks ≈ 336 CPU cycles (5×66+overhead)
d2: DEY
    BNE d2
    INX
    CPX #$40        ; registers $00–$3F
    BNE loop

    ; Select melody mode (disable rhythm): write $00 to register $0E
    LDX #$0E
    STX $BF20
    LDY #8
d3: DEY
    BNE d3
    LDA #$00
    STA $BF30       ; rhythm mode off, all rhythm instruments off
    RTS

The CPU OS implements exactly this discipline in its ym_write helper (roms/cpu_os.s), which paces every YM2413 access. The SN76489s need the same care: each write takes ~32 chip clocks (≈ 128 CPU cycles) to absorb, and their READY pins are not wired to stall the CPU on MAD-65 — the CPU OS's sn_write helper paces those in software too.

Important: do not write to YM2413 before the init sequence. Internal state after power-on is undefined — uninitialised channels may produce noise or stuck tones.


10. Joystick Ports#

bit b7 b6 b5 b4 b3 b2 b1 b0
line FIRE2 FIRE RIGHT LEFT DOWN UP
DE-9 pin 9 6 4 3 2 1

Bits b0–b4 keep their original positions, so single-fire software is unaffected; the previously-floating b5 now carries FIRE2. Bits b6–b7 remain unwired (read high). DE-9 pins 7/8 are +5 V / GND.


11. System Startup Sequence#

Status codes (PPRAM $7800)#

Both CPUs signal their boot state via the first byte of their respective Shared RAM chip.

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

Boot procedures#

Both boot procedures are firmware, and the step-by-step listings live with the firmware they describe — MAD65_GPU_OS.md (GPU Boot Procedure) and MAD65_CPU_OS.md (Boot Procedure). They are not duplicated here, because they change with the ROMs. What matters at the hardware level:

Startup timeline#

t=0 ms     RESET deasserted (both CPUs simultaneously, MCP100-450)
t=0–?      CPU1: shadow copy, RAM clear, cartridge check, waits for GPU_READY
t=0–~130   GPU: shadow copy, RAM clear, VRAM-background clear loop → GPU_READY
t=~130+    Normal operation: VSYNC ISR running on GPU, VSYNC IRQ on CPU1

At startup SWAP_SEL=0 (/CLR connected to /RESET): CPU1 owns SRAM_A, GPU owns SRAM_B. GPU's VSYNC ISR checks CPU_READY on every frame; increments NO_CPU_COUNT if not set. After 64 consecutive frames without CPU_READY (~1 s), GPU enters diagnostic mode (see GPU OS document, Diagnostic Mode section).


12. CPU1 ↔ CPU2 Synchronisation (via Ping-Pong Shared RAM)#

CPU1 (each frame ~16.6 ms):            CPU2/GPU (each frame):
  1. VSYNC IRQ → start of frame          1. VSYNC IRQ → start of frame
  2. Read joysticks (polling)            2. SWAP has occurred: GPU now owns
  3. Run game logic                         the chip CPU1 wrote last frame
  4. Write command list to current       3. Read full command list from
     Shared RAM chip ($7800)                Shared RAM ($7800)
  5. Set CPU_READY ($A2) at $7800        4. Execute commands → draw to VRAM
  6. Handle sound (SN76489 / YM2413)     5. Write VIDEO_REG if needed
  7. Wait for next VSYNC                    ($BFE0: BLINDER, BG_REG, COPY_DIS)
                                         6. Set GPU_READY ($B2) at $7800
                                         7. Wait for next VSYNC

The status byte at the start of PPRAM ($7800) is the single handshake byte in each chip — CPU1 writes CPU_READY there, the GPU writes GPU_READY there. There is no separate "DONE" flag and no $7810.

On the CPU1 side, steps 2 (read joysticks) and 6 (handle sound) are run as VSYNC IRQ-handler housekeeping (joy_read + audio_tick), while game logic and command-list building (steps 3–5) run in the main loop. See MAD65_CPU_OS.md (Frame Loop & Sync Model).

Important timing notes:

VIDEO_REG ($BFE0) protocol:#

GPU writes BG_REG, BLINDER, and COPY_DIS directly to VIDEO_REG ($BFE0, latched inside the CPLD). No CPU1 involvement required — purely GPU-controlled display parameters. Write during VBLANK for clean frame-level changes; write during active display for mid-frame raster effects (visible "cut" line — intentional).

COPY_DIS=1 suspends background copy mechanism — VRAM-image frozen until COPY_DIS=0. Use case: GPU needs multiple frames to complete a drawing operation without the video circuit overwriting VRAM-image with background data on every other line.


13. GPU Performance Estimates#

Frame budget: 14,318,000 / 60.317 ≈ 237,400 CPU cycles @ 14.318 MHz

Interrupt handling (frame_isr reset + PPRAM status check): ~80 cycles.
Effective working budget: ~237,300 cycles.

Dispatch overhead#

Every opcode passes through dispatch_loop before its handler runs. This includes reading the opcode byte via read_byte_ppwp and walking a linear CMP chain:

Operation costs (measured from implemented ROM code)#

All numbers include dispatch overhead. "Steps" below refer to half-resolution Bresenham steps (each half-res step = 2 full-res pixels on the major axis).

Operation Cycles (approx) Max per frame
PIXEL / PIXEL_BG ~276 ~860 pixels
H-line fast path (lc_hline, 100 full-res px) ~700 ~340 lines
V-line fast path (lc_vline, 100 full-res px) ~2,900 ~82 lines
LINE diagonal, 50 steps (~70 px, 45°) ~6,600 ~36 lines
LINE diagonal, 150 steps (~210 px, 45°) ~19,000 ~12 lines
LINE near-horizontal, 50 steps ~3,400 ~70 lines
DOT_LINE diagonal, 50 steps ~3,580 ~66 lines
DOT_LINE diagonal, 150 steps ~9,980 ~24 lines
DOT_LINES chain, 10 segments × 50 steps ~32,500 ~7 chains
DOT_CIRCLE, R≈25 (~18 octant steps, on-screen) ~5,400 ~44 circles
DOT_CIRCLE, R≈50 (~35 octant steps, on-screen) ~10,500 ~22 circles
DOT_CIRCLE, R≈50 (edge-clipped, slower path) ~14,000 ~17 circles
LOAD (256-byte page transfer) ~4,250 ~56 blocks
CLEAR_BG (16 kB, 8× unrolled) ~138,000 1 (58% of frame)
SPRITE 8×8 (overlay, shifted) ~1,400 ~165
SPRITE 16×16 (overlay, shifted) ~4,300 ~55
SPRITE 32×32 (overlay, shifted) ~15,000 ~15
SPRITE 32×32 (overlay, byte-aligned, N=0) ~8,800 ~26
SPRITE 32×32 (no overlay, shifted) ~9,600 ~24
SPRITE 64×32 (overlay, shifted) ~22,000 ~10
SPRITE 64×32 (overlay, byte-aligned, N=0) ~14,000 ~16
SPRITE 64-wide (overlay, shifted) ~700/row
TILE cell 8×8 (scroll=0, fused fast path) ~320 ~740 cells
TILE row, 50 cells (scroll=0) ~18,000 ~13 rows
TILE row, 50 cells (scroll>0, buffer+shift) ~30,000 ~7 rows

Notes on line performance#

Notes on sprite performance#

Sprite cost is dominated by the per-row work (load → shift → composite), so it scales with height (the row count, an independent 1–255 parameter) and, for shifted sprites, with the shift pass count. The numbers above are estimates from the implemented blitter structure (spr_blit_*), not profiler-measured.

Per-frame budget examples#

Scene Approx cycles % of budget
100 scattered pixels ~27,600 12%
30 H-lines (100px each) ~21,000 9%
40 DOT_LINE diagonals (50 steps) ~143,200 60%
CLEAR_BG + 20 DOT_LINE diagonals (50 steps) ~209,600 88%
CLEAR_BG + 10 DOT_LINES chains (10 seg × 50 steps) ~138,000 + ~32,500 72%
20 LOAD blocks (sprite/tile upload, no drawing) ~85,000 36%

Conclusion: Axis-aligned lines (H/V fast paths) and DOT_LINE are the most cycle-efficient drawing primitives. Solid diagonal LINEs are expensive at scale — prefer DOT_LINE for wireframe geometry. Sprites are now implemented (eight size×overlay-specialised blitters: 8/16/32/64 px wide × overlay/no-overlay) and are the primary path for moving objects; budget by height and overlay (e.g. ~15 large 32×32 overlay sprites, or ~55 16×16, per frame; a 64-px-wide overlay row is ~700 cycles, so e.g. a 64×32 object ≈ ~10/frame). Tiles are now implemented (op_tile/op_tile_bg, a line-wise renderer with a fused scroll=0 fast path) and cover dense static backgrounds — roughly 13 full 50-cell rows per frame at scroll=0.


14. GPU Graphics Data Formats#

All graphic operations are requested by CPU1 via the PPRAM instruction list. The GPU OS ROM implements each opcode. Full definitions — bitmap formats, coordinate systems, blitter behaviour, PPRAM protocol — are in MAD65_GPU_OS.md. The CPU1 side — the OS API, PPRAM command builders that emit these opcodes, audio/joystick/math helpers, the cartridge interface and the frame loop — is documented in MAD65_CPU_OS.md.

Graphics objects — quick reference#

Object Count Size Location Notes
Tiles 256 8 B each = 2 kB RAM $0700–$0EFF Uploaded via LOAD ($30); tile $00 reserved as sentinel
Font glyphs 96 (ASCII $20–$7F) 8 B each = 768 B ROM $FC00–$FEFF Read-only; Marble Madness font; 5 codepoints repurposed as symbols
Sprites 256 (SPR_ID 0–255) variable (1–255 rows × 8/16/32/64 px) definition table $0300–$06FF; data from $1000 Uploaded via LOAD ($30); clipped on all edges
Default sprite 0 1 32×32 px overlay, 256 B ROM $F400–$F4FF Installed by boot_main; overwritable

All bitmap data: 1 bpp, MSB = leftmost pixel, one byte per row. Tiles and font glyphs share the same 8-byte-per-row format. Sprite data has an optional second overlay plane (1 = force black on top of bitmap).

Coordinate systems#

GPU instruction set#

Opcode Mnemonic Arguments
$00 WAI — (end of list)
$08 GPU_LED pattern
$10 COPY_DIS_ON
$11 COPY_DIS_OFF
$12 BG_REG_ON
$13 BG_REG_OFF
$14 BLINDER_ON
$15 BLINDER_OFF
$20 CLEAR_BG — (two consecutive frames required)
$30 LOAD page_MSB, 256 data bytes
$40 PIXEL X_LSB, X_MSB, Y_LSB, Y_MSB
$41 PIXEL_BG X_LSB, X_MSB, Y_LSB, Y_MSB
$42 LINE X1, Y1, X2, Y2 (half-res)
$43 LINE_BG X1, Y1, X2, Y2
$44 DOT_LINE X1, Y1, X2, Y2
$45 DOT_LINES N, x0,y0, x1,y1, …, xN,yN
$46 DOT_PIXEL X, Y (half-res, doubled internally)
$47 DOT_PIXELS N, x0,y0, x1,y1, …, xN-1,yN-1
$48 DOT_CIRCLE CX, CY, R (half-res; centre must be on-screen)
$50 SPRITE SPR_ID, X_LSB, X_MSB, Y_LSB, Y_MSB
$60 TEXT X(0–49), Y(0–35), scroll(0–7), …chars…, $00
$61 TEXT_BG same as TEXT
$70 TILE X(0–49), Y(0–35), scroll(0–7), …tile IDs…, $00
$71 TILE_BG same as TILE

See MAD65_GPU_OS.md for full argument descriptions, PPRAM protocol, coordinate rules, and the two-consecutive-frames rule for background writes.


15. Component List#

The digital-logic parts below are the ones modelled in the RTL (rtl/pcb1/pcb1.sv, rtl/pcb2/pcb2.sv); the analogue/clock/reset parts after them carry over unchanged from the hardware design (they are not modelled in sim).

Logic (both boards):

Component Description Qty
WDC W65C02S CPU1 (main) and CPU2 (GPU) 2
ATF1508AS-10JU84 CPLD ×5 — PCB1: cpld_cpu1 (v11: all CPU1 decode + glue, SHADOW_REG, RDY, cart bank register, ÷4 audio clock, LED_STRB); PCB2 video: cpld_video (counters, addr-gen, SEL, pixel path) + cpld_ctrl (v13: GPU address decoder + VIDEO_REG, VRAM control, GPU-data 245) + cpld_xbar (v12: the VRAM address crossbar); PCB3: cpld_ppr (ping-pong SWAP_SEL + address crossbar + /CE + 245 enables, v8). v14: the VSYNC→PHI2 synchroniser (→ vsync_cpu_n) sits in cpld_ctrl, not cpld_ppr 5
CY7C199-15PC RAM: CPU1 ×2 (lower+upper) + GPU ×2 (lower + shadow) 4
CY7C199-15PC VRAM: image A/B + background A/B 4
CY7C199-15PC Ping-pong Shared RAM SRAM_A/B (PCB3, v8; only 2 kB of 32 kB used) 2
↑ same part — 10× CY7C199-15PC total
27C256 / AT28C256 CPU1 ROM 16 kB + GPU ROM 16 kB (same part) 2
SN76489 PSG sound generator (centre pan) 2
YM2413 FM sound generator (OPLL, stereo) 1
74HC245 Data transceivers: 2× VRAM (PCB2) + 4× ping-pong (PCB3) 6
74HC166 Pixel serialisers (one per VRAM buffer) 2
74HC244 Joystick buffers, CPU1 (one per port) 2

v11: the 74HC273 (cartridge bank register) and both 74HC74s (CART_EN + audio ÷4) are gone — absorbed into cpld_cpu1 along with the two CPU1 GALs. See bom.md for per-board totals (PCB1: 14 → 10 ICs; system 36 → 32).

Analogue / clock / reset (not in RTL, carried over):

Component Description Qty
MCP100-450DI/TO Reset supervisor (CPU1 and CPU2, 2 delays) 2
Crystal 20.000 MHz SVGA pixel clock (800×600 @ 60.317 Hz) 1
Crystal 14.318 MHz Shared CPU1/CPU2 clock (÷4 → audio 3.58 MHz) 1
Resistor 75 Ω VGA output white level (R, G, B) 3
Resistor 270 Ω VGA output dark grey background (R, G, B) 3
Op-amp + passives Stereo audio mixer (SN76489 ×2 + YM2413 L/R)

v8 BOM change vs v7: the ping-pong shared RAM moves to its own board PCB3 (2× SRAM + 4× 74HC245 + cpld_ppr). cpld_ppr also absorbs the 6× 74HC157 ping-pong crossbar, so no 74HC157 remain system-wide. PCB2 drops to 14 ICs; PCB3 = 7; system total 40 → 35. PCB1 and PCB2 each cable into a PCB3 port; SWAP_SEL and the frame IRQ are generated on PCB3 from one VSYNC edge detector (coherent by construction). v14 amends this: the VSYNC synchroniser and the frame IRQ moved to PCB2's cpld_ctrl; PCB3 keeps SWAP_SEL and edge-detects the incoming vsync_cpu_n. No IC count changes — cpld_ppr goes 60→59 I/O, cpld_ctrl 48→50.

v7 BOM change vs v6: a second ATF1508AS CPLD on PCB2 absorbs the 8× 74HC157 VRAM address crossbar — the CPU2 address enters cpld_video and both VRAM buffer addresses leave it directly. PCB2 74HC157 drops 14→6 (ping-pong only); PCB2 ICs 33→26.

v6 BOM change vs v5: the ~16-chip discrete video pile — 4× video GAL, 6× 74HC163 counters, 5× 74HC283 adders, the 74HC374 VIDEO_REG and the 74HC74 SEL flip-flop — collapses into one ATF1508AS CPLD. The concurrent double-buffer/ping-pong data path is now honest structural silicon (14× 74HC157 + 6× 74HC245 + 2× 74HC166) instead of a single shared 245. PCB1 gains a second GAL (gal_cpu1_ctrl) that absorbs the R/W-gated glue, and the PCB2 74HC04 is removed (its two inversions were redundant — see the video-circuit doc). Joystick buffers are 74HC244 (input-only), not 74HC245.


16. Open Issues#

  1. Cartridge connector — physical connector type, pinout, power
  2. Power requirements — 2× SN76489 + 1× YM2413, 8× CY7C199-15PC, 5× ATF1508AS CPLD (no GAL since v13) = significant 5V current draw
  3. WE_COPY timing — BG_OE combinatorial (V0 from the CPLD). At 20 MHz margin is ample; verify on prototype
  4. Background loading protocol — GPU must load background into both VRAM buffers at startup (2 VSYNC cycles)
  5. PCB inter-board connector — pinout for the PCB1↔PCB3 header: CPU1 address A10:A0, CPU1 data D7:D0 bidirectional, CPU1 R/W, /CS_SRAM_CPU1, clock, /IRQ (v14: sourced from PCB2, not PCB3), RESET, SWAP_SEL (optional monitoring)
  6. Lower RAM /CE wiring — both CPU1 and GPU: lower RAM chip /CE routed to GAL output /CS_RAM_LO (not 74HC04). Ensures RAM deselected at $7800–$7FFF (Shared RAM region).
  7. VRAM-background A14 — background chip A14 hardwired to VCC (via 100 Ω) on video side. Connects GPU $C000 write address (chip $4000+) with video read address ($4000+).
  8. SWAP_SEL at reset — SWAP_SEL is a registered CPLD output; its async reset is tied to system /RESET → SWAP_SEL=0 at power-on. Defines initial ownership: CPU1 owns SRAM_A, GPU owns SRAM_B.
  9. Ping-pong 74HC245 direction/enable — on PCB2 each 2 kB SRAM has its own CPU1-side and GPU-side 74HC245; DIR = that master's R/W (1=read→chip→bus), enabled by the decoder GAL's /CE_SRAM_A/B. (Resolved: the CPU1-side DIR is cpu1_we_sram_n directly — inverting it was the "CPU NOT READY" bug.)
  10. CPLD I/O budget — the video engine is three ATF1508AS-10JU84: cpld_video ≈ 33/64, cpld_ctrl ≈ 37/64, cpld_xbar ≈ 57/64 I/O (see video-circuit doc §16.4a). v7 first split the engine in two but the VRAM address crossbar (+28 pins) drove cpld_video to ≈ 59/60 — no fitting margin — so v12 gave the crossbar its own chip, cpld_xbar. (Supersedes the old "GAL #4 macrocell budget" concern.)
  11. FIRE2 (DE-9 pin 9) — each port's pin 9 connects to a spare input of the CPU1 joystick 74HC244 (bit b5) with a 10 kΩ pull-up to +5 V, identical to the other five active-low lines. Two added pull-ups total. No GAL/decoder change — /CS_JOY already covers the whole $BF40–$BF5F window. Software keeps b0–b4 unchanged so single-fire titles are unaffected; the CPU OS joy_read masks AND #$3F (was $1F) to publish b5=FIRE2.

Document v6.0 (EN) — changes from v5.3: — Video circuit consolidated from ~16 discrete parts (4 video GAL, 6× 74HC163, * 5× 74HC283, 74HC374, 74HC74) into one ATF1508AS-10JU84 CPLD (cpld_pcb2.sv). — Concurrent double-buffer / ping-pong data path made structural: 14× 74HC157 * (8 VRAM + 6 ping-pong), 6× 74HC245 (2 VRAM + 4 ping-pong), 2× 74HC166. — PCB1: second GAL gal_cpu1_ctrl added for the R/W-gated glue (replaces the * discrete 74HC04 + 74HC00 + 74HC4075). Joystick buffers corrected to 74HC244. — PCB2 74HC04 glue removed (both inversions were redundant). — Component list split into RTL-modelled logic vs carried-over analogue/clock/reset. — Whole-system + video block diagrams: MAD65_system_v6_blockdiagram.svg, * MAD65_video_v6_blockdiagram.svg.

Document v5.3 (EN) — changes from v5.2: — Ping-pong swap made synchronous: SWAP_SEL is now a registered output of video * GAL #4, clocked by the 14.318 MHz CPU clock (= PHI2), so the shared-RAM * ownership swap always lands between bus cycles (deterministic, glitch-free). — 74HC123 monostable removed; the SWAP_SEL 74HC74 half removed (VRAM-SEL 74HC74 * keeps one half, other half spare). BOM: v5 net vs v4 is now +1 (was +2).*

Document v5.2 (EN) — changes from v5.1: — CPU1 ROM enlarged 8 kB → 16 kB, relocated $E000–$FFFF → $C000–$FFFF. — CPU1 cartridge window relocated $C000–$DFFF → $8000–$9FFF (still 8 kB, 1 MB banked). — CPU1 upper RAM shrinks to $A000–$BEFF (~7.75 kB); I/O page stays at $BF00–$BFFF. — Decode: /CS_ROM = A15·A14; /CS_CART = A15·!A14·!A13·CART_EN. — CPU1 ROM part 27C64/AT28C64 → 27C256/AT28C256 (unifies ROM part across both boards).

Document v5.1 (EN) — changes from v5.0: — Sound: SN76489 #3 and #4 removed; YM2413 (OPLL) added at $BF20–$BF3F. — Audio output changed from mono to stereo: SN76489 #1+#2 mixed to centre; YM2413 L/R. — CPU1 GAL: /CS_SND3 and /CS_SND4 merged into /CS_YM (9/10 outputs, 1 spare). — I/O map updated: $BF20–$BF2F = YM2413 register write, $BF30–$BF3F = YM2413 data write. — Component list updated: SN76489 ×4 → ×2; YM2413 ×1 added.

Document v5.0 (EN) — changes from v4.3: — IDT7132 dual-port RAM replaced by ping-pong scheme: 2× standard 2 kB SRAM (SRAM_A, SRAM_B). — SWAP_SEL flip-flop added (spare half of existing VRAM-switch 74HC74 package on PCB2). — 74HC123 monostable added: ~2 µs delay from VSYNC before SWAP_SEL toggles. * Protects against mid-cycle bus access at the swap moment. — GPU GAL v5: SWAP_SEL and /CS_SRAM_CPU1 added as inputs; * /CS_SRAM replaced by /CE_SRAM_A and /CE_SRAM_B (8/10 outputs used, 2 spare). — CPU1 GAL: /CS_SRAM output retained, sent to PCB2 via interconnect as /CS_SRAM_CPU1. — Communication model: 1-frame latency (CPU1 writes frame N; GPU reads frame N in frame N+1). — No bus arbitration hardware needed — exclusive access guaranteed by construction. — Component list updated: IDT7132 removed, 2× HM6116 + 74HC123 added. — Open issues updated: SWAP_SEL reset state, 74HC245 direction control, RC values.

Document v4.3 (EN) — changes from v4.2: — resolution updated to 400×300 logical / 800×600 physical (full SVGA 800×600, no border). — Shared RAM moved to $7800 on both CPU1 and GPU sides.