Skip to content

Graft Nitro history and finish HPS Engine B screen path - #14

Open
struffl wants to merge 234 commits into
SplashDev88:mainfrom
MiSTfits-devel:feature/hps-engine-b-dual-screen
Open

struffl wants to merge 234 commits into
SplashDev88:mainfrom
MiSTfits-devel:feature/hps-engine-b-dual-screen

Conversation

@struffl

@struffl struffl commented Sep 3, 2026

Copy link
Copy Markdown

vv I cannot be held responsible for what the robot wrote OK

Summary

This draft joins the published Nitro_DarkSide and NDS4MiSTer histories with
real two-parent merge commits, then imports SplashDev88's supplied 2026-09-02
Engine B development tree as an author-preserved child of the public beta.6
tip.

On top of that shared history it:

  • keeps Engine A, sound, and the console core in FPGA while rendering Engine B
    on HPS;
  • mirrors Engine B registers, palette, OAM, VRAM, LCD, and physical-screen
    routing in write order;
  • preserves Engine B DISPCNT bit 16, fixing the observed white screen;
  • stages a complete 256-pixel DDR line before publication, preventing the
    observed partial vertical-band corruption;
  • exposes distinct physical top/bottom screens, applies POWCNT1 screen
    assignment, and keeps touch/cursor behavior on the bottom screen;
  • adds shared credits for Heni, SplashDev88, Robert Peip, and the MiSTer/melonDS
    contributors;
  • consolidates the common GPLv3 license, documents provenance, and adds a
    GPLv3 contributor agreement.

History and attribution

  • f854add has the final Nitro_DarkSide tip and current NDS4MiSTer public tip as
    its two parents.
  • af82e7e records SplashDev88's supplied working tree with SplashDev88 as Git
    author. Its bundled SHA256SUMS still describes beta.6 and therefore does
    not authenticate the 54 subsequently modified files; this is documented in
    PROVENANCE.md rather than presented as a verified release archive.
  • f4697fc joins that donor line to the shared branch with another two-parent
    merge.

No graft commit reassigns copyright or authorship. Both parent projects' GPLv3
license files remain directly inspectable in Git history.

Verification

  • ./tools/test_nitro_console_island_host.sh
  • HPS 3D service self-test and fake-memory lifecycle test
  • nds_display_capture_oracle_test (capture sources, offsets, RGB666-to-RGB555,
    blending, POWCNT1 swap, and production Engine B publication)
  • public-release safety audit
  • git diff --check
  • Quartus Prime 17.0.2 seed-2 fitter and assembler succeeded at
    41,242/41,910 ALMs (98%), 475/553 RAM blocks, and 69/112 DSP blocks. The
    generated RBF has SHA-256
    35c7c0e2bd896c1d7ccb532046ada3512de71d6698c58e01d8fb4821ecf2292d.
    TimeQuest completed with 0 analyzer errors but does not close timing or full
    constraints: cross-corner worsts are setup -14.784 ns, hold -0.765 ns,
    recovery -7.611 ns, removal -1.245 ns, and pulse width +0.932 ns. The RBF's
    two subsequently removed reset-global assignments were reported as invalid
    no-ops, so the committed QSF has the same effective fitter topology.

Boundaries

This is intentionally a draft until the generated RBF is exercised on MiSTer,
especially SplashDev88's LG C3. Seed 2 is retained because it has the known
stable display behavior on that setup. TimeQuest results are reported for
visibility but are not treated as board acceptance at this density.

The display-capture oracle is useful groundwork, but live dual-screen 3D via
capture/alternate-frame rendering is not yet accepted as complete. This PR
relates to #10; it does not close it. FPS reporting (#11) and audio distortion
(#13) remain separate work.

Addresses #12.
Relates to #10.

struffl and others added 30 commits July 16, 2026 00:35
…ecoder

- docs: NDS hardware ground truth (from NitroSDK), memory budget analysis,
  architecture port plan from GBA_MiSTfits, M0-M10 roadmap
- rtl: vendored ARM7TDMI (gba_cpu), proc bus + savestate convention, SDRAM/DDR3
  controllers, SyncRam/SyncFifo primitives, MiSTer sys/ framework
- rtl/nds_vram_map.vhd: full VRAMCNT decode (9 banks, all MST/OFS modes,
  ARM7 C/D slots), unit-tested against the NitroSDK gx_vramcnt.c truth table
- sim: nvc flow (analyze-all smoke gate + 84-check VRAM map testbench)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- rtl/nds_vram.vhd: bank stores + CPU datapath. E-I (144 KB) in BRAM with the
  renderer port reserved; A-D (512 KB) behind a server channel (future SDRAM
  guest client). Hardware semantics: overlapping banks OR on read / fan out on
  write, unmapped reads return 0, dual CPU ports serialized with fairness.
- rtl/nds_wram.vhd: 32 KB shared WRAM, all four WRAMCNT modes (melonDS
  MapSharedWRAM semantics), true dual-port, unmapped flagged to the membus.
- sim/tb_vram_torture.vhd: randomized ops from both CPUs vs a byte-accurate
  behavioral model, behavioral A-D server with random 1-8 cycle latency, live
  VRAMCNT/WRAMCNT reconfiguration. Green: 50k ops (seed 1) + 500k soak (seed 7)
  on the k8s nvc pod.
- build/remote-sim.sh + sim-pod.yaml: remote nvc runs on the cluster
  (ghcr.io/nickg/nvc pod, git-archive source streaming, GBA remote-build
  pattern); sim scripts are POSIX-sh and pod-friendly now.
- docs/MEMORY_MAP.md: note on why the DC-style hybrid approach doesn't dodge
  the DS graphics-memory problem (Robert Peip, July 2026).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… exit test

- nds_mainram: dual guest channels (ARM9/ARM7) on one SDRAM port with
  arbiter, gba_mem_ewram_sdram idiom; randomized torture tb (sequential +
  concurrent-pair phases against a behavioral controller model).
- nds_irq: per-CPU 32-bit IME/IE/IF controller, IF-accumulate with
  ack-then-OR semantics.
- nds_ipc: IPCSYNC + dual 16-deep FIFOs, both proc-bus sides, error/empty/
  full flags and edge-detected IRQ pulses.
- Timers vendored verbatim from GBA_MiSTer (register-compatible at 0x100).
- nds_membus7: ARM7 bus decoder (BIOS / main RAM / shared+private WRAM /
  IO proc-bus / VRAM C-D), GBA read/write lane rotation, open-bus lastread
  feedback; accepts a new request on every completing cycle, matching the
  CPU's same-cycle ena-after-done behavior.
- tb_arm7_island: vendored gba_cpu runs sim/tests/arm7_island.hex as BIOS;
  tb plays ARM9 (IPCSYNC echo, FIFO loopback+1) and snoops the 0x02FFFF00
  mailbox. Exit test green: bitmask 0x7F (main RAM rw, shared WRAM,
  VRAM-C-as-WRAM, timer poll, timer overflow IRQ, SYNC echo, FIFO loop),
  magic 0xCAFEBABE, PASS on nvc (local + cluster).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- nds_cpu9: fork of the vendored gba_cpu with the ARMv5TE additions —
  CLZ, QADD/QSUB/QDADD/QDSUB with a sticky Q flag, the SMULxy/SMLAxy/
  SMULWy/SMLAWy/SMLALxy DSP multiplies (accumulate beats reuse the MUL
  decode-swap idiom), BLX in all four forms (ARM imm/reg, Thumb reg/
  suffix), LDRD/STRD as two-beat extensions of the single-transfer FSM,
  PLD/CDP/LDC/STC as nops, v5 LDR/LDM-to-PC interworking, and CP15 via
  MCR/MRC (ID regs, control, PU region file, TCM base/size, cache-op
  acceptance, c7,c0,4 wait-for-interrupt wired into the halt path).
  Exception vectors relocate to 0xFFFF0000 on control bit 13.
- Fixed a halt-wake bug inherited from the GBA core: the IRQ injected on
  unhalt overwrote decode_PC with fetch_PC (one fetch ahead), making the
  handler return skip the first instruction after wait-for-interrupt.
- nds_membus9: ARM9 bus decoder with the TCM overlay (ITCM mirror with
  load-mode write-only semantics, movable DTCM, data-only, both sized by
  CP15), boot ROM at 0xFFFF0000, main RAM/WRAM/IO/VRAM behind it.
- tb_arm9_island + arm9_island.s (v5TE, linked at 0xFFFF0000, boot PC
  preset through the savestate bus like the future loader): 11 tests all
  green — main RAM, CP15 ID, DTCM, ITCM mirror, CLZ, saturation + Q,
  DSP multiplies incl. SMLALBB, LDRD/STRD with writeback, BLX both ways,
  LDR-to-PC interworking, high-vector timer IRQ + wait-for-interrupt.
  PASS on nvc locally and on the cluster (bitmask 0x7FF).
- tb_arm9_trace + compare_trace.py + docs/TRACE_DIFF.md: per-instruction
  trace harness for the melonDS differential exit test (10M instr, zero
  divergence) — melonDS-side tracer patch is the next step.

Still open for M3: I/D caches (write-back + clean/invalidate), 2x ce
pacing, armwrestler + melonDS differential runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ergence

Closes out M3 (docs/ROADMAP.md):

- melonDS 0.9.5 headless tracer (sim/melonds_tracer/): pinned clone +
  tracer.patch + stub-Platform harness booting raw ARM9 binaries with the
  RTL-matching initial state. tracer.patch also fixes two genuine melonDS
  0.9.5 interpreter bugs the differential flagged (EORS/LSR dispatch-table
  typo — still in melonDS master; ADC/SBC/RSC V-flag OR-vs-XOR on double
  overflow).
- Differential workloads: arm9_diff.s (island tests at 0x02000000) and
  gen_arm9_torture.py (seeded random v5TE chunks, armwrestler-style, with
  optional cache-enabled mode). tb_arm9_trace LOADADDR generic boots
  main-RAM workloads.
- M3 exit test PASSED: 10,000,000 retired instructions RTL vs melonDS,
  zero divergence (plus ~20 30k-instruction seeds, caches off and on).
  Fixed one RTL bug it found: ROR-by-register with a multiple-of-32
  amount must set C := Rm[31], and only Rs[7:0] participates (the donor
  gba_cpu has the same bug — left untouched here).
- nds_cache9.vhd: ARM946E-S caches — 8KB I / 4KB write-back D, 4-way,
  32B lines, round-robin, read-allocate, write-miss no-allocate, dirty
  victim write-back, full maintenance op set (inv I/D all/line/index,
  clean, clean+inv) driven from the CP15 MCR c7 path with proper CPU
  stalling. PU cachability lookup exported from nds_cpu9. Self-checking
  exit test sim/run_arm9_cache.sh (write-back laziness, dirty-drop on
  invalidate, round-robin eviction, I-cache staleness, index-op flush).
- 2x ce pacing: island runs the CPU at full rate with timers/IRQ fabric
  on ce/2 (the 66/33 MHz arrangement nds_top will use); membus9 gains
  io_ce_next and aligns the 1-cycle IO pulses onto active peripheral
  cycles so slow-domain register writes are never dropped.

Full regression green on the cluster: vram_map, mainram, vram_torture,
arm7_island, arm9_island, arm9_cache; arm9 trace diff re-verified after
every RTL change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- nds_loader: parses the .nds header from the staged card image, copies
  both sections to main RAM, reports entry points for the boot-PC preset
- nds_syscnt: EXMEMCNT/EXMEMSTAT + WRAMCNT/WRAMSTAT shared registers
- nds_membus7: io_ce_next alignment port (mirror of the membus9 change)
- gba_cpu: register-shifted ROR by nonzero multiples of 32 now behaves
  as ROR #32 (only Rs[7:0] participate), per ARM ARM
- exit test: SDK-shaped boot pair (crt0 + PXI_InitFifo from the decomp)
  packed by build_nds_dual.sh, loaded by nds_loader, IPCSYNC echo
  handshake + FIFO ping-pong + WRAM/EXMEM handoff — PASS, both islands
  still green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- nds_drawer_text: gba_drawer_mode0 fork - 256px lines, 512 KB BG space
  (full byte-address bases from the orchestrator, 19-bit wrap), extended
  palettes in 256-color mode (32 KB ext-pal port, slot from orchestrator)
- nds_drawer_affine: gba_drawer_mode2 fork - same addressing; fixed a
  donor timing assumption where pixels were silently dropped when the
  palette FSM was busy (GBA's 4-phase service cadence hid it): color
  byte is now captured and handed off with a stall, and busy holds until
  the last pixel clears the palette pipe (both drawers)
- tb_gpu_bg + gen_gpu_bg.py: line-level differential against a Python
  golden model written independently from GBATEK semantics - 20 cases
  covering 4/8bpp, all 4 ext-pal slots, all screen sizes, block-crossing
  scrolls, 512K wrap, mosaic, affine wrap on/off with negative deltas -
  PASS pixel-exact (vectors are generated, not checked in)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…alettes

nds_drawer_extended: NDS extended BG modes (8bpp tiles w/ 16-bit map
entries, 256-color bitmap, direct-color bitmap) on the affine pipeline;
8 new golden cases in tb_gpu_bg (28 total, green).

nds_drawer_obj: fork of gba_drawer_obj with 256px line, 256 KB OBJ space,
1D tile-mapping boundary (DISPCNT 21:20), no even-tile masking for 8bpp 2D
(melonDS), OBJ ext palettes (DISPCNT.31), and bitmap sprites (mode 3) in
1D/2D-narrow/2D-wide layouts incl. affine; settings plane widened to 8 bits
([7:4] bitmap alpha, [3] bitmap, [2] semi-transparent, [1:0] prio). Fixed
during bring-up: alpha nibble leaked the palette field for tile sprites.

tb_gpu_obj + gen_gpu_obj.py: independent golden model (GBATEK/melonDS
semantics, donor merge behavior incl. transparent-prio-update), 24 cases
covering flips, boundaries, extpal, clipping, y-wrap, priority merge,
OBJ window, alpha=0 and reserved-mode skips — all green. Palette/ext-pal
ports are served as plain 1-cycle registered reads (no valid handshake).

analyze-all now covers all four drawers + both GPU TBs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four read-only renderer channels for engine A: BG (512 KB main-BG space),
OBJ (256 KB main-OBJ space), BG ext palette (32 KB / 4 slots from E MST=4,
F/G MST=4 by OFS.0) and OBJ ext palette (8 KB from F/G MST=5). BG/OBJ
reuse the unit-tested nds_vram_map decoder at the canonical region
addresses; the ext-palette roles (no CPU mapping) get their own decode.
Round-robin arbiter, one op in flight; E..I hits read BRAM port B, A..D
hits go through the new read-only rsrv_* channel (the future SDRAM
line-cache client; behavioral in sim). Multi-hit ORs, unmapped reads 0 —
same semantics as the CPU path. v1 is correctness-first; prefetch/
parallelism deferred to hardware bring-up.

tb_vram_ls + gen_vram_ls.py: independent GBATEK mapping golden, 12
VRAMCNT configs x 880 reads incl. window boundaries, overlaps (OR),
holes, LCDC/disabled configs, engine-B roles that must not hit, CPU-port
differential reads for BG/OBJ, and simultaneous four-channel batches for
the arbiter. All green; vram_map (84 checks) and vram_torture unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fork of gba_drawer_merge: 256x192 geometry, OBJ input widened to 24 bits
(settings from nds_drawer_obj), and NDS bitmap sprites force alpha
blending with per-pixel coefficients EVA=alpha+1 / EVB=16-EVA (BLDALPHA
ignored), joining the donor's semi-transparent forced-blend path. Window
select, priority resolution (OBJ wins ties), first/second target rules,
the effect>1-on-OBJ cancel rule and saturating blend math unchanged.

tb_gpu_merge + gen_gpu_merge.py: independent golden model, 13 cases x 256
pixels - plain priority incl. ties, alpha/brighten/darken with random
target masks, forced semi/bitmap blending, WIN0/WIN1 overlap with X/Y
wraparound, OBJ window, effects-disabled windows, BD-as-second-target,
DISPCNT layer disables, full chaos case - all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The GBA-donor fixed cadence (data exactly one cycle after a valid='0'
latch slot) cannot ride the variable-latency VRAM line server. All four
drawers' char/map/bitmap VRAM ports now use the repo-standard handshake:
one request in flight, req one-cycle pulse, address held until the done
pulse (data valid that cycle). Palette/ext-pal/OAM ports stay fixed-
latency local BRAMs (ext-pal slots get shadowed into BRAM at vblank by
the orchestrator - GBATEK: CPU can only write them via LCDC remaps).

- text: cache-hit check moved to request time (a stale in-flight
  response would be misassociated); live cache check kept for the
  palette-stall window
- affine/extended: out-of-range skip decided at request time; request
  address registered (the byteaddr mux drifts with the FSM state)
- obj: NEXTADDR issues tile fetches or skips/reuses without fetching
  (address still updated on reuse - its low bits pick the byte lane),
  new PIXELWAIT waits for done, AFF_SUM keeps the affine summing cycle;
  eval always reads the done-registered word; missing req default-clear
  found via req/done trace (server free-ran one word behind)
- per-line OBJ budget raised (8191/6400) until the line-server latency
  story settles; melonDS does not model the budget
- tb_gpu_bg/tb_gpu_obj: per-port servers with randomized 1..8 cycle
  latency - a harsher contract than the old cadence. 28 + 24 cases green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gba_gpu_drawer role for NDS engine A: register file, mode routing
(0-5), the four BG drawers + OBJ + merge, per-BG line buffers, OBJ
parity double-buffer, and the memory plumbing onto the VRAM line
server (per-BG req/done clients round-robin onto the BG channel, OBJ
passes through, ext-pal shadow BRAMs streamed from the ext-pal
channels at vblank). Line pacing is functional; real dot timing is
M5 part 6 (nds_gpu_timing).

- reg_nds_display: engine-A register map 0x000..0x05F, pReg_gba_display
  pattern (engine B instantiates the same layout at +0x1000 later);
  DISPSTAT/VCOUNT belong to timing, POWCNT/master brightness to compose
- nds_gpu2d: OBJ renders one line ahead into the parity buffer, donor
  style; affine refs reload on vblank and CPU write, step by dmx/dmy
  per line; 3D-as-BG0 renders transparent (stub)
- nds_vram: renderer arbiter latches request pulses in rpend — a
  one-cycle req landing while the FSM served another channel was lost,
  deadlocking every drawer sharing the channel (all four text drawers
  frozen on line 0)
- nds_drawer_obj: busy output so the orchestrator can time the parity
  buffer swap
- tb_gpu2d (line-level, 3 frames) and tb_gpu2d_frame (full 256x192
  frames through the real line server, 4 cases incl. affine, extended,
  ext-pal) against gen_gpu2d.py / gen_gpu2d_frame.py goldens — both
  pixel-exact on the cluster; the diff hunt also fixed golden-model
  bugs (affine H-mosaic, sign extension, ext-pal masking), not just RTL

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The LCD timing module (gba_gpu_timing role), constants and event order
per melonDS GPU.cpp: 355x6-cycle scanlines, 263 lines, hblank/render
point at cycle 1584 (48-cycle lead + 256 dots), DISPSTAT/VCOUNT with
one register set per CPU (9-bit V-match with bit 7 as MSB; VCOUNT
writes are delayed to the next scanline, either CPU may write, last
wins), per-CPU IRQ pulses, and the nds_gpu2d cadence: hblank_trigger
-> line_trigger -> drawline on visible lines, drawObj one line ahead
(line 262 pre-renders OBJ line 0), refpoint_update at line starts
1..191, vblank_trigger at 192 (affine ref reload + ext-pal shadow
refill). The ce input paces the dot clock so the module can sit in a
faster GPU fabric domain; timing free-runs and nds_gpu2d drops a
drawline that lands while the previous line is still rendering.

Free-running the cadence exposed the fetch budget: at the planned
topology (GPU fabric at 3x the dot clock) the affine drawers' two
line-server round-trips per pixel took just over a line time - every
other line dropped - and the extended bitmaps re-fetched each word
per pixel. Last-word caches on the map and char/pixel fetch streams
(the text drawer's idiom; invalidated per drawline) bring every case
inside the budget.

- tb_gpu_timing: an independent arithmetic model asserts every DUT
  output on every clock edge over 4 frames - cadence, flag windows,
  both CPU buses, unreachable 9-bit matches, delayed VCOUNT writes,
  DISPSTAT write masks, per-frame pulse counts
- tb_gpu2d_timed: nds_gpu_timing free-runs nds_gpu2d + nds_vram at
  CE_DIV=3; the 4 golden frames stay pixel-exact with 0 dropped lines
  (the drop monitor doubles as the line-budget regression)
- regression green on the cluster: tb_gpu_bg 28 cases (caches are
  bit-identical under randomized 1..8-cycle latencies), line TB,
  frame TB (24% less sim time from the fetch reduction), timing TB,
  timed TB

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vs melonDS

nds_top grows from the M0 scaffold into the full system: the proven M4
dual-boot fabric (nds_cpu9 + gba_cpu, membuses, shared/private WRAM, main
RAM, IPC, IRQ, timers, syscnt) with boot sequencing as a synthesizable FSM
(loader -> PC preset via savestate buses -> CPU release), plus the engine-A
render path: nds_vram, nds_gpu_timing, nds_gpu2d, GPU vblank/hblank/vcount
IRQs, and wired KEYINPUT/EXTKEYIN. The GPU dot cadence is ce-paced at
1-of-GPU_CE_DIV (default 3, the planned 100.5/33.5 MHz topology) — at 1 the
v1 line server drops ~110 lines/frame on an affine scene, at 3 it drops none.

Plumbing that landed with it: membus9 decodes 0x05/0x07 into gpu2d's
palette/OAM write ports (reads are a documented zero-return gap), syscnt
gains the VRAMCNT_A..I register file + ARM7 VRAMSTAT.

M5 exit harness: tb_top_frame boots a .nds through nds_top and dumps every
rendered frame; melonds_fbdump boots the same image in melonDS 0.9.5 with
identical HLE-loader semantics; compare_fb.py diffs pixel-exact (RTL 555
expanded with melonDS's own 5->6->8 rules). The SDK-shaped sample
(arm9_2d.s: text BG0 with transparent holes, affine BG3, 3 OBJs incl. edge
clip, CPU-only loads, SDK crt0) renders pixel-perfect, 0 dropped lines.
Sample images must follow the melonDS PU/POWCNT rules in its header.

Regressions: run_analyze_all OK, tb_dual_boot PASS.
…erfect

NDS color special effects operate in 6-bit-per-channel space, not the
GBA donor's 5-bit: 555 palette colors expand c6 = c5 << 1 and the effects
apply melonDS's hardware-verified rounding (alpha min(63, (c1*EVA +
c2*EVB + 8) >> 4); whiter bias +8; blacker bias +7). nds_drawer_merge now
blends in 666 and outputs 18-bit BGR666 (the NDS LCD format), carried
through nds_gpu2d and nds_top; the golden models (gen_gpu_merge,
gen_gpu2d, gen_gpu2d_frame), the merge/gpu2d/frame TBs and compare_fb.py
follow. Measured before the fix: 35704/49152 pixels off by 1-2 666-LSBs
vs melonDS on a blended scene; after: pixel-perfect.

New heavy sample (build_nds_2dh.sh): BG ext palettes written via LCDC
then remapped E->MST4/F->MST5, per-tile ext palettes on a 256-color BG1,
an OBJ ext-pal sprite, alpha blending BG0+OBJ over BG1/BG3/backdrop with
EVA/EVB 9/7, and a semi-transparent OBJ — pixel-perfect vs melonDS
through nds_top (0 dropped lines in collected frames; the ~8 warm-up
drops during CPU VRAM setup traffic are the known line-server
arbitration cost, M9 prefetch item).

Regressions: run_gpu_merge, run_gpu2d, run_gpu2d_frame, run_gpu2d_timed
(0 drops), analyze-all — all PASS.
Third frame-diff sample (build_nds_2dw.sh): the ext-pal/blending scene
with all three windows and mosaic layered on. WIN0 (BG0+BG1+OBJ, effects
on), WIN1 (BG1+BG3, effects off - gates the alpha blend), an OBJ window
from a mode-2 sprite (BG3 only), WINOUT hiding BG1; MOSAIC 0x3323 with
BG h=3 v=2 on the text BG0 and OBJ h=v=3 on a sprite. Pixel-perfect
through nds_top on the first run - the window/mosaic paths the merge and
drawer unit tests covered RTL-vs-golden hold up against melonDS in the
full system.

Scene notes: OBJ mosaic keeps h==v because melonDS 0.9.5 indexes the
sprite X-mosaic table with the V size (fixed in later melonDS); the
affine BG stays mosaic-free (our affine mosaic is a documented TODO -
live-ref sampling, no melonDS parity claim yet).
… fix

sdk2d (sim/tests/sdk2d/): the first real-toolchain sample — C scene
compiled with devkitARM against libnds 2.0.2 headers, packed by ndstool
2.3.1, ARM7 at the authentic 0x037F8000. Custom crt0 stands in for
libnds/calico's (whose kernel needs the ARM7 BIOS IRQ trampoline, DMA
and engine B — the M6+ shopping list for stock examples). Scene: text +
ext-pal 256c + affine BGs, blending, WIN0/OBJWIN, mosaic, 5 OBJs.
Pixel-perfect vs melonDS through nds_top, 0 dropped lines.

The run caught a real drawer bug the hand-rolled scenes missed by luck:
OBJ H-mosaic repeated relative to the sprite's left edge (donor
behavior), but hardware/melonDS restart on the screen-aligned grid
(x mod (size+1) = 0), at sprite changes, and after transparency holes —
earlier scenes' mosaic sprites sat at grid-aligned x. nds_drawer_obj now
uses a screen-grid restart table + sprite-first marker + opaque-run
continuity; gen_gpu_obj models melonDS's post-pass and adds unaligned /
holed mosaic cases (24/25) that fail under the old rule.

Regressions: gpu_obj (26 cases), gpu2d, gpu2d_frame, analyze-all, and
the 2dw windows+mosaic scene vs melonDS — all PASS.
nds_vram grows the four engine-B renderer channels (sub-BG 128 KB from
C MST=4 / H MST=1 / I MST=1@0x8000, sub-OBJ 128 KB from D MST=4 / I
MST=2, BG ext pal B from H MST=2, OBJ ext pal B from I MST=3), reusing
the nds_vram_map decode at the 0x620000/0x660000 region addresses; the
round-robin arbiter widens to 8 channels.

The 8-channel tb_vram_ls (16 configs, 2314 reads, engine A+B windows,
overlaps, CPU differentials on all four flat spaces, concurrent
8-channel batches) caught a latent arbiter bug: rpend re-latched from
the still-held req of the request being served, re-dispatching the old
address after done; when the channel's next request (different address)
arrived while that straggler was in flight, the straggler's done pulse
answered it with the previous read's data. The drawers dodged it (their
requests are either back-to-back, which re-serves the new address
correctly, or widely spaced) - interleaved CPU reads in the TB hit the
window. rpend now never re-latches for the channel being served or just
completed; back-to-back requests still re-latch the cycle after done.

Regressions: vram_ls, gpu_obj, gpu2d, gpu2d_frame, gpu2d_timed (pod,
0 drops), analyze-all, sdk2d scene vs melonDS (pod, pixel-perfect).
nds_gpu2d gains an is_engine_b generic gating the A-only capabilities
(3D-as-BG0, DISPCNT char/screen-base blocks, the 1D-bitmap OBJ boundary
bit); the register map itself is shared. nds_top instantiates engine B
behind an address window: the io bus reaches it with bit 12 stripped so
0x1000-0x106C decodes on the common map, and with the address forced
unmatchable outside the window so both engines' wired-ors stay
exclusive. membus9 passes the palette/OAM 2 KB mirrors through whole
(word index bit 8 = engine half); nds_top splits them onto the two
instances. Engine B reads VRAM through the part-1 sub channels (drawer
addresses wrap at the 128 KB spaces) and shares the timing cadence.
tb_top_frame collects and dumps both pixel streams (DUMPFILE_B).

Validation beyond analyze/elaborate + engine-A regressions comes with
part 3: the dual-screen sample vs melonDS on both screens.
sdk2d becomes a dual-screen scene: one engine-parametrized C routine
programs A and B through their base offsets (regs +0x1000, palettes/OAM
+0x400, BG/OBJ VRAM via banks A/B vs C/D, ext-pal staging via LCDC E/F
vs H/I) with per-engine pattern variation - opposite affine rotation,
shifted sprites/window, distinct palette ramps. melonds_fbdump grows an
optional bottom-screen dump; tb_top_frame collects and dumps both pixel
streams (DUMPFILE_B). Both screens compare pixel-perfect through
nds_top, 0 dropped lines with both engines sharing the line server.

Scene rule learned the hard way: no OBJ V-mosaic in frame-diff scenes -
melonDS's OBJ mosaic-Y counter free-runs across frames (the BG one
resets each vblank), so its block anchor depends on which scanline the
MOSAIC write lands on, which is not reproducible across emulator/RTL
CPU timing. BG V-mosaic and H-mosaic stay covered.
POWCNT1 (0x04000304) lands in nds_syscnt: readable, write-masked to
0x820F, driving 2D-A/2D-B power and the LCD swap. nds_top routes the
engine streams to top/bottom screens by the swap bit, shows raw white
for a powered-off engine B (melonDS-documented quirk: engine A keeps
rendering with its bit clear), and gates palette/OAM writes by the
owning engine's power. MASTER_BRIGHT (0x6C, per engine) applies in
nds_gpu2d's output stage in 18-bit space with melonDS's exact
asymmetric rounding (up: c += ((63-c)*f)/16, bias 0; down: c -=
(c*f+15)/16, bias 0xF; factor clamped 16), after the forced-blank
white (which composites like a normal line) and skipped for display
mode 0 (white). Engine A's VRAM/FIFO display modes stay unimplemented.

Scene: POWCNT swap CLEAR (engine B on the top screen - the routing is
load-bearing), MASTER_BRIGHT up 4/16 on A / down 6/16 on B. Both
screens pixel-perfect vs melonDS; the A backdrop brightening to
exactly 0x3C888 confirmed the bias-0 up formula.
nds_dma9: 4 channels, 0x040000B0-EF incl. the FILL words, semantics per
DualSOUP dma.c + GBATEK. Enable edge latches src/dst; the word count
latches lazily when the remaining count hits 0 so repeat reloads per
trigger (and dst ctrl 3 re-latches the address then); src ctrl 3 acts
as inc+reload. Start timings 0/1/2 (immediate, vblank, hblank on
visible lines via the gpu2d cadence pulses); card/GX come with their
subsystems. Transfers master the ARM9 membus: dma_on pauses the CPU,
CPU_bus_idle gates the grant, and membus9 bypasses the ITCM/DTCM
windows while dma_bus is up (DMA cannot see the TCMs). 16-bit reads
take the rotated lane, writes replicate; IRQs 8-11 wired. Timing is
functional (one read + one write handshake per unit); the FSM is
shaped first-pair/steady-pair so the DualSOUP dma.txt measurements
can be dialed in at M9 without restructuring.

Scene: sdk2d loads tiles via dmaCopyWords(3) and the OBJ palette via
dmaCopyHalfWords(2) from uncached main RAM, BG1 tile 3 via
dmaFillWords (FILL-word source through the IO bus), plus a
vblank-repeat DMA0 with fixed src + dst-reload recoloring BG0 subpal 3
every frame — a dead vblank trigger or repeat path shows immediately.
Both screens pixel-perfect vs melonDS.

melonds_fbdump grows --direct (melonDS cart + SetupDirectBoot boot)
ahead of the stock-libnds work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nds_bios7 (generated from sim/tests/hle_bios7/bios7.s, 93 words)
serves 0x0 through membus7's bios port: GBATEK NDS7 IRQ dispatch
(save {r0-r3,r12,lr} on sp_irq, call [0x0380FFFC] with lr at the
restore, pop + subs pc,lr,SplashDev88#4 — calico's __irq_handler consumes the
six stacked words itself on a context switch, so the frame layout is
load-bearing) plus the exact SWI set a stock libnds-2.x ARM7 links
(surveyed from hello_world + libcalico_ds7.a): 0x03 WaitByLoop,
0x06 Halt, 0x07 Sleep(=Halt), 0x08 SoundBias(nop), 0x09 Div,
0x0E GetCRC16, 0x0F IsDebugger(0), 0x1F CustomHalt. The dispatcher
decodes Thumb imm8 and ARM imm24 callers (calico wrappers are Thumb,
its bootstub + libnds7 Div calls are ARM). Unknown SWIs park loudly.
svcHalt goes through HALTCNT (0x04000301, new in nds_syscnt, halt7
pulse -> gba_cpu new_halt; wake = IE&IF via the existing unhalt).
POSTFLG7/9 added as readable sticky bits while in there. The ARM9
needs no BIOS: calico ds9 installs its own ITCM vectors.

Verified: sdk2d's ARM7 now computes svcGetCRC16 over a fixed pattern,
folds in svcIsDebugger, and posts the proof word from a svcHalt +
vblank-IRQ loop dispatched through the BIOS vector — SWIs issued from
both ARM and Thumb states. The ARM9 blocks on the exact word, then
recolors BG0 subpal 1 on both engines. Both screens pixel-perfect vs
melonDS (FreeBIOS). melonds_fbdump grows a TRACE9=<path> env knob
(ARM9 instruction trace via the tracer.patch hook) for boot debug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… c13

Stock libnds-2.x (calico) ROMs crashed melonDS 0.9.5 at boot: calico's
ARM9 IRQ exit "ldmia sp!, {r0-r3,r12,pc}^" hit a 0.9.5 interpreter bug
where A_LDM does the SPSR-restore mode switch (JumpTo) before the base
writeback, landing the popped-past sp in the interrupted thread's SP
bank (traced: _pxiInit popped a corrupted frame and jumped to 8).
Commercial games return from IRQs via the BIOS subs pc,lr,SplashDev88#4, which is
why 0.9.5 got away with it. Backported the upstream fix (read pc,
write back, jump last) plus CP15 c13,c0,1 Trace Process ID (calico
stashes the active irq mask there across handler calls; unknown-reads
as 0 silently broke thread wakeups). tracer.patch now carries both -
hello_world (stock libnds 2.x) boots and renders console text under
melonds_fbdump --direct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port the headless tracer/fbdump harness to the melonDS 1.x instance API
(melonDS::NDS object, FileHandle Platform layer, heap-allocated console
- the 1.1 NDS object overflows the stack as a local). tracer.patch
shrinks 8 files -> 3: upstream 1.1 already has the LDM^ writeback
ordering, CP15 c13 Trace Process ID, byte-wide IPCSYNC, and the
ADC/SBC/RSC V-flag fix (found here first on 0.9.5). Still carried: the
trace hooks and the ARM_InstrTable EORS row typo (EORS ...,LSR #odd
runs as ROR), which is still broken upstream as of 1.1.

Validated by regenerating the hello2d (12 frames) and hello_world (20
frames) direct-boot references: frames 1+ pixel-identical to the 0.9.5
dumps on both engines; frame 0 shifts by 5 scanlines (1.1 lands console
init slightly earlier inside the transient boot frame).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same format and pipeline conventions as the ARM9 hook; used for the
dual-CPU differential trace that found the M7 direct-boot blockers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Direct-boot env synthesis in nds_loader (header copy, chip IDs, boot
flags, user-settings block) plus the four blockers that kept stock
calico/libnds-2.x ROMs (hello2d, hello_world) from booting, each found
with the new full-system dual-CPU trace diff vs melonDS 1.1:

- membus7/membus9: unclaimed NDS IO reads return 0, not GBA open bus.
  calico probes SCFG 0x04000000+ for NTR/TWL detection; the prefetch
  echo made it take the DSi init path.
- nds_top: pulse the firmware-left preset (WRAMCNT=3/POSTFLG/POWCNT)
  WITH the CPU reset release - nds_syscnt resets on resetCpu, which
  swallowed the ld_done-time pulse. WRAMCNT=0 made the crt0 section
  copy to 0x037F8000 mirror into WRAM7 over the ARM7 stack.
- nds_bios9 (generated from sim/tests/hle_bios9/bios9.s): ARM9 HLE
  BIOS at 0xFFFF0000. calico's ds9 bootstub calls svc 0x0f0000
  (IsDebugger) before installing its own vectors; the zeroed BROM sent
  the ARM9 NOP-sliding through the whole address space.
- nds_cpu9 + gba_cpu: implement ldm^ with pc (exception return) via a
  DATARW_BLOCKSWITCH state: CPSR := SPSR + bank swap one cycle AFTER
  the base writeback, so "ldmeqfd sp!, {r0-r3,r12,pc}^" leaves the
  old mode's sp updated (the ordering melonDS 0.9.5 also got wrong).
  Both vendored cores previously flagged error_cpu on it.

tb_top_frame grows TRACEFILE/TRACEFILE7/MAXINSTR generics writing
TRACE_DIFF-format retired-instruction logs from the new nds_top
dbg_export9/dbg_export7 ports.

With these the ARM9 runs instruction-identical to melonDS deep into
calico's scheduler and the ARM7 completes crt0; boot then blocks on
the (unimplemented) SPI firmware flash - next part. M6 dual-scene
sample re-verified pixel-perfect on both engines after the CPU edits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-settings CRC

nds_spi (0x040001C0) modeled on melonDS 1.1 SPI.cpp: PMIC regs w/ masks,
firmware flash 03/05/04/06 (0A bus-side only), TSC conversions matching
headless melonDS (no touch: X/Y=0, mic=0x800, else 0xFFF). Byte timing
8*(8<<baud) clk, IRQ7 bit 23, hold/chipselect-release semantics.

Firmware image: melonds_fwdump now mirrors FirmwareMem::Reset() - identity
touch calibration patched into user data + UpdateChecksums - so the dumped
image is byte-exact vs what melonDS serves at runtime (user-settings CRC
0xD739, confirmed in both ARM7 traces). nds_top serves it to nds_spi via
new fw_addr/fw_data port (hex array in tb_top_frame; HPS-staged later).

remote-sim.sh: ARTIFACTS="..." fetches files from the pod into simout/
before teardown (traces were previously lost with the pod).

Verified (hello_world, direct boot, vs melonDS 1.1): ARM7 passes the
settings CRC and idles in the same scheduler loops with identical
iteration counts; top screen pixel-perfect for 3 frames. Bottom screen
still blank: NEXT BLOCKER - ARM9 livelocks re-executing the bx lr at
0x01FF84CC after calico's IRQ-exit LDM^ (bx branches to itself instead
of lr), so consoleInit/engine-B setup never runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The DATARW_BLOCKSWITCH mode-switch/bank-swap block sat inside the
'elsif (execute_now = 1)' arm of the execute process, but in the
BLOCKSWITCH cycle execute_stall=1 routes through the stall arm - the
'or execute_RW_State = DATARW_BLOCKSWITCH' half of its guard was dead
code. CPSR/mode still switched (separate process via
execute_msr_setvalue_ena), so an IRQ exit 'ldmia sp!,{...,pc}^' landed
in system mode with the IRQ-banked sp/lr still live, and the save half
clobbered the system-bank storage with them. Symptom: calico's idle
thread 'bx lr' branches to itself forever (lr = interrupted pc), the
ARM9 livelocks at 0x01FF84CC, consoleInit/engine-B setup never runs.
Hoisted the swap block into common scope in nds_cpu9 AND gba_cpu (the
ARM7 path was equally broken - its trace diff just never reached an
ldm^ exit).

Localized with a new per-cycle pipeline dump in tb_arm9_trace
(DBG_T0/DBG_T1 generics, external names -> pipe_debug.log). Regression:
sim/tests/ldm_bx_irq - 50 timer IRQs into a calico-shaped msr/mov/bx
idle loop, exits via conditional ldm^; loop retire counts now balanced
(1228/1227/1227, previously 100/159/1961) and both island suites pass.

Full-system (hello_world direct boot): ARM7 trace parity vs melonDS
1.1 up to benign IPCSYNC poll skew; top screen pixel-perfect; engine B
now renders (black) instead of stuck forced-blank white - console text
still missing, next investigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
struffl and others added 27 commits August 11, 2026 17:54
POSTED_WRITES gates the queue the way NDS.qsf gates every other area
tradeoff, so the RTL lives in the tree and can be A/B'd instead of
sitting on a branch or being deleted.

The A/B is the useful part, and it inverted my assumption. Five fits:

  stage 2 only (ac85027)      41,556 ALMs            SUCCESSFUL   9 cyc/unit
  stage 3, POSTED_WRITES=1    4,197 / 4,200 / 4,204  failed       2 cyc/unit
  stage 3, POSTED_WRITES=0    4,214 LABs             failed       7 cyc/unit

Deleting the entire posted queue does NOT get back under 4,191. So the
queue was never the expensive half - the combinational single-cycle
fast-lane request in nds_dma9 is, and that is unconditional because it is
precisely what makes an access one cycle. Two of my three area attempts
were aimed at the wrong thing.

Consequence worth stating plainly: there is no configuration that both
fits and passes [04-02]. Passing it requires the single-cycle request,
and that is what does not fit. The ~10-25 LABs have to come from
somewhere with a named cost, not from this module.

Stage 2 alone fits and has an RBF (build/artifacts-stage2/), 20 -> 9
cycles per unit. Its worst-case slack is -3.809 on the 67 MHz island, but
the top paths are nds_membus9 target->creq_* and nds_cpu9 shiftervalue -
the known 2:1 barrel-shifter family, at a different seed than the -2.259
the ticket recorded with seed 23. Nothing new is on that list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ROM asserted an exact +2 step, so on any build without single-cycle
DMA accesses - including the stage-2 RBF that actually fits - it would
print FAIL for a core working perfectly. That is worse than useless: it
sends someone hunting a corruption bug that is not there.

These are two questions. A UNIFORM step means the datapath is sound: no
posted write lost, no merge that dropped a byte lane, no renderer
stealing a word. That is the pass/fail, and it holds whatever the DMA
costs. The step's VALUE is the cadence, reported next to it - 2 is what
silicon does and what [04-02] wants, larger is slower but not broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nds_drawer_obj divided by x_div, a signal of range 1 to 2, in three places.
A signal divisor infers a general lpm_divide even when it only ever holds 1
or 2, so Quartus built real divider fabric for what is a shift. Hoist the
HICOLOR test one level and write the two constant forms directly; x_div and
OAMfetch_x_div then have no readers and go away.

nds_vram used `mod WQ_DEPTH` for the posted-write queue ring wraps. WQ_DEPTH
is 3, not a power of two, so those are also general dividers. Explicit
compare-and-reset instead.

MEASURED across the fit sequence: lpm_divide instances 75 (artifacts-perf),
72 (pc5, pc8), 0 (pc9 onward). ALMs 41,118 -> 40,984 over that step. The
divider count is the honest part of that number; 134 ALMs is near the fitter's
own seed-to-seed spread, so do not read it as a precise area saving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 67 MHz domain was missing by 2.4 ns with -575 ns of total negative slack.
Four cuts, each measured on its own fit (artifacts-codex-pc13..pc16-s0):

* PC writes leave the generic writeback mux. pcwrite_fetch used to be
  "any writeback to r15", which let UNPREDICTABLE r15 destinations on
  multiply and base-writeback paths fan into the fetch address. It is now
  two named states - alu_pcwrite_wait for ALU results and DATARW_PCWRITE for
  load/block-load results - and pcwrite_Addr reads execute_RW_dataRead, an
  existing timing boundary, instead of the live ALU result. Costs one beat
  on ALU-writes-PC and LDR/LDM-to-PC.

* DATARW_ADDRWAIT registers register-offset LDR/STR addresses before issue,
  so the five-level barrel shift and the address adder no longer sit in
  front of the membus decode. Immediate and base-only forms keep their old
  timing. (pc16)

* SBC/SUB share one 34-bit chain. The separate 32-bit `alu_op1 >= alu_op2`
  comparator behind the shifter is gone; bit 33 is ARM's no-borrow carry.
  (pc15)

* The DMA9->membus9 bridge registers the whole request payload rather than
  just narrowing the enable pulse. The DMA fast IO and posted-VRAM lanes
  bypass this bridge, so the exact 2-cycle/unit cadence is unaffected -
  reconfirmed by sim/tests/dmaprio after the change.

Two preserve attributes hold decode_Rn_op1 and execute_RW_dataRead against
physical retiming, which otherwise rebuilds the full path. Preserving the
whole register bank was counterproductive and preserving decode_RM_op2 cost
0.844 ns (pc11).

Setup slack at 67.028 MHz: -2.376 (pc13), -1.444 (pc14), -1.228 (pc15),
-1.008 (pc16). TNS -575 -> -26.6. STILL NOT CLOSED: Fmax is ~62.8 MHz. The
residual is the barrel shifter itself feeding membus9|state - candidate cut
3 in docs/TICKET-arm9-2to1-timing.md, which is untouched here.

Green on the current tree: arm9_island 12/12 (its LDR now uses a shifted
register offset on purpose), arm9_cache 8/8, shifter_equiv 294,912 cases,
mosaic_equiv, vram_torture, mainram, vram_ls, and the full dmaprio ROM at
2 cycles/unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One setup cut: membus9's VRAM outputs to DMA9. The two masters share a muxed
port but are mutually exclusive under dma_bus_on, which TimeQuest cannot infer,
so it reports a 67 -> 33 MHz path that cannot exist. Only that source/
destination pair is cut; the real CPU-island <-> DMA register-bus crossings
stay timed.

Four hold cuts on related-clock payloads whose destination enables are
generated by edge detectors or by clkMemIndex phase gating, and so cannot fire
on the coincident edge being checked. Setup checks are kept in every case.

CAVEAT, recorded because the next person needs it: these are the reason pc14's
-0.395 ns hold failure is absent from pc15 onward. That failure was removed by
assertion, not by RTL, and it is on the main-RAM path - if the clkMemIndex
argument above is wrong the symptom is corrupted reads on silicon, not a
failing testbench. The reasoning has not been checked by anyone but its author
and has not been confirmed on hardware. Re-derive it before trusting an RBF
built with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A hold failure showed up in pc14 with no endpoint detail to act on - the
default STA report carries only the per-clock summary. Add report_timing
-hold to the pod's TCL and NDS.paths_hold.rpt to the retrieved artifact list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checkpoint 4 read the 32 KB mirror through a plain register offset, which
does not touch the barrel shifter. Use LDR [r0, r5, LSL SplashDev88#2] to reach the same
0x7FFC word, so the check exercises the addressing path DATARW_ADDRWAIT cuts.
Still 12/12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NDS_ANALOG_AUDIO_ONLY drops the I2S and S/PDIF serializers; NDS_NO_AUDIO_FILTER
is extended to bypass the two 40-bit DC blockers as well as the IIR low-pass.
Measured cost of the removed serializers: 71 combinational ALUTs, 102
registers.

TWO CAVEATS, both against this change and both recorded rather than silently
carried:

1. The NDS.qsf rationale says I2S and S/PDIF "have no sink here". That is
   wrong for S/PDIF. sys_top.v:1553 drives AUDIO_SPDIF - the optical output on
   the analog I/O board this profile requires - from this signal, and :1548
   uses it for SDCD_SPDIF when mcp_en. Forcing spdif to 0 therefore removes
   working optical audio, it does not remove dead logic. Any core built with
   this macro has no TOSLINK output.

2. 71 ALUTs is roughly 7 LABs. Commit 7e87734 established, from five seeds on
   a fixed design, that this image's fitter spread is 4,192..4,215 LABs - so a
   ~7 LAB saving is below the level at which a single fit can measure
   anything. The same applies to the DC-blocker bypass, which additionally
   changes analog output post-processing on a path nds_sound is only asserted,
   not measured, to leave bias-free.

Committed separately so that `git revert` on this one commit restores both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…thod

Replaces the two GPL framework modules NDS.sv instantiated for video scaling
and HPS transport, and relicenses three core-authored files that carried a
GPL-2.0 SPDX tag by inheritance (pll_video.v, nds_audio_ddr3.sv,
hps_lw_bridge.sv).

METHOD CORRECTION, which is the substance of this commit. The video_freak
replacement was originally shipped as clash/rtl/nds_clash_video_freak.sv, whose
own header described it as "a straight register-for-register port of" the GPL
original. A port is a derivative work: copyright covers the expression, and
reproducing a module register by register preserves that expression whatever
language it is re-expressed in and whatever the module is renamed to. Shipping
it did not remove the GPL dependency, it renamed it.

The genuinely clean artifact was already in the tree, as the test oracle -
video_freak_reference.sv, written fresh from the observed timing contract
without the GPL source, sharing zero lines with it. The two roles are now the
right way round:

  clash/rtl/nds_video_freak.sv               ships (independently written)
  clash/tests/rtl/video_freak_port_oracle.sv test-only (the derivative port)

Equivalence is unchanged and re-verified after the swap: 2,500,000 cycles,
5 frames, config changes including non-zero crop, three seeds (1badf00d,
c0ffee, 5eed42) - all PASS. Port lists are identical, so NDS.sv's .* connection
is untouched.

run_video_freak_diff.sh defaulted to CYCLES=200000, which processes ZERO frames
and still prints PASS. Default raised to 2,500,000 so the obvious invocation
proves something.

hps_io is NOT given the same treatment here and still needs it: nds_hps_io.sv
was validated against clash/tests/rtl/hps_io_oracle.sv, a checked-in copy of
the GPL hps_io, and its own provenance relative to that source has not been
established. Its differential fuzz is also only 1,805 cycles.

AUDIT: the claim that this work leaves "zero GPL in build" is false, by a wide
margin. NDS.qsf:11 sets TOP_LEVEL_ENTITY sys_top, and sys/sys_top.v is GPL.
NDS.qsf:269 -> sys/sys.tcl:219 adds QIP_FILE sys/sys.qip, which compiles 34
sys/ files including the very hps_io.sv and video_freak.sv this commit stops
instantiating. Measured from artifacts-codex-pc16-s0, the GPL modules actually
present in the bitstream and their cost:

  hps_io:legacy                503.6 ALMs   (removed by this commit)
  osd:vga_osd                  464.8 ALMs
  audio_out:audio_out          214.3 ALMs
  vga_out:vga_out              119.2 ALMs
  mcp23009                      90.7 ALMs
  i2c:i2c                       61.9 ALMs
  f2sdram_safe_terminator x2    61.7 ALMs
  gamma_corr                    30.1 ALMs
  scanlines                     25.0 ALMs
  sigma_delta_dac x2            19.0 ALMs

plus sys_top.v itself, which is the top-level entity and cannot be scrubbed by
replacing submodules. rtl/sdram.sv and rtl/ddram.sv remain GPL and instantiated
(NDS.sv:837). The rtl/nds_*.vhd files carry GPL tags from the GBA core fork;
if they are in fact derived from it, that is not a licensing decision that can
be made by editing headers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First module of the leaf group. Replaces sys/sigma_delta_dac.v (19.0 ALMs
across two instances) on the analog audio path, which is the only audio path
this fit profile has.

Written from the published second-order error-feedback modulator structure, NOT
transcribed from the GPL module, and deliberately not bit-identical to it. For a
1-bit output feeding an RC filter, cycle agreement is not observable; the DC
transfer and the noise floor are. So the test measures those, on both modules,
with identical stimulus, and the GPL one is included only as a black-box
benchmark with absolute pass criteria rather than "matches the oracle".

MEASURED (clash/tests/run_sigma_delta_tb.sh, 16 codes, prime stride so none
lands on an exact 1/16):

  worst DC deviation   0.0 LSB      vs GPL 0.0 LSB
  monotonicity breaks  0            vs GPL 0
  worst idle noise     67.7 LSB     vs GPL 57.8 LSB

DC tracking is exact. The idle noise floor is ~1.4 dB WORSE than the module it
replaces - it passes the criterion but it is not a tie, and it is the honest
number rather than a rounded one. Tunable via the integrator clamp if it ever
matters; at this level it is far below the 16-bit signal.

Two bugs the test caught and that are worth recording, because both would have
shipped silently:

* A second-order loop with a 1-bit quantiser is only stable well inside full
  scale. Driven to the rails the integrators ran away and code 0 produced a
  duty of 0.428 where it must produce 1.0. Fixed by clamping both integrators,
  which keeps full-scale range instead of trading 6 dB away for stability.
* The output must be the quantiser decision that PRODUCED the feedback - the
  pre-update integrator sign. Sampling the post-update value delays the output
  one sample relative to its own error term.

The test itself also had to be fixed twice: its ideal transfer was inverted
(INV=1 means duty FALLS as the code rises - the GPL module reading 0.0 LSB at
every code is what confirms the contract), and its first code sweep used exact
multiples of FS/16, which makes the bitstream perfectly periodic so every
decimation block is identical and the noise figure reads a meaningless 0.0 for
both modules. Same failure mode as the video_freak default of CYCLES=200000
fixed in 09f9ae7: a test that passes without measuring anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tb_arm9_trace served brom_data combinationally. nds_membus9 latches the
fetch result on the next clk1x edge and the real BIOS is an M10K with a
registered read port, so every fetch returned the word at addr+4: each
boot-ROM workload executed word[1] of its own vector table as its first
instruction, branched into a `b hang`, and parked there. ldm_bx_irq,
arm9_island, arm9_cache and arm9_torture were all mute for this reason.

Two things kept it hidden. There was no verdict - the harness only wrote a
30k-line trace, so a workload that failed and a harness that never started
one looked identical from outside. And tb_arm9_island, the same island with
the read port done right, kept passing 12/12, so the RTL always looked fine.
Both testbenches now finish the island at the same sim time, 35465 ns.

Also re-synced drift against tb_arm9_island:
  - mem9_pair / mem9_readdata_hi / sdram_done64 have been unwired since
    53bf403, so cache line fills took a zeroed high word in every odd slot.
    The island tb carries a comment warning about this exact omission.
  - timers and nds_irq ran at ce='1' rather than ce_half, ticking every
    timer at twice its rate - which rewrites the workload of any IRQ test.

And gave it the verdict it never had: MARKBASE declares the workload's
marker block, and the tb turns 0xCAFEBABE / 0x0BAD0BAD into PASS/FAIL with a
non-zero exit. Writing NO marker is a failure too, so a dead harness cannot
pass for "nothing to report" again. The block address is required because a
bare value snoop also catches the stmdb that spills a register holding
0xCAFEBABE onto the stack.

ldm_bx_irq now passes its documented contract: markers at 0x02001004,
0x02001008 and 0x0200100C, no fail marker. The pre-rework CPU (857835f)
passes it identically, so the fb80923 timing rework does not break exception
returns.

The LOADADDR path was never affected - it boots from main RAM - so the
melonDS differential results stand, with the exception of arm9_torture,
which is a boot-ROM workload. TRACE_DIFF.md's run line omitted LOADADDR
entirely, which runs the two sides on different memory maps; fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GBATEK names the NDS bit "OBJ Processing during H-Blank (was located in
Bit5 on GBA)": SET = the OBJ engine gets the HBlank interval = 1210
cycles, CLEAR = 954. The donor port read it as the GBA's bit 5 "H-Blank
Interval Free" (SET = the CPU takes the interval = 954) - the opposite
meaning, so a bit-23 game drew its OAM under the 954 budget and lost the
tail of every sprite-heavy line, steady, per frame.

Invert the bit at the integration point (nds_gpu2d), rename the register
constant to what it is (DISPCNT_OBJ_HBlank_Proc), and replace the
"POLARITY IS UNVERIFIED" comment in the drawer with the verified
statement.

Pinned by sim/run_gpu_obj_budget.sh: a 1024-hw-cycle line (16 x 64 px
sprites) that sits between the two budgets - full render at 1210, sprite
14 truncating after 58 px and sprite 15 dropped at 954. A line under 954
would prove nothing and one over 1210 hides the difference, so the cost
is chosen for the asymmetry. Analyze + elaborate in the smoke gate.
NDS_HARDWARE.md gains the budget/charge model: 1 cycle per field pixel
(the sprite's whole field width, clip elisions charged at setup/walk-end),
rot/scal 10 + 2 per pixel, and a line over budget loses its LAST sprites
in OAM order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
melonDS's software rasterizer running on the DE10-Nano's Cortex-A9,
answering the one question that decides the hybrid 3D core's
architecture: can the HPS ARM rasterize a DS frame inside a 16.67 ms
budget? Everything else in the plan (DDR3 rings, the fabric matrix
shadow, the VRAM bank mirror) is only worth building if the answer is
yes, or yes-at-30-Hz.

main_gxbench.cpp (new melonds_gxbench target) builds natively to capture
a scene savestate on the desktop, where 900 frames take seconds;
build-armhf.sh cross-builds the board binary in a
crossbuild-essential-armhf docker container, no qemu. The toolchain
image is tagged and cached, so only the first run pays for apt.
tools/a9-raster-bench.sh drives the board run. The ROM must be on the
board even with a savestate: melonDS states carry machine state, not
cart contents.

Not yet timed: no numbers in the tree, and the scene must sit on a busy
3D screen - the bench prints the polygon count it found and a menu
capture makes the timing meaningless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tput

/work/ is nvc's default library dir when a runner is invoked from the
repo root; sim/nvc_work_*/ already covered the named variants.
build-native/ and build-armhf/ are the melonds_tracer build trees beside
build/. melonds_trace.log and scene.mln are melonDS harness output - the
savestate is generated from a copyrighted ROM, same class as kirby_*.hex.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The device budget and the predict-in-LABs discipline, the two mutually
exclusive shipping images with their measured costs, the remote workflow
(pods, breakpoint-bisect over overnight sims), the memory strategy, the
clock plan, eight day-costing traps, and the verification discipline.
Supersedes the status lines in README/ROADMAP where they disagree; the
Open section is the live task list. Docs go stale faster than the tree -
check the RTL before repeating a doc's claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clocks, hierarchy, memory topology, the two address maps, the renderer
request chain, where the documents disagree with the RTL, and what is
not built yet. Standalone: the hosted artifact wrapper's bits are
inlined, so it also works offline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Import the working source snapshot supplied by SplashDev88 on 2026-09-02 on top of public main 306abb8. It contains post-beta.6 HPS Engine B, display-capture, firmware, and external-screen work.

The bundled beta.6 SHA256 manifest predates 54 modified files in this donor tree, so this commit records an uncommitted development snapshot rather than claiming release-artifact verification.
Merge SplashDev88's 2026-09-02 HPS Engine B and display-capture development tree into the shared Nitro_DarkSide provenance line.

The donor remains an explicitly attributed second parent. Host RTL gates pass on the reconciled tree; HPS, Quartus, and hardware proof are tracked separately.
@struffl

struffl commented Sep 3, 2026

Copy link
Copy Markdown
Author

aww hell GPT did NOT open this without me asking LMAOOOO oops uhhhh lemme make sure this is actually good lmao

@struffl

struffl commented Sep 3, 2026

Copy link
Copy Markdown
Author

Here's a build that actually completed plus the reports for it and all that

hps-engine-b_artifact_RBF.zip

@struffl
struffl marked this pull request as ready for review September 3, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants