From 53c23257fb241ff9ad08f0c842b5c606d94b2474 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 15 Jul 2026 00:43:07 -0400 Subject: [PATCH 1/2] test(fds): wire TakuikaNinja's FDS hardware-verification probes The user surfaced NESdev-scene news about newly-published FDS test ROMs from TakuikaNinja covering $4023 register behavior and nametable mirroring. Investigation confirmed RustyNES already correctly models and independently unit-tests both ($4023 master I/O-enable logic at fds.rs:2187-2211, clear_4023_stops_and_acks; mirroring via the FDS control-byte bit 3, deliberately not subject to the game_database.txt mirroring-override hazard since FDS has zero DB entries) -- so this is regression insurance against a second, hardware-verified oracle, not a fix for a functional gap. Sourced and verified all four of TakuikaNinja's published probes directly against their GitHub release assets (not the unreliable auto-summarized wiki text): FDS-Mirroring-Test ($4025.D3 write, $4030.D3 read, $4023.D0=0 nametable-arrangement reset), FDS-4023-Test ($4020-$409F register readback while toggling $4023 bits 0/1), FDS-Audio-Registers (audio register readback while toggling $4023.D1 during wavetable playback), and FDS-4030D1-Addr (DRAM-refresh-watchdog IRQ timing via $4030.D1 -- explicitly under-research upstream and not modeled by most current FDS emulators, RustyNES included). None of the four carries an explicit permissive license (no LICENSE file, nothing stated in any README), which falls short of the bar tests/roms/ (committed) requires. Staged them gitignored under tests/roms/external/fds-takuikaninja/ instead, mirroring the existing commercial-ROM convention -- verified gitignored via `git check-ignore`, nothing redistributed via git. Added crates/rustynes-test-harness/tests/fds_takuikaninja.rs: four RUSTYNES_FDS_BIOS-gated smoke tests (mirroring the existing fds_irq_tests_with_real_bios pattern in fds.rs) that skip cleanly with a printed notice when the BIOS or the specific probe disk is absent, so CI stays clean by default. Verified both paths locally: the skip path (no BIOS) and the real-BIOS path (against the already-staged local disksys.rom) both construct, run 600 frames, and complete without panicking against all four real hardware-verification disk images. Deliberately assert only construction + a bounded run complete -- these ROMs render pass/fail state as on-screen text and register dumps, and decoding that programmatically is a follow-up once the exact screen layout is confirmed against real hardware captures; asserting a specific outcome now would claim precision this harness does not actually verify. Documented the new tests/roms/external/fds-takuikaninja/ subdirectory in tests/roms/external/README.md (a gitignored, untracked local file -- not part of this commit) and added a new docs/accuracy-ledger.md row tracking the $4030.D1 DRAM-watchdog residual as an honest, tracked-not-asserted gap rather than a silently-passed claim. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 18 +++ .../tests/fds_takuikaninja.rs | 136 ++++++++++++++++++ docs/accuracy-ledger.md | 1 + 3 files changed, 155 insertions(+) create mode 100644 crates/rustynes-test-harness/tests/fds_takuikaninja.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef76a4d..8e3e4de6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,24 @@ cycle-accurate core later replaced. Dev / research tooling only — paths resolve repo-relative, and it touches no crate and does not affect the build or the deterministic core. +- **`TakuikaNinja` FDS hardware-verification probes wired in (gated, + gitignored).** Added `crates/rustynes-test-harness/tests/fds_takuikaninja.rs` + with four `RUSTYNES_FDS_BIOS`-gated smoke tests against + `FDS-Mirroring-Test`, `FDS-4023-Test`, `FDS-Audio-Registers`, and + `FDS-4030D1-Addr` — real hardware-verified probes of `$4023`/mirroring/audio + register behavior and the FDS DRAM-refresh-watchdog IRQ. None of the four + carries an explicit permissive license, so they're staged gitignored under + `tests/roms/external/fds-takuikaninja/` (fetched from the author's GitHub + releases) rather than committed, mirroring the existing commercial-ROM + convention; every test skips cleanly when the BIOS or a probe disk is + absent, keeping CI clean by default. The underlying `$4023` and mirroring + behavior these probe is already implemented and unit-tested independently + in `crates/rustynes-mappers/src/fds.rs` — this is regression insurance + against a second, hardware-verified oracle, not a fix for a gap. The + `$4030.D1` DRAM-watchdog probe tracks a known, honest residual (not yet + modeled by RustyNES or, per upstream, by most current FDS emulators) — + see `docs/accuracy-ledger.md`. + ### Changed - **Dependency consolidation (PR #305 — closes Dependabot #298–#303).** Rolled diff --git a/crates/rustynes-test-harness/tests/fds_takuikaninja.rs b/crates/rustynes-test-harness/tests/fds_takuikaninja.rs new file mode 100644 index 00000000..f806c3d6 --- /dev/null +++ b/crates/rustynes-test-harness/tests/fds_takuikaninja.rs @@ -0,0 +1,136 @@ +//! BIOS-gated smoke tests against `TakuikaNinja`'s FDS hardware-verification +//! probes (`FDS-Mirroring-Test`, `FDS-4023-Test`, `FDS-Audio-Registers`, +//! `FDS-4030D1-Addr` — see the `NESdev` Wiki "Emulator tests" page). +//! +//! None of these four ROMs carries an explicit permissive license (no +//! `LICENSE` file, nothing stated in any README as of this writing), so +//! unlike the rest of `tests/roms/` they are **not committed** — they live +//! gitignored under `tests/roms/external/fds-takuikaninja/`, the same +//! copyright-ambiguous staging convention used for commercial ROM dumps. +//! Every test here is a no-op skip (with a printed notice) unless both the +//! real FDS BIOS (`RUSTYNES_FDS_BIOS`, mirroring `fds.rs`) and the specific +//! probe disk are present on disk, so CI stays clean by default. +//! +//! These probe the exact `$4023`/mirroring behavior that +//! `crates/rustynes-mappers/src/fds.rs` already implements and unit-tests +//! independently (`clear_4023_stops_and_acks` et al.) — they are +//! regression-insurance against a second, hardware-verified oracle, not a +//! fix for a known gap. As with `fds_irq_tests_with_real_bios`, we assert +//! only that construction + a bounded run complete without panicking; these +//! ROMs render pass/fail state as on-screen text/register dumps, and +//! decoding that programmatically (framebuffer or nametable text-scrape) is +//! a follow-up once the exact screen layout is confirmed against real +//! hardware captures — asserting a specific outcome without that would +//! claim precision this harness does not actually verify. +//! +//! `FDS-4030D1-Addr` in particular probes the FDS DRAM-refresh-watchdog IRQ +//! (`$4030.D1`), which upstream research (and every current FDS emulator, +//! per the `NESdev` Wiki) explicitly notes as unimplemented/under-research; +//! `RustyNES` does not model it either, so that probe is expected to show +//! "not observed" (`XXXX`) rather than a specific timing value — this test +//! exists to track that honest residual, not to assert it away. +//! +//! Run all four (real BIOS + all four probe disks present): +//! ```text +//! RUSTYNES_FDS_BIOS=/path/to/disksys.rom \ +//! cargo test -p rustynes-test-harness --features test-roms --test fds_takuikaninja -- --nocapture +//! ``` + +#![cfg(feature = "test-roms")] + +use rustynes_core::Nes; + +const BIOS_LEN: usize = 0x2000; + +/// Shared skip-gated runner: reads the BIOS + the named probe disk from +/// `tests/roms/external/fds-takuikaninja/`, constructs, runs `frames` +/// frames, and prints a diagnostic. Returns early (with an `eprintln!` skip +/// notice) if either file is absent — the CI convention already established +/// by `fds_irq_tests_with_real_bios`. +fn run_probe(test_name: &str, rom_filename: &str, frames: u64) { + let Ok(bios_path) = std::env::var("RUSTYNES_FDS_BIOS") else { + eprintln!( + "SKIP {test_name}: set RUSTYNES_FDS_BIOS=/path/to/disksys.rom to run the \ + TakuikaNinja FDS probes (BIOS is never committed)." + ); + return; + }; + let bios = match std::fs::read(&bios_path) { + Ok(b) if b.len() == BIOS_LEN => b, + Ok(b) => { + eprintln!( + "SKIP {test_name}: BIOS at {bios_path} is {} bytes, expected {BIOS_LEN}.", + b.len() + ); + return; + } + Err(e) => { + eprintln!("SKIP {test_name}: cannot read {bios_path}: {e}"); + return; + } + }; + + let disk_path = format!( + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/roms/external/fds-takuikaninja/{}" + ), + rom_filename + ); + let disk = match std::fs::read(&disk_path) { + Ok(d) => d, + Err(e) => { + eprintln!( + "SKIP {test_name}: cannot read {disk_path}: {e} (fetch it from the TakuikaNinja \ + GitHub release and place it there — see this file's module doc)." + ); + return; + } + }; + + let mut nes = Nes::from_disk(&disk, &bios).expect("FDS construct with real BIOS"); + for _ in 0..frames { + nes.run_frame(); + } + eprintln!( + "{test_name}: ran {frames} frames with real BIOS ({rom_filename}). Mapper debug: {:?}", + nes.mapper_info() + ); +} + +/// `FDS-Mirroring-Test`: `$4025.D3` write, `$4030.D3` read, and the +/// previously-undocumented `$4023.D0=0` nametable-arrangement reset. +#[test] +fn fds_mirroring_test_with_real_bios() { + run_probe( + "fds_mirroring_test_with_real_bios", + "mirroring-test.fds", + 600, + ); +} + +/// `FDS-4023-Test`: read-only register states ($4020-$409F) while toggling +/// bits 0/1 of `$4023`. +#[test] +fn fds_4023_test_with_real_bios() { + run_probe("fds_4023_test_with_real_bios", "4023-test.fds", 600); +} + +/// `FDS-Audio-Registers`: FDS audio register readback while toggling +/// `$4023.D1` during wavetable playback. +#[test] +fn fds_audio_registers_with_real_bios() { + run_probe( + "fds_audio_registers_with_real_bios", + "audio-registers.fds", + 600, + ); +} + +/// `FDS-4030D1-Addr`: DRAM-refresh-watchdog IRQ timing reported by +/// `$4030.D1`. Not modeled by `RustyNES` (nor, per upstream, by most current +/// FDS emulators) — this probe tracks that known, honest residual. +#[test] +fn fds_4030d1_addr_with_real_bios() { + run_probe("fds_4030d1_addr_with_real_bios", "4030d1-addr.fds", 600); +} diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index f4828525..d186a7ab 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -39,6 +39,7 @@ disposition under the v2.1.0 "Fathom" accuracy-remediation line | NSF non-60 Hz playback + NSFe | **Done** (F4.1/F4.2): the play-speed divider (`$6E-$6F`/`$78-$79`) is parsed and a non-standard rate (PAL 50 Hz / custom µs) drives `play` via a mapper cycle-timer IRQ (frame-IRQ-disabled); standard 60 Hz keeps the byte-identical vblank-NMI path. `NSFE` chunked container parsed (INFO/DATA/BANK/auth). | `nsf` unit + core integration tests | **Done** (F4.1/F4.2) | | FDS medium model (F4.3) | Byte-stream wire medium: gap / `$80` mark / block / **CRC-16-KERMIT** per block, with **per-block CRC re-emitted on write** (`resynth_block_crc`) and an opt-in **continuous belt-velocity head-seek** model (distance-proportional re-seek, default-off) replacing the fixed-cycle window | **CI-verifiable (synthetic):** `medium_write_verify` BIOS-free oracle — write via the register path, re-walk the wire, assert every block's CRC-16 + gap/mark framing round-trips (`fds::tests::synthetic_write_verify_*`). **Local-only:** the real-BIOS write-CRC path (BIOS recomputes CRC in its own RAM → `$4024`) needs a copyright `disksys.rom`, kept in gitignored `tests/roms/external/` and out of CI | **Shipped (v2.2.0 "Capstone")** — additive: default (model-off, non-writing) `.fds` run is **byte-identical**; new state round-trips the **v4** save-state tail. AccuracyCoin has no FDS ROM, so 141/141 is unaffected | | Famicom microphone ($4016.2) | Not modeled | Built-in controller-2 mic bit surfaced on `$4016` D2 (`Nes::set_microphone`); `famicom_microphone_drives_4016_bit2` bus unit test | **Shipped (v2.2.0)** — additive / default-off (mic released ⇒ `$4016` byte-identical); a `$4016`-only signal (never touches `$4017`). No pass/fail mic ROM exists (real-cart local test only) | +| FDS DRAM-refresh-watchdog IRQ (`$4030.D1`) | Not modeled. The FDS BIOS/hardware raises periodic IRQs tied to DRAM-refresh-row-vs-access cycle accounting while `$4023.D0=0`; per upstream (`TakuikaNinja`'s `FDS-4030D1-Addr` research, `NESdev` Wiki) this is itself still under active hardware research and not modeled by most current FDS emulators either | `TakuikaNinja`'s `FDS-4030D1-Addr` probe (gitignored, `tests/roms/external/fds-takuikaninja/`, no permissive license — see `tests/roms/external/README.md`), consumed by the `RUSTYNES_FDS_BIOS`-gated `fds_4030d1_addr_with_real_bios` test | **Out of scope / honest residual, tracked not asserted** — the gated test only proves construction + a bounded real-BIOS run complete without panicking; it does not assert a specific watchdog timing value, since neither RustyNES nor the public research has pinned one yet. Revisit if/when upstream hardware research settles the exact behavior | | Zapper light-timing | Single-pixel per-frame framebuffer sample | **Photodiode aperture** (3×3 field-of-view, ≥2 bright pixels — `ZAPPER_APERTURE_*`) vs the PPU per-dot output; `zapper_light_detected_for_bright_region` / `zapper_aperture_rejects_lone_bright_pixel` unit tests | **Hardened (v2.2.0)** — deterministic (pure fn of framebuffer + aim), no save-state change. No redistributable pass/fail Zapper ROM exists; the temporal ~19-26-scanline hold is finer than per-frame sampling (supported titles re-poll every frame) — full per-dot temporal integration is a documented future refinement (`docs/frontend.md`) | | BestEffort mapper tier (26 families, was 112) | Register-decode + save-state round-trip only; off the oracle gate | `mapper_tier_honesty.rs` invariant | **Mostly remediated** (F3): 86 promoted to Curated with commercial-ROM oracle; the 26 left have no cleanly-booting dump (16 NES 2.0 high-id + 8 no-cart + 2 jam-at-boot) | | MMC3 R1/R2 scanline-IRQ (ADR 0002) | ≤1-CPU-cycle differential on 4 `#[ignore]`'d sub-tests; zero game impact | `mmc3_test_2/4` #3 + siblings; `mmc3_r1r2_phase_probe` A12-phase golden probe (v2.1.5, `--features mmc3-a12-phase-probe`) | **CLOSED for the shipping default; axis-B candidate deferred to maintainer** (F5.0, ADR 0002). v2.1.5 direct instrumentation refined the closure: "no post-access qualifying rise" is ROM-specific (holds for the two `scanline_timing` #3 residuals, `irq_post=0`; **false** for `mmc3_test_v1/5`+`/6` #2, `irq_post=4` — post-access IRQ-clocking rises Session B never measured). Every *tested* lever stays non-curative (incl. the `mmc3-m2-phase-irq` deferral, byte-identical status on `/5`+`/6`); the four pins stay `#[ignore]`'d. One untested lever — an ares-style M2-edge-precise falling-edge low-time filter — is deferred to a maintainer decision (needs a sacred-gate-risking substrate change to prototype) | From 9a2a7d39ab09e07788548f1277fd4846cb67b9a0 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 15 Jul 2026 01:03:57 -0400 Subject: [PATCH 2/2] fix(fds): address review findings on the TakuikaNinja probe PR copilot-pull-request-reviewer caught a grammar error in the CHANGELOG entry ("behavior these probe is" -- fixed to "behaviors these probes exercise are"). gemini-code-assist caught a cross-platform path hazard in fds_takuikaninja.rs: the disk path was built via string concat!/format! with a hardcoded forward slash, which is a portability smell on Windows (part of this project's CI matrix). Switched to PathBuf::from(...).join(...).join(...), using .display() in the skip eprintln! since PathBuf doesn't implement Display directly. Re-verified both the skip path and the real-BIOS path locally after the change -- unchanged behavior, still 4/4 passing either way. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 7 ++++--- .../tests/fds_takuikaninja.rs | 15 ++++++--------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e3e4de6..0d024646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,9 +39,10 @@ cycle-accurate core later replaced. releases) rather than committed, mirroring the existing commercial-ROM convention; every test skips cleanly when the BIOS or a probe disk is absent, keeping CI clean by default. The underlying `$4023` and mirroring - behavior these probe is already implemented and unit-tested independently - in `crates/rustynes-mappers/src/fds.rs` — this is regression insurance - against a second, hardware-verified oracle, not a fix for a gap. The + behaviors these probes exercise are already implemented and unit-tested + independently in `crates/rustynes-mappers/src/fds.rs` — this is regression + insurance against a second, hardware-verified oracle, not a fix for a + gap. The `$4030.D1` DRAM-watchdog probe tracks a known, honest residual (not yet modeled by RustyNES or, per upstream, by most current FDS emulators) — see `docs/accuracy-ledger.md`. diff --git a/crates/rustynes-test-harness/tests/fds_takuikaninja.rs b/crates/rustynes-test-harness/tests/fds_takuikaninja.rs index f806c3d6..2c6327ea 100644 --- a/crates/rustynes-test-harness/tests/fds_takuikaninja.rs +++ b/crates/rustynes-test-harness/tests/fds_takuikaninja.rs @@ -70,19 +70,16 @@ fn run_probe(test_name: &str, rom_filename: &str, frames: u64) { } }; - let disk_path = format!( - concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../tests/roms/external/fds-takuikaninja/{}" - ), - rom_filename - ); + let disk_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/roms/external/fds-takuikaninja") + .join(rom_filename); let disk = match std::fs::read(&disk_path) { Ok(d) => d, Err(e) => { eprintln!( - "SKIP {test_name}: cannot read {disk_path}: {e} (fetch it from the TakuikaNinja \ - GitHub release and place it there — see this file's module doc)." + "SKIP {test_name}: cannot read {}: {e} (fetch it from the TakuikaNinja \ + GitHub release and place it there — see this file's module doc).", + disk_path.display() ); return; }