Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,44 @@ 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

Expand Down
62 changes: 59 additions & 3 deletions crates/rustynes-core/src/bus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::input_device::InputDevice>; 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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -3521,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
}
Expand Down Expand Up @@ -3859,7 +3893,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)
Comment thread
doublegate marked this conversation as resolved.
}
0x4017 => {
Expand Down Expand Up @@ -4944,6 +4979,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();
Expand Down
120 changes: 97 additions & 23 deletions crates/rustynes-core/src/input_device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 /
Expand Down Expand Up @@ -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)"
);
}

Expand Down
11 changes: 11 additions & 0 deletions crates/rustynes-core/src/nes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions crates/rustynes-frontend/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading