diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef76a4d..0d024646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,25 @@ 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 + 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`. + ### 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..2c6327ea --- /dev/null +++ b/crates/rustynes-test-harness/tests/fds_takuikaninja.rs @@ -0,0 +1,133 @@ +//! 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 = 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 {}: {e} (fetch it from the TakuikaNinja \ + GitHub release and place it there — see this file's module doc).", + disk_path.display() + ); + 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) |