DMG-CPU Research
On this page
  1. Bus / netlist notes needed to read the waves
  2. Test 1 — tb_ppu_regs (register write/read + reset + counter/mode smoke)
  3. Test 2 — tb_ppu_bg_scanline (BG rendering: modes, fetch, pixels)
  4. Test 3 — tb_ppu_scroll (SCY/SCX scroll adders)
  5. Test 4 — tb_ppu_window (WIN layer: WY/WX + window tile map)
  6. Research handoff (sprite/OAM block)
  7. Test 5 — tb_ppu_frame (V counter / VBlank / wrap, full frame)
  8. Test 6 — tb_ppu_scene (synthetic scene: LCD+BG+WIN+OBJ together)
  9. Test 7 — tb_ppu_bg_win_matrix (all BG/WIN layer combinations)
  10. Test 8 — Mode state machine and LX/LY waves
  11. Test 9 — mid-line BG→WIN switch wave (matrix config C10)
  12. Work in progress

PPU testbench waves (issue #390)

Regression testbench for the DMG-CPU PPU netlists HDL/soc/ppu1.v and HDL/soc/ppu2.v, run with Icarus Verilog. Each test compiles the real ClkGen + PPU1 + PPU2 netlists together with behavioral VRAM / OAM models (see Readme) and dumps a VCD. The wave images below are rendered from those VCDs; the matching .gtkw files open the same traces in GTKWave (v3.3.128 save format).

Run a test with Icarus on the PATH:

tb_ppu_regs.bat
tb_ppu_bg_scanline.bat

or everything with run_all.bat. Each run prints PASS/FAIL checks and ends with RESULT: ALL PASS (or a failure count). The VCD files are git-ignored; the .gtkw files and the images in waves/ are committed.

Bus / netlist notes needed to read the waves

Test 1 — tb_ppu_regs (register write/read + reset + counter/mode smoke)

Scenario: power-on reset, then through the CPU model write LCDC=$91 (LCD + BG on), SCY=$21, SCX=$07, BGP=$E4, WY/WX, and check that PPU2 produces ppu_wr/ppu_rd, that LCDC stores the value (checked via the FF40_D1..3 outputs), that LCDC.7=1 releases the PPU soft reset, that the other register writes do not disturb LCDC, that the read-only LY read-back tracks the V counter, and that the H/V counters and the mode2/3 handshake run.

tb_ppu_regs

What the wave shows (0–62 µs):

Test 2 — tb_ppu_bg_scanline (BG rendering: modes, fetch, pixels)

Scenario: VRAM is preloaded (tile map $9800 row 0 → tile $01 for all 32 columns; tile 1, row 0 = plane0 $AA / plane1 $55), the PPU is programmed (LCDC=$91: LCD on, BG on, map $9800, tiles $8000; SCY=SCX=0; BGP=$E4) and two scanlines are observed. Checks: mode2 + mode3 per line, 456-tick line period, VRAM fetches during mode3, pixel samples on the LCD bus.

tb_ppu_bg_scanline

What the wave shows (2–65 µs):

Test 3 — tb_ppu_scroll (SCY/SCX scroll adders)

Scenario: SCY=1, SCX=8 with a per-column/per-row unique tile map, then two checks on the real VRAM addresses fetched by PPU2's scroll adders (wiki/soc/ppu2.md block 2): with SCY=1 the tile-map row fetched at LY=8 is (LY+SCY)>>3 = 1, and with SCX=8 the first map column fetched at the line start is SCX>>3 = 1.

tb_ppu_scroll

Test 4 — tb_ppu_window (WIN layer: WY/WX + window tile map)

Scenario: LCDC = $F1 (LCD + BG + WIN on, window tile map $9C00 via LCDC.6), WY=0, WX=7 so the window covers the whole visible area. The test verifies that the PPU tile-map fetches come from the $9C00 window map (address 0x1C00..0x1DFF) with the window tile data, i.e. the PPU1 window logic (in_window / WY/WX compare / map select) and the PPU2 !in_window gating of the scroll adders behave together.

tb_ppu_window

Why oa[7:1] shows red (x) in the sprite waves

Red in the rendered waves is the x (unknown) state. oa is red during most non-mode-2 stretches and in the "precharge" half of every 128 ns OAM access inside mode 2. Direct probe of the oa drivers (e.g. at t=30016, mode 2) shows the cause:

X t=30016 m2=1 oa=0000xxx w497=x  en518=0 en475=0 w476=1   <- x (contention)
X t=30020 m2=1 oa=0000100 w497=1  en518=0 en475=x w476=x   <- clean (scan only)

So the red oa is a driven x from two oa mux groups overlapping in time (scan group + Y-adder group), i.e. the dynamic two-phase (precharge / evaluate) separation of the oa bus is not reproduced by the static simulation. (Historical; superseded by rounds 22-30: the no-reset FFs are NOT the cause - round 22; the discharge-only/keeper bus models remove the x - rounds 27-30, gen_weakbus.py -> ppu2_weakbus.v/ppu2_m2only.v; the underlying mux-group overlap (g419/g421) remains an author/schematic question, see waves.md rounds 28-30.)

Research handoff (sprite/OAM block)

Historical log (rounds 1-21). The current state is round 30 — see the "Work in progress" section below (rounds 22-30). Short status:

  • Regression suite: 7/7 ALL PASS.
  • Y-test comparator: verified correct (round 28 — passes LY 1..7, fails LY=8 once port B carries a valid Y byte).
  • Sprite path (round 30, dev): claims, obj_prio_ck (~10-11/line), sp_bp_cys, sprite_x_match and sprite colour pixels on LD0/LD1 run under the workaround bus model ppu2_m2only.v (gen_weakbus.py, netlists untouched). Root cause of the earlier failure: the static sim cannot separate the overlapping oa mux groups (scan w518 vs port-B w475/CPU w403/store w444, g419/g421), which corrupts the mode-2 scan address (x / odd words) so port B never presents the Y bytes.

Historical (rounds 1-21):

Verified / working (regression suite): CPU register write+read-back (LCDC/SCY/SCX/BGP/LY - PPU1 and PPU2 read paths), 456-tick line / 80-tick mode-2 rhythm, BG fetch & pixel stream, SCY/SCX scroll adders, WIN layer, OAM SRAM model (oam_ram.v), LCD stub (lcd_stub.v).

Not yet reproducible: the sprite store claim + mode-3 sprite fetch/pixel path. With OBJ on, mode-3 length is now normal (fixed by the OAM model), but obj_prio_ck never pulses, no sprite slot is claimed, sp_bp_cys never fires. Two coupled root causes (see the open questions / suspected-issue lists in wiki/soc/ppu1.md & ppu2.md):

  1. dynamic-bus two-phase behaviour of the oa/n_oama/n_oamb muxes is not reproduced statically (the scan group and the port-B-adder group of the oa mux overlap in time -> contention x; a discharge-only tristate removes the contention but changes the scan addressing polarity);

  2. several FF groups have no async reset (PPU2 g938-g943 scan-address register, PPU1 g325/g326 window dividers, plus PPU1 g652 looks like a duplicate LCDC.D7 latch) - reported to the author, NOT patched here.

Round-12/21 observation: sampled over lines v=1..10, the ten 8-bit store banks (g650/g668/g647/g637/g659/g642/g670/g664/g677/g635) all stay 0x00. Correction (round 21): the banks are NOT held in reset by the in-use dffr - their async reset nres = ~(w27|flag) is released during the line (w27 = h_restart | !n_ppu_reset); they simply never receive an enable, because the store window never opens: w852 = oam_rd_ck & w209(mode2) & w816, w816 = w474&w475&w484&w483&w481&w480 where w474/w475/w484/w483/w481 are the Y-test adder (g595-g602) results and w480 = FF40_D2 | w479. With OAM data undefined (the oa/port-x issue) the Y-test never satisfies the window, so no bank enable, no stored sprite, no mode-3 compare -> obj_prio_ck stays idle. The whole sprite claim path is therefore gated on the OAM Y-test data/window, i.e. the same root as the oa phase issue (reported to the author).

Round-13: obj_prio_ck has ZERO rising edges over 4 full lines (edge-detection probe), although h_restart pulses each line and the sprite ring reset term w816 = ~(w530|w531|h_restart) does dip. obj_prio_ck = ~(w239|w240); w240 (nand3 over the sprite-process FFs g322/g314/g287) stays high because w229/w241 never reach 1 together - the g287-g289 ring never leaves its latched state (w226=1,w228=1) after boot, so the claimed "restart each line" never produces process-clock pulses. The FSM boot state depends on the no-reset FFs reported to the author. Sprite research was paused here pending the author's netlist fixes. (Superseded: rounds 22-30 continued below — no-reset FF boot state was ruled out, the oa mux-group overlap was identified as the static-sim blocker, and the m2only bus-model workaround runs the sprite path end to end.)

To unblock: (a) author fixes/confirms the suspected netlist items, and/or (b) schematic-level two-phase bus timing (msinger pages) is made available, then the sprite test can be completed from the current bring-up state (tb_ppu_sprites.v already runs the scan; OAM model and LCD stub are in place).

Test 5 — tb_ppu_frame (V counter / VBlank / wrap, full frame)

Full-frame simulation (~4.55 ms sim time, slow): with the LCD on, checks that VBLANK (vbl from PPU1) asserts at LY>=144, the V counter wraps 153->0 at the frame end and ppu_int_vbl pulses (PPU1 blocks 4 and 14).

tb_ppu_frame

Test 6 — tb_ppu_scene (synthetic scene: LCD+BG+WIN+OBJ together)

Full synthetic scene with real content: BG map $9800 (tile 1 checker), WIN map $9C00 (tile 2, WY=160 so the window is configured but below the observed lines), one OBJ (Y=16, X=8, tile 1). Checks the combined mode rhythm and the BG pixel stream; the sprite pixel output is not engaged yet in this (weakbus-default) run - see the round-30 e2e wave below for the sprite path under the ppu2_m2only.v workaround.

tb_ppu_scene

Test 7 — tb_ppu_bg_win_matrix (all BG/WIN layer combinations)

Sweeps the LCDC layer combinations (BG enable/bit0, BG map select/bit3, WIN enable/bit5, WIN map select/bit6, LCD/bit7) with WY/WX, and verifies which tile map ($9800/$9C00) the fetches come from for the active layers:

# LCDC Layers / window Result
C1/C9 0x80 LCD only (no layers) frame runs; fetcher still walks map0 (INFO, DMG layer bits gate the pixel mux, not the fetcher)
C2 0x91 BG $9800 PASS (map0 dominates)
C3 0x99 BG $9C00 PASS (map1 dominates)
C4 0xA0 WIN $9800, WY=0 PASS (map0 dominates)
C5 0xE0 WIN $9C00, WY=0 PASS (map1 dominates)
C6 0xE1 BG $9800 + WIN $9C00, WY=0 (whole-screen window) PASS (map1 dominates)
C7 0xE1 same, WY=40 (window below LY<40) PASS (BG map0 only)
C8 0xB9 BG $9C00 + WIN $9800, WY=0 PASS (map0/window dominates)
C10 0xE1 BG $9800 + WIN $9C00, WY=0, WX=50 PASS (both maps: mid-line BG->WIN switch, m0=13/m1=30)
C11 0xE1 same, WX=3 (WX<7) PASS (window covers the whole line - comparator WX-7 wraps negative)

RESULT: ALL PASS.

Test 8 — Mode state machine and LX/LY waves

Waves of the PPU mode handshake (mode 2 / mode 3 / stop_oam_eval / h_restart / vclk2, the mode-2 OAM clocks oam_addr_ck/oam_rd_ck/obj_prio_ck), the LX (h) and LY (v) counters and the LCD line timing (/CPL, /CP), from the tb_ppu_bg_scanline run. Two scanlines (left) and one line zoom (right):

mode_fsm

mode_fsm_line

Test 9 — mid-line BG→WIN switch wave (matrix config C10)

Wave of one scanline with the window starting mid-line (WY=0, WX=50 => active from LX = WX-7 = 43): in_window rises inside mode 3, the VRAM fetches switch from the BG map ($9800 / 0x1800) to the window map ($9C00 / 0x1C00) and the LD pixel pattern changes between the two tile contents.

win_mid

Work in progress

State after the OAM model rework (round 6):

Round-6 trace of that domain: the process FFs g287–g289 form a self-timed ring (w227=!w228, clocks w815=~(ppu_clk & w226&w228) and w964), whose async reset w816 = ~(w530|w531|w844) is released only when w530 = w184&w185 is low (w184/w185 = free-running dividers g326/g325 on ppu_clk, themselves without reset: nr1=w47=const1) and w531 = !n_ppu_reset is low. In the simulation the ring sits in the latched rest state (w226=1, w228=1) and never starts: w239/w240 (→ obj_prio_ck = ~(w239|w240)) never pulse, so PPU2 never claims a sprite slot. Whether w530's dot window / w844 and the store handshake are supposed to kick the ring out of this state is the open question (likely needs PPU2's sprite_x_match/store feedback).

Round-7: w844 = h_restart (ppu1 assign) - so the ring IS reset at the end of every scanline (w816 = ~(w530|w531|h_restart)), yet it re-latches to its terminal state (w226=1/w228=1) within the next few clocks and obj_prio_ck still never pulses in any sampled mode-2 or mode-3 window. Also note PPU2's per-slot in-use dffr g611–g628 have d-terms built from the mode-3 compare flags (w332 = FF40_D1 & ppu_mode3 gated) while the store banks capture during mode 2 - the exact mode-2/mode-3 interleaving of "claim" vs "capture" is not yet reproducible and needs schematic-level sprite scheduling data.

Round-22 (no-reset FF "init-0" probe, tb_ppu_ring_init0): per the checklist, the no-reset triggers (nr1 = nr2 = const-1) were pinned to 0 instead of x for the test. Two runs were compared:

Round-23 (weak / discharge-only oa experiment): per the checklist, the oa bus was made "weak": the six oa-chain inverse-hold nodes (w497/w146/w500/w554/w641/w49, each driven by four notif0 mux groups - scan w518, port-B w475, CPU w403, store w444, idle w48) were re-modelled as precharged nodes: pullup keepers + open-drain drivers (dmg_notif0_od, a strong-0 pull only when enabled AND data=1; polarity preserved). Variant netlists + probes live in temp/weak/ (gitignored). Result:

Round-26 (OAM A/B buses "weak" + inverse-hold model fix): the ports n_oama/n_oamb are inverse-hold buses like oa/nma (idle = precharged HIGH = data 0; data = ~pad level). PPU2's scan-capture stage stores the pad level directly (dmg_latch g733-g748, no inversion), so the macro must present levels, not data. The committed oam_ram.v was reworked (round 26):

Round-27 (weak oa accepted as the default bus model): the round-23 discharge-only experiment became the standard simulation model for the PPU testbench. gen_weakbus.py generates ppu2_weakbus.v from ppu2_merged.v (the 24 oa-chain notif0 mux drivers -> open-drain dmg_notif0_od in bus_weak_cells.v; pullup keepers on w497/w146/w500/w554/w641/w49); all PPU test compiles now use it instead of ppu2_merged.v. Simulator bus model only - ppu2.v untouched. Results: oa x gone in mode 2 (0/1280 samples) and idle (0/5168); residual x only in mode 3 (sprite compare/store re-fetch, bus unused for the BG stream); scan addresses now fully defined, walking words {7,15,23,...,79} per mode 2; full regression suite (7/7, incl. tb_ppu_frame) ALL PASS; sprite-scan wave regenerated (red pixels 42000 -> ~15900). obj_prio_ck/sp_bp_cys remain flat and the Y-test AND6 still never passes with sprites in all 40 OAM entries - the sprite claim is gated upstream as documented (rounds 13-26), not by the oa bus x.

Round-28 (WHY the Y-test never passed - resolved): the Y-test comparator itself is CORRECT. Decisive probe (temp/oamweak/tb_ytest_all10*): with every OAM byte preloaded to 0x10 (= Y 16, visible on LY 0..7):

Round-29 (scan-only oa bus - sprite claims fire): variant ppu2_scanonly.v (temp/oamweak; ppu2_weakbus with the 18 non-scan oa-chain drivers disabled - only the mode-2 scan group w518 drives) makes the scan address stable and EVEN: words {2,4,...,78} per mode 2. With realistic content (Y=16 in OAM entries 1..39, tiles elsewhere):

Round-30 (path b - sprite pixels reach LD0/LD1): gen_weakbus.py now also emits ppu2_m2only.v: the 18 non-scan oa-chain drivers are disabled while ppu_mode2 is high (n_ena = orig | ppu_mode2) so the mode-2 scan address is stable/even, while in mode 3 the store re-read (w444) and the other groups work again. Dev test tb_ppu_sprite_e2e (sprite in OAM entry 1: Y=16, X=16, tile 1 filled in VRAM, BG zero) on ppu2_m2only.v:

Wave (t = 30 000..117 000 ns, two visible rows) on the ppu2_m2only.v bus model: modes 2/3, obj_prio_ck/sp_bp_cys/sprite_x_match pulsing, oa stepping the scan words, and the LD0/LD1 lines carrying the sprite colour pixels during the pixel phase.

tb_ppu_sprites_scan

Wave regenerated (round 27) from the default run, one line window (t = 29 000..58 000 ns): mode 2 shows the OAM scan - oa steps 39 word addresses (defined; weak-bus default) while n_oam_rd/oam_bl_pch pulse; residual oa x only in mode 3 (compare phase); obj_prio_ck and sp_bp_cys stay flat - no slot claimed.

Not promoted to a regression test yet.