From b2a95ab72758f1545d19dae7f8bfb87ab3afe7fe Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 12 Jul 2026 00:11:23 -0400 Subject: [PATCH 1/4] feat(fds,input): FDS medium model completion + Famicom mic + Zapper aperture Emulation capstone (v2.2.0). FDS medium model: continuous belt-velocity head-seek model (opt-in, default-off, byte-identical fixed-window default), per-block CRC-16 re-emission on write, a BIOS-free synthetic write-verify oracle (medium_write_verify), and an additive v4 save-state tail. Peripherals: Famicom controller-2 microphone modeled on $4016 bit 2 (default-off, byte-identical), and a 3x3 photodiode-aperture Zapper light-sample replacing the single-pixel model (deterministic, no save-state change). Co-Authored-By: Claude Opus 4.8 --- crates/rustynes-core/src/bus.rs | 55 ++- crates/rustynes-core/src/input_device.rs | 120 ++++-- crates/rustynes-core/src/nes.rs | 11 + crates/rustynes-mappers/src/fds.rs | 441 +++++++++++++++++++++-- 4 files changed, 581 insertions(+), 46 deletions(-) diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index 05ebc1a4..2d76cb0c 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -434,6 +434,17 @@ pub struct LockstepBus { /// the default + Four Score reads and the determinism contract are /// unaffected unless a device is explicitly attached. expansion_device: [Option; 2], + /// Famicom built-in **microphone** signal (v2.2.0 "Capstone"). The hardwired + /// second Famicom controller carries a push-to-talk microphone whose state is + /// read on **`$4016` bit 2** (not `$4017`) — games such as *The Legend of + /// Zelda* (killing Pols Voice), *Kid Icarus*, *Raid on Bungeling Bay*, and + /// *Takeshi no Chōsenjō* poll it. Modelled as a single live bit (the analog + /// mic is quantized to "loud enough / not" by the frontend, matching how the + /// Famicom's comparator fed the port): `true` ORs `1` into `$4016.D2`. + /// Default `false` leaves the `$4016` read byte-identical (bit 2 is otherwise + /// open-bus / 0), so the standard controller path is unaffected until a + /// frontend explicitly drives the mic via [`Self::set_microphone`]. + famicom_mic: bool, /// v1.1.0 beta.1 (T-110-B4) — optional per-game nametable mirroring /// override. `None` (default) defers to the mapper's `nametable_address` /// (byte-identical). When `Some`, the standard `$2000-$3EFF` nametable @@ -843,6 +854,7 @@ impl LockstepBus { vs_4016_bit1: false, vs_4016_bit1_dirty: false, expansion_device: [None, None], + famicom_mic: false, nt_mirroring_override: None, #[cfg(feature = "debug-hooks")] events: alloc::vec::Vec::new(), @@ -1073,6 +1085,9 @@ impl LockstepBus { // Non-standard input devices are unplugged on power-cycle (they are // re-attached explicitly by the frontend, like the controllers above). self.expansion_device = [None, None]; + // The microphone is a transient live signal; a power-cycle releases it + // (the frontend re-drives it each frame while a key is held). + self.famicom_mic = false; self.cycle = 0; self.dma_pending = None; self.dma_cycles_owed = 0; @@ -1485,7 +1500,7 @@ impl LockstepBus { } v } - 0x4016 => 0x40 | self.peek_port(0), + 0x4016 => 0x40 | self.peek_port(0) | (u8::from(self.famicom_mic) << 2), 0x4017 => 0x40 | self.peek_port(1), 0x4000..=0x4014 | 0x4018..=0x401F => self.open_bus, 0x4020..=0xFFFF => { @@ -1662,6 +1677,20 @@ impl LockstepBus { } } + /// Drive the Famicom built-in microphone signal (read on `$4016` bit 2). + /// + /// `pressed` = the frontend's quantized "mic is loud" verdict. Additive: + /// leaving it `false` (the default) keeps the `$4016` read byte-identical. + pub const fn set_microphone(&mut self, pressed: bool) { + self.famicom_mic = pressed; + } + + /// Whether the Famicom microphone signal is currently asserted. + #[must_use] + pub const fn microphone(&self) -> bool { + self.famicom_mic + } + /// Update an attached Power Pad's live button mask (bit `i` = mat button /// `i+1`) on `port`. No-op if the attached device is not a Power Pad. /// @@ -3859,7 +3888,8 @@ impl LockstepBus { // "Standard controller" + AccuracyCoin `CPU Behavior :: // Open Bus` Test 6. 0x4016 => { - let base = (self.open_bus & 0xE0) | self.read_port(0); + let mic = u8::from(self.famicom_mic) << 2; + let base = (self.open_bus & 0xE0) | self.read_port(0) | mic; self.vs_overlay_4016(base) } 0x4017 => { @@ -4944,6 +4974,27 @@ mod four_score_tests { bus.commit_controller_strobe(0); } + #[test] + fn famicom_microphone_drives_4016_bit2() { + let mut bus = test_bus(); + // Default: mic released -> $4016 bit 2 clear (byte-identical stock read). + assert!(!bus.microphone()); + assert_eq!(bus.peek_cpu(0x4016) & 0x04, 0x00, "mic off -> D2 clear"); + // Press the mic: $4016 bit 2 reads 1. + bus.set_microphone(true); + assert!(bus.microphone()); + assert_eq!(bus.peek_cpu(0x4016) & 0x04, 0x04, "mic on -> D2 set"); + // $4017 is unaffected (the Famicom mic is a $4016-only signal). + assert_eq!(bus.peek_cpu(0x4017) & 0x04, 0x00, "mic never touches $4017"); + // Release restores the stock read. + bus.set_microphone(false); + assert_eq!( + bus.peek_cpu(0x4016) & 0x04, + 0x00, + "mic released -> D2 clear" + ); + } + #[test] fn four_score_off_reads_like_standard_controller() { let mut bus = test_bus(); diff --git a/crates/rustynes-core/src/input_device.rs b/crates/rustynes-core/src/input_device.rs index 372b59a6..f37ac460 100644 --- a/crates/rustynes-core/src/input_device.rs +++ b/crates/rustynes-core/src/input_device.rs @@ -204,6 +204,21 @@ pub struct ZapperState { /// pixel counts as "bright enough" to trigger the photodiode. pub(crate) const ZAPPER_LUMA_THRESHOLD: u16 = 0x80; +/// Photodiode **aperture radius** in pixels (v2.2.0 "Capstone" light-timing +/// hardening). The real Zapper's lens focuses light from a small solid angle +/// onto the photodiode, so the sensor integrates a *region* of the CRT phosphor, +/// not a single dot. Sampling a `(2r+1) x (2r+1)` window around the aim point +/// (rather than one pixel) hardens detection against sub-pixel aim error and +/// single-pixel dropouts in the PPU output — matching how the hardware responds +/// to the bright target the game flashes. Radius 1 = a 3x3 aperture. +pub(crate) const ZAPPER_APERTURE_RADIUS: i32 = 1; + +/// Minimum number of bright pixels within the aperture required to assert +/// "light detected". Requiring more than one rejects a lone stray-bright pixel +/// (PPU edge artefact) as a false positive while still firing on the target +/// flash, which lights the whole aperture. Calibrated for the 3x3 aperture. +pub(crate) const ZAPPER_APERTURE_MIN_BRIGHT: u32 = 2; + impl ZapperState { /// New zapper aimed off-screen, trigger released, no light. #[must_use] @@ -223,29 +238,64 @@ impl ZapperState { self.trigger = trigger; } - /// Sample the framebuffer luminance at the aim point, setting `light_seen` - /// if the pixel is bright enough. `framebuffer` is the PPU's RGBA8 256x240 - /// buffer. Called once per frame by the bus after the frame completes. + /// Sample the framebuffer luminance over the photodiode **aperture** around + /// the aim point, setting `light_seen` when enough of the aperture is bright. + /// `framebuffer` is the PPU's RGBA8 256x240 buffer. Called once per frame by + /// the bus after the frame completes. + /// + /// v2.2.0 "Capstone" light-timing hardening: rather than sampling a single + /// pixel, the sensor integrates a `(2r+1) x (2r+1)` aperture + /// ([`ZAPPER_APERTURE_RADIUS`]) and asserts light only when at least + /// [`ZAPPER_APERTURE_MIN_BRIGHT`] pixels cross [`ZAPPER_LUMA_THRESHOLD`]. This + /// models the lens/photodiode field-of-view against the PPU's per-dot output: + /// the bright target the game flashes lights the whole aperture (robust + /// detection), while a black "blanked" background frame — or a lone stray + /// bright pixel — yields no light (no false positive). The computation is a + /// pure, deterministic function of the framebuffer + aim point, so it needs + /// no additional save-state and preserves the determinism contract. + /// + /// The temporal light-sense window (the ~19-26-scanline photodiode hold) is + /// finer than the per-frame sample resolution used here; the supported + /// light-gun titles re-poll every frame, so frame-granular sampling of the + /// presented framebuffer is sufficient. A full per-dot temporal integration + /// against the beam position is a documented future refinement — see + /// `docs/frontend.md`. pub fn sample_light(&mut self, framebuffer: &[u8]) { - const W: usize = 256; - const H: usize = 240; - if (self.x as usize) >= W || (self.y as usize) >= H { + const W: i32 = 256; + const H: i32 = 240; + let (ax, ay) = (i32::from(self.x), i32::from(self.y)); + if ax >= W || ay >= H { // Aimed off-screen: never sees light. self.light_seen = false; return; } - let idx = ((self.y as usize) * W + (self.x as usize)) * 4; - // Guard against a partial framebuffer. - if idx + 2 >= framebuffer.len() { - self.light_seen = false; - return; + let mut bright = 0u32; + let r = ZAPPER_APERTURE_RADIUS; + for dy in -r..=r { + for dx in -r..=r { + let (px, py) = (ax + dx, ay + dy); + if !(0..W).contains(&px) || !(0..H).contains(&py) { + continue; // aperture clipped by the screen edge + } + // px/py are now bounded to the screen, so the linear index is + // non-negative and fits a usize. + let Ok(idx) = usize::try_from((py * W + px) * 4) else { + continue; + }; + if idx + 2 >= framebuffer.len() { + continue; // guard against a partial framebuffer + } + let cr = u16::from(framebuffer[idx]); + let cg = u16::from(framebuffer[idx + 1]); + let cb = u16::from(framebuffer[idx + 2]); + // Rec.601 luma approximation (integer): (77*R + 150*G + 29*B) >> 8. + let luma = (77 * cr + 150 * cg + 29 * cb) >> 8; + if luma >= ZAPPER_LUMA_THRESHOLD { + bright += 1; + } + } } - let r = u16::from(framebuffer[idx]); - let g = u16::from(framebuffer[idx + 1]); - let b = u16::from(framebuffer[idx + 2]); - // Rec.601 luma approximation (integer): (77*R + 150*G + 29*B) >> 8. - let luma = (77 * r + 150 * g + 29 * b) >> 8; - self.light_seen = luma >= ZAPPER_LUMA_THRESHOLD; + self.light_seen = bright >= ZAPPER_APERTURE_MIN_BRIGHT; } /// The device byte for a `$4016`/`$4017` access. Bit 3 = light (0 detected / @@ -1155,21 +1205,45 @@ mod tests { } #[test] - fn zapper_light_detected_for_bright_pixel() { + fn zapper_light_detected_for_bright_region() { let mut z = ZapperState::new(); z.set(10, 10, false); let mut fb = alloc::vec![0u8; 256 * 240 * 4]; - // Bright white pixel at (10, 10). - let idx = (10 * 256 + 10) * 4; + // Bright white 3x3 target block centred on the aim point (10, 10) — the + // target flash lights the whole photodiode aperture. + for py in 9..=11usize { + for px in 9..=11usize { + let idx = (py * 256 + px) * 4; + fb[idx] = 0xFF; + fb[idx + 1] = 0xFF; + fb[idx + 2] = 0xFF; + } + } + z.sample_light(&fb); + // Light detected -> bit 3 = 0. + assert_eq!( + z.read() & (1 << 3), + 0, + "bright target region -> light detected (bit3=0)" + ); + } + + #[test] + fn zapper_aperture_rejects_lone_bright_pixel() { + // A single stray-bright pixel (below ZAPPER_APERTURE_MIN_BRIGHT) is not + // enough to fire the photodiode — the aperture rejects PPU edge noise. + let mut z = ZapperState::new(); + z.set(50, 50, false); + let mut fb = alloc::vec![0u8; 256 * 240 * 4]; + let idx = (50 * 256 + 50) * 4; fb[idx] = 0xFF; fb[idx + 1] = 0xFF; fb[idx + 2] = 0xFF; z.sample_light(&fb); - // Light detected -> bit 3 = 0. assert_eq!( z.read() & (1 << 3), - 0, - "bright pixel -> light detected (bit3=0)" + 1 << 3, + "lone bright pixel -> no light (bit3=1)" ); } diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index 8de2dab8..3b3efc5b 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -1261,6 +1261,17 @@ impl Nes { self.bus.set_zapper(port, x, y, trigger); } + /// Drive the Famicom built-in **microphone** (read on `$4016` bit 2). + /// + /// The hardwired second Famicom controller carries a push-to-talk mic that + /// games poll on `$4016.D2` (e.g. *Zelda*'s Pols Voice, *Kid Icarus*). Pass + /// `pressed = true` while the frontend's mic key is held / an audio source + /// crosses the loudness threshold. Additive and opt-in: `false` (the + /// default) keeps the `$4016` read byte-identical to a stock NES. + pub const fn set_microphone(&mut self, pressed: bool) { + self.bus.set_microphone(pressed); + } + /// Attach an NES Power Pad / Family Fun Fitness mat on `port` (typically /// port 1 / `$4017`) and set its live button mask (bit `i` = mat button /// `i+1`, 0..=11). Convenience wrapper that attaches the device if absent diff --git a/crates/rustynes-mappers/src/fds.rs b/crates/rustynes-mappers/src/fds.rs index 377df7b2..9d69b0d8 100644 --- a/crates/rustynes-mappers/src/fds.rs +++ b/crates/rustynes-mappers/src/fds.rs @@ -49,12 +49,28 @@ //! dumps; the Kid Icarus side-B fix above is title-independent (the timed //! head model) and needs no entry. //! -//! Still simplified (matching Stage 1): CRC/gap bytes are not synthesized on -//! write (the BIOS's CRC is recomputed in its own RAM, not on the medium), and -//! the seek windows are short deterministic fixed cycle counts, not an analog -//! seek-time model. BIOS-driven writing is unverified without a real BIOS. +//! v2.2.0 "Capstone" completes the **medium model**: +//! +//! - **Byte-stream disk medium with per-block CRC-16 on write**: reads present a +//! synthesized wire image (gap / `$80` start mark / block / CRC-16-KERMIT), +//! and each written block re-emits a fresh CRC-16 over its payload +//! ([`Fds::resynth_block_crc`]) — modelling the RP2C33 controller's per-block +//! CRC generator, so the medium stays self-consistent after a BIOS write. +//! - **Continuous analog head-seek / velocity model** ([`Fds::set_analog_head_seek`]): +//! an opt-in, default-OFF replacement for the fixed [`HEAD_RESEEK_CYCLES`] +//! not-ready window. The belt-driven head's rewind time becomes proportional +//! to the distance it had travelled from the disk-start gap (a constant belt +//! velocity, [`HEAD_SEEK_BYTES_PER_CYCLE`]), rather than a flat constant. With +//! the model disabled a non-writing `.fds` run is byte-identical to prior +//! releases; the new state round-trips the v4 save-state tail. +//! - **Synthetic write-verify oracle** ([`Fds::medium_write_verify`]): a +//! BIOS-free walk of the wire image asserting every block's CRC-16 + gap / +//! mark framing round-trips — the CI-verifiable half of the medium model. The +//! real-BIOS write-CRC path depends on a copyright FDS BIOS and is validated +//! only from a local, gitignored dump (see `docs/accuracy-ledger.md`). +//! //! The frontend wiring (BIOS prompt, side-swap keybind, `.fds.sav` file I/O) -//! is a later stage; this module only provides the core API + state. +//! is provided by `rustynes-frontend`; this module owns the core API + state. //! //! References (nesdev wiki, committed under `nesdev_wiki/`): //! - `Family_Computer_Disk_System.xhtml` — register map + IRQ + banks. @@ -152,6 +168,34 @@ pub const MOTOR_SPIN_UP_CYCLES: u32 = 50_000; /// may extend it for titles whose timing the nominal value does not satisfy. pub const HEAD_RESEEK_CYCLES: u32 = 8_000; +/// Belt-driven head-seek velocity for the **continuous analog head-seek model** +/// (v2.2.0 "Capstone", opt-in — see [`Fds::set_analog_head_seek`]). +/// +/// The stock model above opens a single *fixed* [`HEAD_RESEEK_CYCLES`] not-ready +/// window on every motor-restart rewind, regardless of how far into the disk the +/// head had travelled. Real hardware is a belt-driven linear actuator: the time +/// to rewind the head back to the disk-start gap is **proportional to the +/// distance** the head sits from that gap (a constant belt velocity), not a flat +/// constant. This value expresses that velocity as *wire bytes of head travel +/// undone per CPU cycle*: the re-seek window becomes +/// `HEAD_SEEK_SETTLE_CYCLES + pre_rewind_head / HEAD_SEEK_BYTES_PER_CYCLE`, +/// clamped to [`MOTOR_SPIN_UP_CYCLES`] (a rewind can never take longer than a +/// cold spin-up). The divisor is calibrated so a full-side rewind (head parked +/// near the inner track, ≈65500 wire bytes) lands close to the historical fixed +/// [`HEAD_RESEEK_CYCLES`] window, while a shallow rewind (a few blocks in) +/// completes proportionally sooner — a genuine continuous position→time map. +/// +/// The model is **deterministic** (integer distance ÷ integer velocity, no +/// wall-clock / analog jitter) and **opt-in / default-off**, so a non-writing +/// `.fds` run with the model disabled is byte-identical to the fixed-window path. +pub const HEAD_SEEK_BYTES_PER_CYCLE: u32 = 8; + +/// Fixed settle time (CPU cycles) added to every continuous head-seek window, +/// modelling the head-carriage settle + track-0 detent that is independent of +/// the seek distance. Even a zero-distance re-seek reports not-ready for this +/// long so the BIOS re-read loop always observes the not-ready -> ready edge. +pub const HEAD_SEEK_SETTLE_CYCLES: u32 = 512; + /// Per-game FDS timing quirk, modelled on `puNES` `fds.c`'s per-CRC drive table. /// /// A small, additive set of knobs keyed off the disk-image CRC-32 (see @@ -391,6 +435,47 @@ fn build_side_wire(side: &[u8]) -> (Vec, Vec) { (wire, map) } +/// A structural fault found by [`Fds::medium_write_verify`] while walking a +/// synthesized wire image. Used by the **synthetic FDS write-verify oracle** +/// (v2.2.0 "Capstone"): after driving the register-level write path, the test +/// re-walks the medium and asserts every block's synthesized CRC-16 and +/// surrounding gap/mark framing round-trips. This is the CI-verifiable half of +/// the medium model — it needs **no copyright FDS BIOS** (the real-BIOS write +/// path is exercised only from a gitignored local dump; see +/// `docs/accuracy-ledger.md`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FdsMediumError { + /// A block's leading `$80` start mark was missing at the expected offset. + MissingStartMark { + /// Zero-based block index in stream order. + block: usize, + /// Wire offset the mark was expected at. + wire_pos: usize, + }, + /// A block's synthesized CRC-16 does not match a recomputation over its + /// payload — i.e. a write did not re-emit a consistent per-block CRC. + CrcMismatch { + /// Zero-based block index in stream order. + block: usize, + /// The CRC-16 stored on the wire (little-endian lo/hi). + stored: u16, + /// The CRC-16 recomputed from the block payload. + expected: u16, + }, + /// The block payload ran past the end of the wire image (a truncated wire). + Truncated { + /// Zero-based block index in stream order. + block: usize, + }, + /// The inter-block gap before a block was not all-`$00` (framing corrupted). + GapNotZero { + /// Zero-based block index in stream order. + block: usize, + /// Wire offset of the first non-zero gap byte. + wire_pos: usize, + }, +} + /// A parsed FDS disk image: an ordered list of disk sides. #[derive(Debug, Clone)] pub struct FdsDisk { @@ -1175,6 +1260,21 @@ pub struct Fds { /// ([`quirk_for_crc`]). [`FdsQuirk::NONE`] for the vast majority of titles. /// Derived once from immutable inputs, so it is not part of the save-state. quirk: FdsQuirk, + /// Opt-in **continuous analog head-seek model** (v2.2.0 "Capstone"). When + /// `false` (the default), a motor-restart rewind opens the flat + /// [`HEAD_RESEEK_CYCLES`] not-ready window (byte-identical to prior + /// releases). When `true`, the window is instead the belt-driven + /// distance-proportional seek time computed from [`Self::pre_rewind_head`] + /// via [`HEAD_SEEK_BYTES_PER_CYCLE`] + [`HEAD_SEEK_SETTLE_CYCLES`]. Persisted + /// in the v4 save-state tail; toggled via [`Fds::set_analog_head_seek`]. + analog_head_seek: bool, + /// Wire head position captured at the *instant the motor stops* — i.e. how + /// far the belt-driven head had travelled from the disk-start gap before the + /// motor-off rewind snapped [`Self::head`] back to 0. The next motor-on uses + /// this distance to size the continuous re-seek window (a far-out head takes + /// proportionally longer to rewind). Only consulted while + /// [`Self::analog_head_seek`] is set; persisted in the v4 tail. + pre_rewind_head: usize, // --- Registers --- /// $4020/$4021 — 16-bit timer IRQ reload value. @@ -1299,6 +1399,11 @@ impl Fds { // ready transition it waits for. insert_not_ready: 0, quirk, + // The continuous head-seek model is opt-in (default off) so a + // non-writing `.fds` run stays byte-identical to the fixed-window + // path. `pre_rewind_head` starts at 0 (head parked at disk start). + analog_head_seek: false, + pre_rewind_head: 0, audio: FdsAudio::default(), trace_on: false, trace: Vec::new(), @@ -1451,11 +1556,18 @@ impl Fds { // BIOS writes whole blocks back to the same position it read // them, so the existing block geometry stays valid. let raw_off = self.wire_head_to_raw(self.head); + let written_pos = self.head; self.wire[self.head] = self.write_data; if let Some(off) = raw_off && off < self.disk.side(idx).len() { self.disk.side_mut(idx)[off] = self.write_data; + // The FDS controller emits a fresh CRC-16 after each + // block it writes; re-synthesize this block's stored CRC + // over its (now-updated) payload so the medium stays + // self-consistent — the synthetic write-verify oracle and + // any $4030.D4-checking loader then see a valid block. + self.resynth_block_crc(written_pos); } self.disk_dirty = true; } @@ -1472,6 +1584,144 @@ impl Fds { } } + /// Compute the motor-restart re-seek not-ready window (CPU cycles). + /// + /// With the continuous head-seek model disabled (the default) this is the + /// flat [`HEAD_RESEEK_CYCLES`] plus any per-game quirk slack — byte-identical + /// to prior releases. With the model enabled it is the belt-driven, + /// distance-proportional seek time: a fixed [`HEAD_SEEK_SETTLE_CYCLES`] + /// settle plus the head-travel distance ([`Self::pre_rewind_head`] wire + /// bytes) divided by the belt velocity [`HEAD_SEEK_BYTES_PER_CYCLE`], + /// clamped so a rewind never exceeds a cold [`MOTOR_SPIN_UP_CYCLES`] + /// spin-up. Both paths add the per-game quirk slack and are fully + /// deterministic (integer arithmetic, no analog jitter). + fn reseek_window_cycles(&self) -> u32 { + let base = if self.analog_head_seek { + let travel = (self.pre_rewind_head as u32) / HEAD_SEEK_BYTES_PER_CYCLE; + HEAD_SEEK_SETTLE_CYCLES + .saturating_add(travel) + .min(MOTOR_SPIN_UP_CYCLES) + } else { + HEAD_RESEEK_CYCLES + }; + base.saturating_add(self.quirk.extra_reseek_cycles) + } + + /// Re-emit the per-block CRC-16 for the block whose payload contains wire + /// offset `wire_pos`, recomputing it over the block's current payload bytes. + /// + /// Real FDS hardware appends a fresh CRC-16 immediately after each block it + /// writes; the RP2C33 controller's CRC generator runs continuously over the + /// block stream and the two CRC bytes it emits close the block. Modelling + /// that keeps the synthesized wire image self-consistent after a BIOS write: + /// once the last payload byte of a block lands, its stored CRC matches a + /// recomputation, so [`Self::medium_write_verify`] (and any stricter loader + /// that checks `$4030.D4`) sees a valid block. Gap / start-mark / CRC + /// positions map to no block payload and are skipped (their framing is + /// regenerated from the raw side on the next [`Self::rebuild_wire`]). + fn resynth_block_crc(&mut self, wire_pos: usize) { + for blk in &self.wire_map { + let payload_end = blk.wire_payload_start + blk.len; + if wire_pos >= blk.wire_payload_start && wire_pos < payload_end { + // The two CRC bytes sit immediately after the payload on the + // wire (build_side_wire lays them out `[block][crc_lo][crc_hi]`). + if payload_end + 1 < self.wire.len() { + let crc = fds_block_crc( + WIRE_START_MARK, + &self.wire[blk.wire_payload_start..payload_end], + ); + self.wire[payload_end] = (crc & 0xFF) as u8; + self.wire[payload_end + 1] = (crc >> 8) as u8; + } + return; + } + } + } + + /// Walk the synthesized wire image of the currently inserted side and verify + /// every block's gap / start-mark framing and per-block CRC-16 round-trips — + /// the **synthetic FDS write-verify oracle** (v2.2.0 "Capstone"). + /// + /// This is deliberately BIOS-free: it validates the emulator's own medium + /// synthesis (gap runs, `$80` start marks, CRC-16/KERMIT block CRCs) so the + /// write path can be exercised and checked entirely in CI without any + /// copyright FDS BIOS. The real-BIOS write path (which recomputes the CRC in + /// its own RAM and streams it to `$4024`) is validated only from a local, + /// gitignored dump — see `docs/accuracy-ledger.md` for the CI-verifiable vs + /// local-only split. + /// + /// # Errors + /// + /// Returns the first [`FdsMediumError`] encountered (missing start mark, + /// CRC mismatch, truncated block, or a corrupted inter-block gap). Returns + /// `Ok(())` when no side is inserted (nothing to verify). + pub fn medium_write_verify(&self) -> Result<(), FdsMediumError> { + for (block, blk) in self.wire_map.iter().enumerate() { + // The `$80` start mark sits one byte before the payload. + if blk.wire_payload_start == 0 + || self.wire[blk.wire_payload_start - 1] != WIRE_START_MARK + { + return Err(FdsMediumError::MissingStartMark { + block, + wire_pos: blk.wire_payload_start.saturating_sub(1), + }); + } + let payload_end = blk.wire_payload_start + blk.len; + // Payload + its two CRC bytes must fit inside the wire. + if payload_end + 1 >= self.wire.len() { + return Err(FdsMediumError::Truncated { block }); + } + let expected = fds_block_crc( + WIRE_START_MARK, + &self.wire[blk.wire_payload_start..payload_end], + ); + let stored = + u16::from(self.wire[payload_end]) | (u16::from(self.wire[payload_end + 1]) << 8); + if stored != expected { + return Err(FdsMediumError::CrcMismatch { + block, + stored, + expected, + }); + } + // The gap before this block (from the prior block's CRC end, or the + // start of the wire) must be all `$00`. + let gap_start = if block == 0 { + 0 + } else { + let prev = &self.wire_map[block - 1]; + prev.wire_payload_start + prev.len + 2 + }; + let mark_pos = blk.wire_payload_start - 1; + for (i, &b) in self.wire[gap_start..mark_pos].iter().enumerate() { + if b != 0x00 { + return Err(FdsMediumError::GapNotZero { + block, + wire_pos: gap_start + i, + }); + } + } + } + Ok(()) + } + + /// Enable or disable the continuous analog head-seek model (default off). + /// + /// Opt-in accuracy feature: when disabled (the default) motor-restart + /// rewinds use the flat [`HEAD_RESEEK_CYCLES`] window, so a non-writing + /// `.fds` run is byte-identical to prior releases. When enabled, the re-seek + /// window scales with head-travel distance (belt velocity). See + /// [`Self::reseek_window_cycles`]. + pub const fn set_analog_head_seek(&mut self, enabled: bool) { + self.analog_head_seek = enabled; + } + + /// Whether the continuous analog head-seek model is currently enabled. + #[must_use] + pub const fn analog_head_seek(&self) -> bool { + self.analog_head_seek + } + /// Acknowledge a pending timer IRQ (clears the timer-IRQ status bit and the /// shared IRQ line if no disk IRQ remains). Mirrors the three documented ack /// paths: read `$4030`, write `$4022`, write `$4023`. @@ -1531,9 +1781,7 @@ impl Fds { // (i.e. a true rewind happened), which is what the post-load // re-read sequence produces. if self.head == 0 { - self.insert_not_ready = self - .insert_not_ready - .max(HEAD_RESEEK_CYCLES + self.quirk.extra_reseek_cycles); + self.insert_not_ready = self.insert_not_ready.max(self.reseek_window_cycles()); } } else { // First motor-on since the disk was inserted: the drive spins up @@ -1556,6 +1804,12 @@ impl Fds { // the load stalls forever. The cold spin-up window is NOT re-opened // (`spun_up` stays set) — only the disk position rewinds, matching the // mid-session rewind hardware does without a full spin-up delay. + // + // Capture how far the head had travelled from the disk-start gap + // BEFORE snapping it back, so the continuous head-seek model can size + // the next motor-on's re-seek window by that distance (belt + // velocity). The fixed-window model ignores this field. + self.pre_rewind_head = self.head; self.head = 0; self.end_of_head = false; self.read_skipping_gap = true; @@ -1766,11 +2020,15 @@ impl Fds { data: &[u8], mut off: usize, base: usize, + version: u8, ) -> Result<(), MapperError> { let saved_sides = u32::from_le_bytes(data[off..off + 4].try_into().unwrap()) as usize; off += 4; - // Validate the full v3 length now that the side count is known. - let expected = base + FdsAudio::TAIL_LEN + 4 + saved_sides * FDS_SIDE_LEN + 4 + 4 + 1; + // Validate the full length now that the side count is known. v4 appends + // the continuous head-seek tail after the v3 fields. + let v4_extra = if version >= 4 { FDS_V4_TAIL_LEN } else { 0 }; + let expected = + base + FdsAudio::TAIL_LEN + 4 + saved_sides * FDS_SIDE_LEN + 4 + 4 + 1 + v4_extra; if data.len() != expected { return Err(MapperError::Truncated { expected, @@ -1799,9 +2057,20 @@ impl Fds { self.insert_not_ready = u32::from_le_bytes(data[off..off + 4].try_into().unwrap()); off += 4; let disk_flags = data[off]; + off += 1; self.disk_dirty = (disk_flags & 0x01) != 0; self.write_protected = (disk_flags & 0x02) != 0; self.spun_up = (disk_flags & 0x04) != 0; + // v4 continuous head-seek tail; v3 blobs default the model off. + if version >= 4 { + self.analog_head_seek = data[off] != 0; + off += 1; + self.pre_rewind_head = + u32::from_le_bytes(data[off..off + 4].try_into().unwrap()) as usize; + } else { + self.analog_head_seek = false; + self.pre_rewind_head = 0; + } Ok(()) } } @@ -1819,7 +2088,16 @@ impl Fds { /// is retained (clamped) for back-compat; v3 overwrites it from the tail. /// Loading a v1/v2 blob leaves the disk at its construction contents /// (un-modified), side 0 inserted, not dirty, writable. -const FDS_SAVE_VERSION: u8 = 3; +/// - v4 (Capstone): appends the **continuous head-seek** tail +/// ([`FDS_V4_TAIL_LEN`]) after the v3 disk tail — the `analog_head_seek` +/// opt-in flag + the `pre_rewind_head` distance. Strictly additive: a v1/v2/v3 +/// blob restores with the model disabled and `pre_rewind_head` = 0 (the +/// byte-identical default). +const FDS_SAVE_VERSION: u8 = 4; + +/// Extra bytes the v4 disk tail appends after the v3 tail: the +/// `analog_head_seek` flag (1 byte) + `pre_rewind_head` (u32, 4 bytes). +const FDS_V4_TAIL_LEN: usize = 1 + 4; impl Mapper for Fds { fn sram(&self) -> &[u8] { @@ -2141,6 +2419,11 @@ impl Mapper for Fds { disk_flags |= u8::from(self.write_protected) << 1; disk_flags |= u8::from(self.spun_up) << 2; out.push(disk_flags); + // v4 tail (Capstone): continuous head-seek model state. Strictly + // additive after the v3 disk tail — v1/v2/v3 blobs restore with the + // model disabled and `pre_rewind_head` = 0 (the byte-identical default). + out.push(u8::from(self.analog_head_seek)); + out.extend_from_slice(&(self.pre_rewind_head as u32).to_le_bytes()); out } @@ -2170,7 +2453,7 @@ impl Mapper for Fds { }); } } - 3 => { + 3 | 4 => { // Need at least the fixed prefix + audio tail + the disk tail's // leading side-count u32 to learn how long the tail is. let min = base + FdsAudio::TAIL_LEN + 4; @@ -2260,7 +2543,7 @@ impl Mapper for Fds { // Legacy v1/v2 blobs leave the disk at its construction contents // (un-modified), side 0 inserted, not dirty, writable. if version >= 3 { - self.load_disk_tail(data, off, base)?; + self.load_disk_tail(data, off, base, version)?; } else { self.disk_dirty = false; self.write_protected = false; @@ -2269,6 +2552,9 @@ impl Mapper for Fds { // spun up so a restored mid-game state does not re-trigger a spin-up // window (the disk was spinning when the state was captured). self.spun_up = true; + // v1/v2/v3 predate the continuous head-seek model: default it off. + self.analog_head_seek = false; + self.pre_rewind_head = 0; } // Rebuild the wire image from the (possibly modified) inserted side and // clamp the restored head into it. The wire image is derived state — it @@ -3068,7 +3354,10 @@ mod tests { fds.notify_cpu_cycle(); } let blob = fds.save_state(); - assert_eq!(blob[0], 3, "FDS save version is 3 (Stage 2b)"); + assert_eq!( + blob[0], 4, + "FDS save version is 4 (Capstone head-seek tail)" + ); let mut fresh = make_device(2); fresh.load_state(&blob).unwrap(); @@ -3117,8 +3406,9 @@ mod tests { let mut fds = make_device(1); enable_sound_io(&mut fds); // Build a v1-shaped blob by truncating off the disk + audio tails and - // stamping version 1. - let disk_tail = 4 + FDS_SIDE_LEN + 4 + 4 + 1; + // stamping version 1. `save_state` now emits v4, so the disk tail + // includes the Capstone head-seek extra. + let disk_tail = 4 + FDS_SIDE_LEN + 4 + 4 + 1 + FDS_V4_TAIL_LEN; let mut blob = fds.save_state(); blob.truncate(blob.len() - disk_tail - FdsAudio::TAIL_LEN); blob[0] = 1; @@ -3506,7 +3796,7 @@ mod tests { assert!(fds.disk_is_dirty()); assert_eq!(fds.transfer, TransferState::Writing); let blob = fds.save_state(); - assert_eq!(blob[0], 3, "FDS save version bumped to 3"); + assert_eq!(blob[0], 4, "FDS save version bumped to 4 (Capstone)"); let mut fresh = make_device(2); fresh.load_state(&blob).unwrap(); @@ -3557,9 +3847,9 @@ mod tests { // the disk un-modified, side 0 inserted, clean, writable. let mut fds = make_device(2); enable_disk_io(&mut fds); - // Build a v2-shaped blob by truncating off the v3 disk tail + stamping 2. - // The disk tail = 4 (side count) + sides*FDS_SIDE_LEN + 4 + 4 + 1. - let disk_tail = 4 + 2 * FDS_SIDE_LEN + 4 + 4 + 1; + // Build a v2-shaped blob by truncating off the v4 disk tail + stamping 2. + // The disk tail = 4 (side count) + sides*FDS_SIDE_LEN + 4 + 4 + 1 + v4. + let disk_tail = 4 + 2 * FDS_SIDE_LEN + 4 + 4 + 1 + FDS_V4_TAIL_LEN; let mut blob = fds.save_state(); blob.truncate(blob.len() - disk_tail); blob[0] = 2; @@ -3578,7 +3868,7 @@ mod tests { // A v1 blob has neither audio nor disk tail; the disk defaults apply. let mut fds = make_device(1); let blob_v3 = fds.save_state(); - let disk_tail = 4 + FDS_SIDE_LEN + 4 + 4 + 1; + let disk_tail = 4 + FDS_SIDE_LEN + 4 + 4 + 1 + FDS_V4_TAIL_LEN; let mut blob = blob_v3; blob.truncate(blob.len() - FdsAudio::TAIL_LEN - disk_tail); blob[0] = 1; @@ -3588,4 +3878,113 @@ mod tests { assert!(!fds.disk_is_dirty()); assert_eq!(fds.cpu_read(0x4032) & 0x04, 0x00, "v1 defaults to writable"); } + + // --- v2.2.0 "Capstone" medium model --- + + #[test] + fn freshly_built_wire_passes_write_verify() { + // The synthesized wire image for a power-on disk must already satisfy the + // oracle: every block has a $80 mark, a correct CRC-16, and all-$00 gaps. + let fds = make_device(1); + fds.medium_write_verify() + .expect("power-on wire image is well-formed"); + } + + #[test] + fn synthetic_write_verify_crc_and_gap_round_trip() { + // The BIOS-free synthetic write-verify oracle: drive the register-level + // WRITE path over a block payload, then re-walk the medium and assert the + // per-block CRC-16 was re-emitted and the gap/mark framing round-trips — + // no copyright FDS BIOS required (see docs/accuracy-ledger.md). + let mut fds = make_device(1); + enable_disk_io(&mut fds); + // Land inside the disk-info block payload so the writes mirror into the + // raw side and re-synthesize that block's CRC. + seek_head(&mut fds, FIRST_BLOCK_WIRE_PAYLOAD); + write_bytes(&mut fds, &[0xDE, 0xAD, 0xBE, 0xEF]); + assert!(fds.disk_is_dirty(), "a write dirties the medium"); + // The whole medium remains structurally valid after the write. + fds.medium_write_verify() + .expect("write path keeps the medium self-consistent"); + // Prove the CRC oracle actually bites: corrupt one payload byte on the + // wire WITHOUT re-synthesizing its CRC and confirm the verifier catches + // the mismatch on the first (disk-info) block. + fds.wire[FIRST_BLOCK_WIRE_PAYLOAD] ^= 0xFF; + match fds.medium_write_verify() { + Err(FdsMediumError::CrcMismatch { block: 0, .. }) => {} + other => panic!("expected a block-0 CRC mismatch, got {other:?}"), + } + } + + #[test] + fn resynthesized_block_crc_matches_reference() { + // After a block-payload write, the stored CRC-16 on the wire must equal a + // fresh reference computation over the (updated) payload. + let mut fds = make_device(1); + enable_disk_io(&mut fds); + seek_head(&mut fds, FIRST_BLOCK_WIRE_PAYLOAD); + write_bytes(&mut fds, &[0x10, 0x20, 0x30]); + let blk = fds.wire_map[0]; + let end = blk.wire_payload_start + blk.len; + let stored = u16::from(fds.wire[end]) | (u16::from(fds.wire[end + 1]) << 8); + let expected = fds_block_crc(WIRE_START_MARK, &fds.wire[blk.wire_payload_start..end]); + assert_eq!( + stored, expected, + "written block re-emits a consistent CRC-16" + ); + } + + #[test] + fn analog_head_seek_defaults_off_and_matches_fixed_window() { + // With the model OFF (default), a motor-restart re-seek opens the flat + // HEAD_RESEEK_CYCLES window regardless of head distance — byte-identical + // to prior releases. + let mut fds = make_device(1); + assert!(!fds.analog_head_seek(), "model is opt-in / default off"); + fds.pre_rewind_head = 40_000; // far-out head; ignored while disabled + assert_eq!(fds.reseek_window_cycles(), HEAD_RESEEK_CYCLES); + } + + #[test] + fn analog_head_seek_scales_with_distance() { + // With the model ON, the re-seek window grows with the pre-rewind head + // distance (belt velocity) and clamps at a cold spin-up. + let mut fds = make_device(1); + fds.set_analog_head_seek(true); + fds.pre_rewind_head = 0; + let near = fds.reseek_window_cycles(); + assert_eq!( + near, HEAD_SEEK_SETTLE_CYCLES, + "zero-distance = settle floor" + ); + fds.pre_rewind_head = 8_000; + let mid = fds.reseek_window_cycles(); + assert_eq!( + mid, + HEAD_SEEK_SETTLE_CYCLES + 8_000 / HEAD_SEEK_BYTES_PER_CYCLE + ); + assert!(mid > near, "a farther head takes longer to rewind"); + // A head parked deep past the whole disk clamps to the spin-up ceiling. + fds.pre_rewind_head = 10_000_000; + assert_eq!(fds.reseek_window_cycles(), MOTOR_SPIN_UP_CYCLES); + } + + #[test] + fn v4_save_state_round_trips_analog_head_seek() { + // The v4 tail persists the head-seek model state. + let mut fds = make_device(2); + enable_disk_io(&mut fds); + fds.set_analog_head_seek(true); + fds.pre_rewind_head = 12_345; + let blob = fds.save_state(); + assert_eq!(blob[0], 4); + let mut fresh = make_device(2); + fresh.load_state(&blob).unwrap(); + assert!(fresh.analog_head_seek(), "v4 restores the opt-in flag"); + assert_eq!( + fresh.pre_rewind_head, 12_345, + "v4 restores the seek distance" + ); + assert_eq!(fresh.save_state(), blob, "re-serialize is byte-identical"); + } } From 676c252db118fdc49ef8929da3d1903e9e4cb730 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 12 Jul 2026 00:21:16 -0400 Subject: [PATCH 2/4] feat(frontend): wire Famicom microphone hold-to-talk + docs (v2.2.0 Capstone) Thread the Famicom mic (M key) through InputState -> FrameInputs -> SharedInput -> EmuCore latch (Nes::set_microphone). Docs: mappers.md FDS medium model, accuracy-ledger.md CI-verifiable-vs-local-only split, frontend.md peripherals, CHANGELOG [Unreleased]. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 38 ++++++++++++++++++++ crates/rustynes-frontend/src/app.rs | 6 ++++ crates/rustynes-frontend/src/emu.rs | 7 ++++ crates/rustynes-frontend/src/emu_thread.rs | 6 ++++ crates/rustynes-frontend/src/input.rs | 40 ++++++++++++++++++++++ docs/accuracy-ledger.md | 4 ++- docs/frontend.md | 26 ++++++++++++++ docs/mappers.md | 34 ++++++++++++++++++ 8 files changed, 160 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc37d88..d21d7154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -300,6 +300,44 @@ cycle-accurate core later replaced. byte-identical (AccuracyCoin **141/141**, nestest 0-diff, save-state round-trip byte-identical). No `dmc_dma_during_read4` sub-test is made to fail or newly `#[ignore]`'d. See ADR 0033 + `docs/scheduler.md` §"Unexpected DMA". +- **FDS medium model completion — CRC-16 / gap / continuous head-seek (v2.2.0 + "Capstone", F4.3).** The Famicom Disk System RAM adapter + (`crates/rustynes-mappers/src/fds.rs`) completes the disk **medium** model. The + disk is a synthesized byte-stream wire image — lead-in / inter-block gaps, a + `$80` start mark, the block bytes, and a **CRC-16/KERMIT** per block — and each + BIOS-written block now **re-emits a fresh per-block CRC-16** over its updated + payload (`resynth_block_crc`), modelling the RP2C33 controller's continuous CRC + generator so the medium stays self-consistent after a write. A new **continuous + analog head-seek / velocity model** (opt-in, default-OFF — + `Fds::set_analog_head_seek`) replaces the flat fixed `HEAD_RESEEK_CYCLES` + motor-restart not-ready window with a belt-driven, distance-proportional seek + time (`HEAD_SEEK_BYTES_PER_CYCLE` velocity + `HEAD_SEEK_SETTLE_CYCLES` settle, + clamped to a cold spin-up), sized from the head-travel distance captured at + motor-off. A **BIOS-free synthetic write-verify oracle** + (`Fds::medium_write_verify`) walks the wire image and asserts every block's + CRC-16 and gap/mark framing round-trips — the CI-verifiable half of the medium + model; the real-BIOS write-CRC path needs a copyright `disksys.rom` and is + exercised only from a gitignored local dump (`docs/accuracy-ledger.md` records + the CI-verifiable-vs-local-only split). **Additive and deterministic**: with + the head-seek model off (the default) a non-writing `.fds` run is + **byte-identical** to prior releases; the new state round-trips an additive + **v4** FDS save-state tail (v1/v2/v3 blobs load with the model disabled). + AccuracyCoin has no FDS ROM, so **141/141 (100%)** is unaffected. +- **Famicom microphone + Zapper light-timing hardening (v2.2.0 "Capstone" + peripherals).** The Famicom built-in controller-2 **microphone** is modelled on + **`$4016` bit 2** (`Nes::set_microphone` / `Bus::set_microphone`), wired through + the frontend input path (hold-to-talk `M` key → `FrameInputs.microphone` → + latch), for games such as *The Legend of Zelda* (Pols Voice) and *Kid Icarus*. + It is a `$4016`-only signal (never touches `$4017`). The **Zapper** photodiode + now integrates a **3×3 aperture** (field-of-view) around the aim point, + asserting light only when ≥2 pixels cross the luma threshold + (`ZAPPER_APERTURE_*`) — hardening detection against sub-pixel aim error and PPU + edge noise vs the prior single-pixel sample, while staying a deterministic pure + function of the presented framebuffer (no save-state change). Both are additive + and **default-off**: the mic released leaves the `$4016` read byte-identical, + and the standard controller / Four Score path is unchanged. (The full Family + BASIC `9×8` keyboard matrix was already modelled; its frontend mapping is + unchanged.) ## [2.1.6] - 2026-07-11 - "Fathom" (expansion audio — decibel oracle + hardware/Mesen2 channel-level calibration + Namco 163 12 dB fix + mix UI/scopes; "Timbre") diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index 32124f5e..1ceab53f 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -3686,6 +3686,12 @@ impl App { crate::emu::FrameInputs { buttons, four_score: self.config.input.four_score, + // v2.2.0 "Capstone" — Famicom microphone hold-to-talk (native only; + // no key source on wasm). Byte-identical when released. + #[cfg(not(target_arch = "wasm32"))] + microphone: self.input.microphone(), + #[cfg(target_arch = "wasm32")] + microphone: false, // v2.7.0 — RA hardcore disables rewind; fold the gate here. rewind_held: self.input.rewind_held() && !hardcore_blocked, hardcore_blocked, diff --git a/crates/rustynes-frontend/src/emu.rs b/crates/rustynes-frontend/src/emu.rs index d21c47d4..cdc2b42b 100644 --- a/crates/rustynes-frontend/src/emu.rs +++ b/crates/rustynes-frontend/src/emu.rs @@ -135,6 +135,9 @@ pub struct FrameInputs { pub buttons: [Buttons; 4], /// Whether the Four Score adapter is enabled (players 3/4 latch). pub four_score: bool, + /// v2.2.0 "Capstone" — Famicom built-in microphone held (read on `$4016` + /// bit 2). `false` (default) keeps the `$4016` read byte-identical. + pub microphone: bool, /// The rewind gesture is held (already hardcore-gated by `App`). pub rewind_held: bool, /// RA hardcore gating is active (disables raw cheats; rewind is @@ -516,6 +519,9 @@ impl EmuCore { nes.set_buttons(2, turbo(inputs.buttons[2])); nes.set_buttons(3, turbo(inputs.buttons[3])); } + // v2.2.0 "Capstone" — Famicom microphone ($4016 bit 2). Latched at + // the SAME point as the buttons; byte-identical when released. + nes.set_microphone(inputs.microphone); // v1.2.0 (T-110-E2) — Lua setInput override: replace ports 0/1 AFTER // the keyboard/turbo latch, so the script's recorded bitmask wins for // this frame. Applied at the late-latch point, so a session that @@ -1187,6 +1193,7 @@ mod tests { FrameInputs { buttons: [Buttons::empty(); 4], four_score: false, + microphone: false, rewind_held: false, hardcore_blocked: false, run_ahead: 0, diff --git a/crates/rustynes-frontend/src/emu_thread.rs b/crates/rustynes-frontend/src/emu_thread.rs index 7cdce364..fe03cb24 100644 --- a/crates/rustynes-frontend/src/emu_thread.rs +++ b/crates/rustynes-frontend/src/emu_thread.rs @@ -96,6 +96,8 @@ pub mod regime { pub struct SharedInput { buttons: [AtomicU8; 4], four_score: AtomicBool, + /// v2.2.0 "Capstone" — Famicom microphone held (`$4016` bit 2). + microphone: AtomicBool, rewind_held: AtomicBool, hardcore_blocked: AtomicBool, run_ahead: AtomicU8, @@ -136,6 +138,7 @@ impl SharedInput { slot.store(b.bits(), Ordering::Relaxed); } self.four_score.store(inputs.four_score, Ordering::Relaxed); + self.microphone.store(inputs.microphone, Ordering::Relaxed); self.rewind_held .store(inputs.rewind_held, Ordering::Relaxed); self.hardcore_blocked @@ -203,6 +206,7 @@ impl SharedInput { FrameInputs { buttons, four_score: self.four_score.load(Ordering::Relaxed), + microphone: self.microphone.load(Ordering::Relaxed), rewind_held: self.rewind_held.load(Ordering::Relaxed), hardcore_blocked: self.hardcore_blocked.load(Ordering::Relaxed), run_ahead: u32::from(self.run_ahead.load(Ordering::Relaxed)), @@ -806,6 +810,7 @@ mod tests { Buttons::all(), ], four_score: true, + microphone: false, rewind_held: true, hardcore_blocked: false, run_ahead: 2, @@ -853,6 +858,7 @@ mod tests { let mut inputs = FrameInputs { buttons: [Buttons::empty(); 4], four_score: false, + microphone: false, rewind_held: false, hardcore_blocked: false, run_ahead: 0, diff --git a/crates/rustynes-frontend/src/input.rs b/crates/rustynes-frontend/src/input.rs index 8991171e..6f29e3a6 100644 --- a/crates/rustynes-frontend/src/input.rs +++ b/crates/rustynes-frontend/src/input.rs @@ -664,6 +664,11 @@ pub struct InputState { /// [`BANDAI_HYPER_SHOT_KEYS`]. Only consumed when a Bandai Hyper Shot is the /// configured expansion device. bandai_hyper_shot: u8, + /// v2.2.0 "Capstone" — Famicom built-in microphone (read on `$4016` bit 2), + /// held while [`MICROPHONE_KEY`] is down. Tracked unconditionally; the core + /// only surfaces it when driven, so `false` (default) keeps `$4016` + /// byte-identical. + microphone: bool, } /// v1.1.0 beta.1 (T-110-B1) — default keyboard mapping for the 12 Power Pad mat @@ -721,6 +726,13 @@ pub const BANDAI_HYPER_SHOT_KEYS: [KeyCode; 8] = [ KeyCode::Digit8, ]; +/// Default hold-to-talk key for the Famicom built-in microphone (v2.2.0). +/// +/// The microphone is read on `$4016` bit 2. The key is chosen off the P1/P2 +/// controller keys and the system bindings; games such as *Zelda* (Pols Voice) +/// and *Kid Icarus* poll it. Fixed for now (a rebindable mic key is a follow-up). +pub const MICROPHONE_KEY: KeyCode = KeyCode::KeyM; + /// v1.2.0 Workstream D — host-key -> Family BASIC keyboard matrix-index map. /// /// The Famicom Family BASIC keyboard is a `9 x 8` switch matrix (`9` rows x @@ -821,6 +833,7 @@ impl InputState { power_pad: 0, konami_hyper_shot: 0, bandai_hyper_shot: 0, + microphone: false, } } @@ -883,6 +896,14 @@ impl InputState { self.bandai_hyper_shot } + /// v2.2.0 "Capstone" — whether the Famicom microphone hold-to-talk key + /// ([`MICROPHONE_KEY`]) is currently held. Fed to the core each frame via + /// `Nes::set_microphone`; `false` (default) keeps `$4016` byte-identical. + #[must_use] + pub const fn microphone(&self) -> bool { + self.microphone + } + /// Currently-held player-1 buttons (keyboard OR gamepad OR stick). #[must_use] pub fn player1(&self) -> Buttons { @@ -1073,6 +1094,13 @@ impl InputState { } } + // v2.2.0 "Capstone" — Famicom microphone hold-to-talk key. Tracked + // unconditionally; the core only surfaces it on $4016.D2 when driven, so + // the no-mic path stays byte-identical. + if code == MICROPHONE_KEY { + self.microphone = pressed; + } + // System actions. Rewind is special: emit on both press and // release so the run loop can transition between forward play // and step-back. @@ -1099,6 +1127,18 @@ impl InputState { mod tests { use super::*; + #[test] + fn microphone_key_tracks_hold_state() { + let mut s = InputState::with_defaults(); + assert!(!s.microphone(), "mic released by default"); + let (k, e) = down(MICROPHONE_KEY); + s.handle_key(k, e); + assert!(s.microphone(), "mic key down -> held"); + let (k, e) = up(MICROPHONE_KEY); + s.handle_key(k, e); + assert!(!s.microphone(), "mic key up -> released"); + } + fn down(code: KeyCode) -> (PhysicalKey, ElementState) { (PhysicalKey::Code(code), ElementState::Pressed) } diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index a8bee85a..f4828525 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -37,7 +37,9 @@ disposition under the v2.1.0 "Fathom" accuracy-remediation line | NTSC composite shader ladder (F2.2) | Display-only GPU post-passes: simplified blur (`Ntsc`) → LMP88959 composite → Bisqwit per-dot (`CompositeRt`) | No pass/fail ROM (visual only); `visual_regression` stays byte-identical with any filter active | **Shipped** (v2.1.2) — three-rung ladder verified end-to-end, live emulator-synced dot-crawl now on both composite passes (`Lmp88959` phase wired), palette↔pass split documented; no separable-kernel rung (LMP covers that tier). See `docs/frontend.md` | | Vs. `DualSystem` second screen | Core modeled + `sub_framebuffer()` exposed; desktop frontend now presents both screens (side-by-side / stacked), routes P1-P4 + coin, plays the main console's audio | Synth harness + boot of the 4 DualSystem titles (real-cabinet boot stays fixture-limited — see below) | **Shipped** (v2.1.2 F2.1, desktop) — advanced features (run-ahead / rewind / netplay / TAS / dual save-state) scoped out in dual mode per ADR 0032; wasm/mobile deferred | | 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 | No CRC/gaps-on-write, fixed-cycle seek (no analog seek) | Real-BIOS write-verify (copyright-gated, out of CI) | **Deferred** stretch (F4.3) | +| 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) | +| 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) | | APU non-linear mixer | Lookup-table matches within the `apu_mixer` band | `apu_mixer` (analog-cancellation, tolerance) | **No stricter oracle** — the LUT already passes; ±4% is honest | diff --git a/docs/frontend.md b/docs/frontend.md index a61bf315..4d867ce3 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -1136,6 +1136,32 @@ movies + netplay and adds no new determinism surface. Hidden by default (the "Touch controls" checkbox reveals it), so the desktop keyboard UX is unchanged; the native build is byte-identical (all touch state is wasm-only). +**Capstone peripherals (v2.2.0).** Three input-path additions, all additive so +the default (no-device) input path stays byte-identical: + +- **Famicom microphone.** The hardwired second Famicom controller's push-to-talk + microphone is surfaced on **`$4016` bit 2** (a `$4016`-only signal — it never + touches `$4017`), driven by `Nes::set_microphone(pressed)`. The frontend maps a + hold-to-talk key to it; games such as *The Legend of Zelda* (killing Pols + Voice) and *Kid Icarus* poll it. Default (mic released) leaves the `$4016` read + byte-identical to a stock NES, so the standard controller path is unaffected. + The mic is a transient live signal (like a held button), released on + power-cycle. +- **Family BASIC keyboard.** The full `9 × 8` positional keyboard matrix + (`FamilyKeyboardState`, and the Subor clone) is selectable as the port-2 + expansion device; `input::family_keyboard_index` maps host keys 1:1 onto the + 72-key matrix (row-select via the `$4016` strobe + column-half on `$4017`). +- **Zapper light-timing.** The photodiode now integrates a **3×3 aperture** + (field-of-view) around the aim point rather than a single pixel, asserting + light only when ≥2 pixels cross the luma threshold (`ZAPPER_APERTURE_*`). This + hardens detection against sub-pixel aim error and PPU edge noise while + remaining a deterministic, pure function of the presented framebuffer (no + save-state change). The finer ~19-26-scanline photodiode temporal hold is + below the per-frame sample resolution used here; supported light-gun titles + re-poll every frame, so frame-granular aperture sampling suffices. A full + per-dot temporal integration against the beam position is a documented future + refinement. + **Browser save-states + movies (wasm)** (v1.4.0 Workstream E). The browser build reaches native QoL parity for two persistence features: diff --git a/docs/mappers.md b/docs/mappers.md index 8235c22f..3d558659 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -101,6 +101,40 @@ distinguish some conflict-free homebrew or modified boards from original conflict-prone boards. The mapper implementation should therefore decide conflicts from mapper/submapper/board metadata, not only from mapper number. +### FDS medium model (Mapper 20, v2.2.0 "Capstone") + +The Famicom Disk System RAM adapter (`crates/rustynes-mappers/src/fds.rs`) models +the disk **medium** as a synthesized byte-stream wire image, not just the raw +`.fds` payload: + +- **Wire image**: each inserted side is expanded into the hardware wire format the + RP2C33 controller scans — a lead-in gap (`$00` run), a `$80` start mark, the + block bytes, and a **CRC-16/KERMIT** (reflected poly `0x8408`) per block, with + inter-block gaps between them. Reads stream this wire image; the controller's + gap-skip hides the gap + start mark from the CPU (first byte delivered is the + block's first real byte). +- **Per-block CRC-16 on write**: when the BIOS writes a block payload, the write + path mirrors the byte into the raw side **and re-emits that block's CRC-16** + over the updated payload (`resynth_block_crc`), modelling the controller's + continuous CRC generator so the medium stays self-consistent (a stricter loader + checking `$4030.D4`, and the synthetic oracle below, see a valid block). +- **Continuous analog head-seek model** (opt-in, default-off — `set_analog_head_seek`): + the belt-driven head's motor-restart rewind time is proportional to the + distance it had travelled from the disk-start gap (a constant belt velocity, + `HEAD_SEEK_BYTES_PER_CYCLE`, plus a fixed `HEAD_SEEK_SETTLE_CYCLES` settle), + clamped to a cold `MOTOR_SPIN_UP_CYCLES` spin-up. This replaces the flat + `HEAD_RESEEK_CYCLES` not-ready window with a position-dependent seek. **With the + model disabled (the default) a non-writing `.fds` run is byte-identical** to + prior releases; the model state round-trips the **v4** save-state tail. +- **Synthetic write-verify oracle** (`medium_write_verify`): a BIOS-free walk of + the wire image asserting every block's CRC-16 and gap/mark framing round-trips. + This is the **CI-verifiable** half of the medium model; the real-BIOS write-CRC + path needs a copyright FDS BIOS and is exercised only from a local, gitignored + dump. See `docs/accuracy-ledger.md` for the CI-verifiable vs local-only split. + +All FDS timing is deterministic cycle arithmetic (no wall-clock / analog jitter), +so the determinism contract and save-state round-trip hold. + ## Mapper coverage matrix (Phase 4 status) Sorted by number of commercial titles using each mapper. From 3158fbcbf6fe4830a22c9eaebcde63785973f66f Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 12 Jul 2026 00:29:09 -0400 Subject: [PATCH 3/4] docs(fds,zapper): plain code spans for private-item intra-doc links Convert doc links to pub(crate)/private items (FdsMediumError, reseek_window_cycles, ZAPPER_APERTURE_*) to plain code spans so the default cargo doc build passes -D warnings (project convention for feature-/visibility-limited item names). --- CHANGELOG.md | 77 ++++++++++++------------ crates/rustynes-core/src/input_device.rs | 4 +- crates/rustynes-mappers/src/fds.rs | 4 +- 3 files changed, 42 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d21d7154..12602a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,11 +23,48 @@ cycle-accurate core later replaced. - **Fuzz-target expansion (v2.2.0 "Capstone", quality).** `fuzz/` grows from 3 to 8 cargo-fuzz targets covering the remaining untrusted-input boundaries: `ppu_reg_io` (`Ppu::cpu_{read,write}_register` over a minimal `PpuBus`), `apu_reg_io` (`Apu::write_register` / `read_status`), `netplay_message` (the highest-value target — `NetMessage::from_bytes` binary UDP + `SignalMessage::parse` JSON signaling/lobby, both ingesting bytes straight off the wire), `save_state` (`parse_header` + `Nes::extract_thumbnail` + `restore_quiet`), and `movie` (`Movie::deserialize`). Each builds under nightly cargo-fuzz and runs clean for tens of thousands of iterations. `fuzz/README.md` documents the targets + the LeakSanitizer-under-sandbox note. - **Read-only ROM Info browser (v2.2.0 "Capstone").** A new **Tools → ROM Info** panel (`crates/rustynes-frontend/src/debugger/rom_info_panel.rs`) surfaces, for the loaded ROM, the two dump-identity CRC32 keys (the header-excluded game-DB key + the full-file **No-Intro** key), the SHA-256, the effective per-game database entry (title / mapper / region / mirroring / submapper), and the decoded cartridge header read straight off the running `Nes` (mapper id, region, PRG-ROM / CHR-ROM sizes). Read-only (`&Nes`) — never mutates the emulator or the DB overlay; the deterministic core never consults it. Honest about surfacing only the vendored per-game DB + the header (no bootgod / nescartdb table is vendored). - **MkDocs handbook deepening (v2.2.0 "Capstone", quality).** Four new Material-for-MkDocs handbook pages — `docs/expansion-audio.md`, `docs/pal-region.md`, `docs/crt-composite.md`, `docs/creator-tools.md` — curated entry points for the newer subsystems, cross-linked to the authoritative `apu-2a03.md` / `ppu-2c02.md` / `frontend.md` specs, with matching `mkdocs.yml` nav entries. +- **FDS medium model completion — CRC-16 / gap / continuous head-seek (v2.2.0 + "Capstone", F4.3).** The Famicom Disk System RAM adapter + (`crates/rustynes-mappers/src/fds.rs`) completes the disk **medium** model. The + disk is a synthesized byte-stream wire image — lead-in / inter-block gaps, a + `$80` start mark, the block bytes, and a **CRC-16/KERMIT** per block — and each + BIOS-written block now **re-emits a fresh per-block CRC-16** over its updated + payload (`resynth_block_crc`), modelling the RP2C33 controller's continuous CRC + generator so the medium stays self-consistent after a write. A new **continuous + analog head-seek / velocity model** (opt-in, default-OFF — + `Fds::set_analog_head_seek`) replaces the flat fixed `HEAD_RESEEK_CYCLES` + motor-restart not-ready window with a belt-driven, distance-proportional seek + time (`HEAD_SEEK_BYTES_PER_CYCLE` velocity + `HEAD_SEEK_SETTLE_CYCLES` settle, + clamped to a cold spin-up), sized from the head-travel distance captured at + motor-off. A **BIOS-free synthetic write-verify oracle** + (`Fds::medium_write_verify`) walks the wire image and asserts every block's + CRC-16 and gap/mark framing round-trips — the CI-verifiable half of the medium + model; the real-BIOS write-CRC path needs a copyright `disksys.rom` and is + exercised only from a gitignored local dump (`docs/accuracy-ledger.md` records + the CI-verifiable-vs-local-only split). **Additive and deterministic**: with + the head-seek model off (the default) a non-writing `.fds` run is + **byte-identical** to prior releases; the new state round-trips an additive + **v4** FDS save-state tail (v1/v2/v3 blobs load with the model disabled). + AccuracyCoin has no FDS ROM, so **141/141 (100%)** is unaffected. +- **Famicom microphone + Zapper light-timing hardening (v2.2.0 "Capstone" + peripherals).** The Famicom built-in controller-2 **microphone** is modelled on + **`$4016` bit 2** (`Nes::set_microphone` / `Bus::set_microphone`), wired through + the frontend input path (hold-to-talk `M` key → `FrameInputs.microphone` → + latch), for games such as *The Legend of Zelda* (Pols Voice) and *Kid Icarus*. + It is a `$4016`-only signal (never touches `$4017`). The **Zapper** photodiode + now integrates a **3×3 aperture** (field-of-view) around the aim point, + asserting light only when ≥2 pixels cross the luma threshold + (`ZAPPER_APERTURE_*`) — hardening detection against sub-pixel aim error and PPU + edge noise vs the prior single-pixel sample, while staying a deterministic pure + function of the presented framebuffer (no save-state change). Both are additive + and **default-off**: the mic released leaves the `$4016` read byte-identical, + and the standard controller / Four Score path is unchanged. (The full Family + BASIC `9×8` keyboard matrix was already modelled; its frontend mapping is + unchanged.) ### Changed - **Movie (`.rnm`) deserializer hardening (v2.2.0 "Capstone", quality).** The new `movie` fuzz target surfaced two OOM DoS paths in `Movie::deserialize` (`crates/rustynes-core/src/movie.rs`), both now fixed **byte-identically for valid input**: (1) the untrusted 4-byte `frame_count` was passed straight to `Vec::with_capacity`, so a 49-byte header could claim a multi-gigabyte reservation — now capped at `remaining_bytes / width` (== `frame_count` for any real file); (2) a `bytes_per_frame` of 0 made each `r.take(0)` consume no input, so the frame loop pushed `frame_count` empty records out of a finite file — now rejected up front (a real movie always writes the fixed `BYTES_PER_FRAME` ≥ 1). Regression test `deserialize_hostile_frame_count_does_not_oom` added; the existing 44 movie tests (incl. the determinism round-trip) stay green. - ## [2.1.10] - 2026-07-12 - "Fathom" (creator tools and web parity — TAStudio greenzone + Lua API breadth + browser-RA auth-proxy deploy stack + Vs. DualSystem libretro presentation — "Loom") ### Added @@ -300,44 +337,6 @@ cycle-accurate core later replaced. byte-identical (AccuracyCoin **141/141**, nestest 0-diff, save-state round-trip byte-identical). No `dmc_dma_during_read4` sub-test is made to fail or newly `#[ignore]`'d. See ADR 0033 + `docs/scheduler.md` §"Unexpected DMA". -- **FDS medium model completion — CRC-16 / gap / continuous head-seek (v2.2.0 - "Capstone", F4.3).** The Famicom Disk System RAM adapter - (`crates/rustynes-mappers/src/fds.rs`) completes the disk **medium** model. The - disk is a synthesized byte-stream wire image — lead-in / inter-block gaps, a - `$80` start mark, the block bytes, and a **CRC-16/KERMIT** per block — and each - BIOS-written block now **re-emits a fresh per-block CRC-16** over its updated - payload (`resynth_block_crc`), modelling the RP2C33 controller's continuous CRC - generator so the medium stays self-consistent after a write. A new **continuous - analog head-seek / velocity model** (opt-in, default-OFF — - `Fds::set_analog_head_seek`) replaces the flat fixed `HEAD_RESEEK_CYCLES` - motor-restart not-ready window with a belt-driven, distance-proportional seek - time (`HEAD_SEEK_BYTES_PER_CYCLE` velocity + `HEAD_SEEK_SETTLE_CYCLES` settle, - clamped to a cold spin-up), sized from the head-travel distance captured at - motor-off. A **BIOS-free synthetic write-verify oracle** - (`Fds::medium_write_verify`) walks the wire image and asserts every block's - CRC-16 and gap/mark framing round-trips — the CI-verifiable half of the medium - model; the real-BIOS write-CRC path needs a copyright `disksys.rom` and is - exercised only from a gitignored local dump (`docs/accuracy-ledger.md` records - the CI-verifiable-vs-local-only split). **Additive and deterministic**: with - the head-seek model off (the default) a non-writing `.fds` run is - **byte-identical** to prior releases; the new state round-trips an additive - **v4** FDS save-state tail (v1/v2/v3 blobs load with the model disabled). - AccuracyCoin has no FDS ROM, so **141/141 (100%)** is unaffected. -- **Famicom microphone + Zapper light-timing hardening (v2.2.0 "Capstone" - peripherals).** The Famicom built-in controller-2 **microphone** is modelled on - **`$4016` bit 2** (`Nes::set_microphone` / `Bus::set_microphone`), wired through - the frontend input path (hold-to-talk `M` key → `FrameInputs.microphone` → - latch), for games such as *The Legend of Zelda* (Pols Voice) and *Kid Icarus*. - It is a `$4016`-only signal (never touches `$4017`). The **Zapper** photodiode - now integrates a **3×3 aperture** (field-of-view) around the aim point, - asserting light only when ≥2 pixels cross the luma threshold - (`ZAPPER_APERTURE_*`) — hardening detection against sub-pixel aim error and PPU - edge noise vs the prior single-pixel sample, while staying a deterministic pure - function of the presented framebuffer (no save-state change). Both are additive - and **default-off**: the mic released leaves the `$4016` read byte-identical, - and the standard controller / Four Score path is unchanged. (The full Family - BASIC `9×8` keyboard matrix was already modelled; its frontend mapping is - unchanged.) ## [2.1.6] - 2026-07-11 - "Fathom" (expansion audio — decibel oracle + hardware/Mesen2 channel-level calibration + Namco 163 12 dB fix + mix UI/scopes; "Timbre") diff --git a/crates/rustynes-core/src/input_device.rs b/crates/rustynes-core/src/input_device.rs index f37ac460..1ab60cc7 100644 --- a/crates/rustynes-core/src/input_device.rs +++ b/crates/rustynes-core/src/input_device.rs @@ -245,8 +245,8 @@ impl ZapperState { /// /// v2.2.0 "Capstone" light-timing hardening: rather than sampling a single /// pixel, the sensor integrates a `(2r+1) x (2r+1)` aperture - /// ([`ZAPPER_APERTURE_RADIUS`]) and asserts light only when at least - /// [`ZAPPER_APERTURE_MIN_BRIGHT`] pixels cross [`ZAPPER_LUMA_THRESHOLD`]. This + /// (`ZAPPER_APERTURE_RADIUS`) and asserts light only when at least + /// `ZAPPER_APERTURE_MIN_BRIGHT` pixels cross `ZAPPER_LUMA_THRESHOLD`. This /// models the lens/photodiode field-of-view against the PPU's per-dot output: /// the bright target the game flashes lights the whole aperture (robust /// detection), while a black "blanked" background frame — or a lone stray diff --git a/crates/rustynes-mappers/src/fds.rs b/crates/rustynes-mappers/src/fds.rs index 9d69b0d8..7aac97b1 100644 --- a/crates/rustynes-mappers/src/fds.rs +++ b/crates/rustynes-mappers/src/fds.rs @@ -1652,7 +1652,7 @@ impl Fds { /// /// # Errors /// - /// Returns the first [`FdsMediumError`] encountered (missing start mark, + /// Returns the first `FdsMediumError` encountered (missing start mark, /// CRC mismatch, truncated block, or a corrupted inter-block gap). Returns /// `Ok(())` when no side is inserted (nothing to verify). pub fn medium_write_verify(&self) -> Result<(), FdsMediumError> { @@ -1711,7 +1711,7 @@ impl Fds { /// rewinds use the flat [`HEAD_RESEEK_CYCLES`] window, so a non-writing /// `.fds` run is byte-identical to prior releases. When enabled, the re-seek /// window scales with head-travel distance (belt velocity). See - /// [`Self::reseek_window_cycles`]. + /// `Self::reseek_window_cycles`. pub const fn set_analog_head_seek(&mut self, enabled: bool) { self.analog_head_seek = enabled; } From 51f241298bed8919d666d9b2ad872634fc443e09 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Sun, 12 Jul 2026 10:44:18 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(fds,input):=20adopt=20PR=20#291=20revie?= =?UTF-8?q?w=20=E2=80=94=20mic=20bit=20on=20DMC-conflict=20$4016=20+=20FDS?= =?UTF-8?q?=20tail=20offset=20invariant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Copilot review threads on the v2.2.0 "Capstone" FDS + peripherals PR, both valid, both fixed with default-off byte-identity preserved. 1. bus.rs — DMC-DMA register-conflict read of $4016 dropped the microphone bit. The normal $4016 read path composes D2 from `famicom_mic` (`<< 2`), but `dmc_dma_read`'s `conflict_addr == 0x4016` branch rebuilt the byte from `controllers[0].read()` alone, so a DMC-DMA that collides with a $4016 read while the mic is asserted would have incorrectly cleared bit 2. Now OR in the same `mic = u8::from(self.famicom_mic) << 2` term, keeping the conflict path consistent with the normal controller-read semantics. Mic is default -off (released ⇒ mic == 0), so the returned byte is byte-identical to prior releases on the default; only an actively-held Famicom mic during a DMC/OAM DMA overlap is affected — the accurate behavior. 2. fds.rs — `load_disk_tail` read the v4 `pre_rewind_head` u32 out of `data[off..off+4]` without advancing `off`, breaking the "offset always reflects consumed bytes" invariant (functionally inert today since `off` was dead afterward, but a hazard for any future tail extension). Advance `off += 4` after the read, and add a `debug_assert_eq!(off, data.len())` that both documents/enforces the invariant (the top-of-fn length check already proved `expected == data.len()`, and both the v4 and v3 paths consume exactly to the blob end) and reads `off` on every path, so the new final assignment does not trip `unused_assignments` under `-D warnings`. Gate: fmt/clippy (workspace + frontend scripting/hd-pack/retroachievements)/ no_std thumbv7em/rustdoc/markdownlint all green; AccuracyCoin 141/141 (100%), visual_regression + nestest + pal_apu_tests byte-identical, FDS synthetic write-verify + NES-level save-state round-trip pass. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + crates/rustynes-core/src/bus.rs | 7 ++++++- crates/rustynes-mappers/src/fds.rs | 12 ++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12602a62..d602250c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ cycle-accurate core later replaced. ### Changed - **Movie (`.rnm`) deserializer hardening (v2.2.0 "Capstone", quality).** The new `movie` fuzz target surfaced two OOM DoS paths in `Movie::deserialize` (`crates/rustynes-core/src/movie.rs`), both now fixed **byte-identically for valid input**: (1) the untrusted 4-byte `frame_count` was passed straight to `Vec::with_capacity`, so a 49-byte header could claim a multi-gigabyte reservation — now capped at `remaining_bytes / width` (== `frame_count` for any real file); (2) a `bytes_per_frame` of 0 made each `r.take(0)` consume no input, so the frame loop pushed `frame_count` empty records out of a finite file — now rejected up front (a real movie always writes the fixed `BYTES_PER_FRAME` ≥ 1). Regression test `deserialize_hostile_frame_count_does_not_oom` added; the existing 44 movie tests (incl. the determinism round-trip) stay green. + ## [2.1.10] - 2026-07-12 - "Fathom" (creator tools and web parity — TAStudio greenzone + Lua API breadth + browser-RA auth-proxy deploy stack + Vs. DualSystem libretro presentation — "Loom") ### Added diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index 2d76cb0c..11ec42eb 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -3550,7 +3550,12 @@ impl LockstepBus { sample } 0x4016 => { - let v = (sample & 0xE0) | self.controllers[0].read(); + // Keep the DMC-conflict $4016 composition consistent with the + // normal controller read (line ~3890): D2 carries the Famicom + // built-in microphone. Default-off (mic released) leaves `mic` + // = 0, so the returned byte is byte-identical to prior releases. + let mic = u8::from(self.famicom_mic) << 2; + let v = (sample & 0xE0) | self.controllers[0].read() | mic; self.open_bus = v; v } diff --git a/crates/rustynes-mappers/src/fds.rs b/crates/rustynes-mappers/src/fds.rs index 7aac97b1..46838000 100644 --- a/crates/rustynes-mappers/src/fds.rs +++ b/crates/rustynes-mappers/src/fds.rs @@ -2067,10 +2067,22 @@ impl Fds { off += 1; self.pre_rewind_head = u32::from_le_bytes(data[off..off + 4].try_into().unwrap()) as usize; + // Advance past the u32 we just consumed so `off` keeps reflecting the + // total bytes read — preserving the "offset == consumed" invariant so + // any future tail extension starts from the correct position. + off += 4; } else { self.analog_head_seek = false; self.pre_rewind_head = 0; } + // Both paths must have consumed exactly the blob the length check at the + // top validated (`expected == data.len()`); assert the invariant and, in + // doing so, read `off` on every path (no `unused_assignments`). + debug_assert_eq!( + off, + data.len(), + "FDS disk tail consumed byte count must match the validated blob length" + ); Ok(()) } }