pub struct ZapperState { /* private fields */ }Expand description
The NES Zapper light-gun overlay state.
Models the NES variant: bit 3 = light sensed (0 detected / 1 not), bit 4 =
trigger (1 pulled). Light detection samples the PPU framebuffer luminance at
the aim point once per frame (a frame-granular model — see light_seen).
Implementations§
Source§impl ZapperState
impl ZapperState
Sourcepub const fn set(&mut self, x: u16, y: u16, trigger: bool)
pub const fn set(&mut self, x: u16, y: u16, trigger: bool)
Update the live aim point + trigger state.
Sourcepub fn sample_light(&mut self, framebuffer: &[u8])
pub fn sample_light(&mut self, framebuffer: &[u8])
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.
Sourcepub fn light_at_scanline(&self, framebuffer: &[u8], scanline: u16) -> bool
pub fn light_at_scanline(&self, framebuffer: &[u8], scanline: u16) -> bool
A3 (v2.2.3): does the photodiode see light right now, given where the CRT beam currently is?
The frame-granular Self::sample_light answers “was the aim point
bright in the completed frame”, which is constant for the whole frame —
so a game polling immediately after its flash and one polling 100
scanlines later get the same answer. Real hardware does not work that
way: the photodiode charges as the beam passes the aim point and
drains over ~19-26 scanlines afterwards.
This models that directly, as a pure function of
(framebuffer, aim, current scanline):
- before the beam reaches the aim row (
scanline < y) — dark, because this frame has not painted it yet; - from the aim row until the hold expires — bright iff the aperture is
bright over the rows the beam has already finished, per
aperture_is_bright_painted(a plain code span, not an intra-doc link: that item is private andrustdoc::private_intra_doc_linksis denied); - after the hold — dark again, the capacitor having drained.
Holding no extra state is deliberate: light is derived on demand at read time rather than latched by a per-scanline callback, so it adds no field to serialize, cannot desync a save state or a netplay rollback, and keeps the determinism contract (same framebuffer + aim + scanline always yields the same answer).
§A wrong claim this used to make (v2.3.6)
This doc previously ended: “One consequence is physically right rather than a compromise: the aperture rows below the beam still hold the previous frame’s pixels, which is exactly what the sensor sees, since the beam has not repainted them yet.”
That is backwards. A photodiode responds to light the phosphor has emitted; a row the beam has not reached this frame is emitting nothing, and its stale framebuffer contents are an artefact of how the emulator stores pixels, not something a sensor could see. Reading those rows made the model report light on an all-black screen — measured at scanline 96, where the beam was 5 dots into row 96 and the sampler returned the previous frame’s sky at luma 152 on a frame whose mean luma was 0.
Because Duck Hunt requires the gun to see nothing for one frame before it will accept a shot, that false positive discarded every shot: the gun fired and no duck could ever be hit. The rows are now clipped, and the paragraph is kept rather than deleted because the plausible-sounding wrong reasoning is what made the defect look intentional.
Sourcepub fn read_at_scanline(&self, framebuffer: &[u8], scanline: u16) -> u8
pub fn read_at_scanline(&self, framebuffer: &[u8], scanline: u16) -> u8
The device byte as Self::read would return it, but using the
beam-relative light state from Self::light_at_scanline.
Sourcepub const fn read_before_visible(&self) -> u8
pub const fn read_before_visible(&self) -> u8
The device byte for a read taken before the visible frame begins — the answer when the PPU’s scanline is negative, which no light is detectable for (the beam has painted nothing this frame yet).
This is a total-conversion fallback, not a fix for a live defect. In this
engine Ppu::scanline() is non-negative on every region — the pre-render
line is 261 (NTSC) / 311 (PAL), not -1 — so the visible/vblank path
through Self::read_at_scanline already yields no-light for pre-render
(prerender - y >= ZAPPER_LIGHT_HOLD_SCANLINES for every on-screen aim),
and this branch is not reached. It exists so that the caller’s
u16::try_from(scanline) has a correct Err answer — “no light yet” —
rather than the row-0 fold a bare unwrap_or(0) would produce, should a
future scanline convention (a -1 pre-render, as some emulators use) ever
hand this a negative value.
Sourcepub const fn read(&self) -> u8
pub const fn read(&self) -> u8
The device byte for a $4016/$4017 access. Bit 3 = light (0 detected /
1 not), bit 4 = trigger (1 pulled). Independent of the strobe (the
Zapper has no shift register). The caller ORs in the open-bus upper bits.
Sourcepub const fn from_parts(x: u16, y: u16, trigger: bool, light_seen: bool) -> Self
pub const fn from_parts(x: u16, y: u16, trigger: bool, light_seen: bool) -> Self
Reconstruct from save-state parts.
Sourcepub const fn trigger_raw(&self) -> bool
pub const fn trigger_raw(&self) -> bool
Raw trigger state (save-state).
Sourcepub const fn light_seen_raw(&self) -> bool
pub const fn light_seen_raw(&self) -> bool
Raw light-seen state (save-state).
Trait Implementations§
Source§impl Clone for ZapperState
impl Clone for ZapperState
Source§fn clone(&self) -> ZapperState
fn clone(&self) -> ZapperState
1.0.0 · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read more