Skip to main content

rustynes_core/
hardware_options.rs

1//! v2.9.8 — the emulation options a movie or a netplay session must agree on.
2//!
3//! The determinism contract is "same ROM + same seed + same input ⇒
4//! bit-identical output". Until v2.9.8 a `.rnm` movie recorded the ROM and the
5//! input and nothing else, and netplay compared only the ROM, so every host
6//! knob that changes what the console *does* was left to whatever the player
7//! happened to have configured: record on the Famicom model, replay on the NES
8//! model, and the replay silently ran a different machine. The maintainer's
9//! decision of 2026-10-01 is that a replay or a peer must never silently
10//! diverge, so this module gives that knob set one canonical, serialisable
11//! form, [`HardwareOptions`], which the movie stores in its header and netplay
12//! folds into its handshake.
13//!
14//! Two kinds of fact are kept apart here because they are enforced
15//! differently:
16//!
17//! - **[`HardwareOptions`] are host settings** — a console model, a die
18//!   revision, a power-on fill, an overclock, a Game Genie code. The host can
19//!   change them, so a movie *applies* them before frame 0 whatever the
20//!   player's own settings are.
21//! - **[`BoardDescription`] is what the cartridge header says** — mapper,
22//!   submapper, mirroring, RAM sizes. Since v2.9.8 `Nes::rom_sha256` excludes
23//!   the 16-byte header, so two images with the same PRG/CHR but different
24//!   (or differently corrected) headers share an identity while building
25//!   different machines. A host cannot apply a header, so a movie *checks* the
26//!   description and refuses on a mismatch, naming the field.
27//!
28//! # What is deliberately not here
29//!
30//! The survey behind this list is in `docs/frontend.md` § "What a movie
31//! records". In short: presentation (custom palette, the APU channel mask,
32//! gain and analog filter model, the sample rate), performance selectors that
33//! are proven byte-identical (the fast dot path), debug observability
34//! (tracing, logging, breakpoints, provenance), and **inputs** — the two
35//! standard pads are in the movie's input stream, while Four Score players
36//! 3/4, expansion devices, the Famicom microphone, Vs. coins / service and FDS
37//! disk swaps are per-frame inputs a `.rnm` does not carry (a documented limit
38//! of the input stream, not an option).
39//!
40//! This module is `no_std`-clean: `core` + `alloc` and the save-state
41//! `BinWriter` / `BinReader` primitives only.
42
43use alloc::string::{String, ToString};
44use alloc::vec::Vec;
45
46use rustynes_mappers::{ConsoleType, Mirroring, VsPpuType};
47use sha2::{Digest, Sha256};
48
49use crate::Cpu2A03Revision;
50use crate::Region;
51use crate::genie::GenieCode;
52use crate::nes::{ConsoleModel, Nes, PowerOnRam};
53use crate::save_state::{BinReader, BinWriter};
54use rustynes_ppu::{PaletteInit, PpuRevision};
55
56/// Why an options or board block could not be decoded.
57///
58/// Carried by `MovieError::BadOptions`; a `&'static str` because every case
59/// is a fixed fact about the bytes ("an unknown console-model byte"), never a
60/// value worth echoing back from an untrusted file.
61pub type OptionsDecodeError = &'static str;
62
63/// The most Game Genie codes one record may carry. A real cheat list is a
64/// handful of codes; the bound exists so a hostile count byte cannot ask the
65/// decoder to loop or allocate without limit. 255 is what the count byte can
66/// express, so it costs no legitimate list.
67const MAX_GENIE_CODES: usize = u8::MAX as usize;
68
69/// v3.0.0 (ADR 0045) — which emulator *behaviour* a movie or a netplay peer
70/// expects, beside the options and the board that [`HardwareOptions`] and
71/// [`BoardDescription`] describe.
72///
73/// Two builds with identical options can still emulate a game differently
74/// whenever an accuracy fix lands: v3.0.0's T-MMC3-BG-A12, for instance,
75/// moves the MMC3 IRQ for games with the background at `$1000`. Without a
76/// record of which behaviour a recording assumes, a movie replays under the
77/// new timing and a mixed-version netplay session desyncs, and neither says
78/// why. `.rnm` format 5 records this number, and netplay protocol 6 sends it
79/// in the handshake; a mismatch is refused, naming both epochs.
80///
81/// **The bump rule.** Increment it, in the same change, whenever a change
82/// makes the core produce a different framebuffer, audio sample or bus cycle
83/// from the same inputs than the last release did. Every such change already
84/// re-blesses a golden or moves a commercial snapshot, so that is the
85/// trigger to look for. Refactors, byte-identical performance work, frontend
86/// features and new mapper families (which have no earlier output to differ
87/// from) do not bump it. A release that moved goldens without raising it
88/// breaks the promise this constant exists to keep.
89///
90/// It was 1 at v3.0.0, the first release to carry it. Earlier builds have no
91/// epoch, and are refused by the movie format (5) and the protocol (6)
92/// instead.
93///
94/// | epoch | release | what moved |
95/// | --- | --- | --- |
96/// | 1 | v3.0.0 | the MMC3 background-at-`$1000` A12 rule (T-MMC3-BG-A12) |
97/// | 2 | v3.0.1 | mapper 45 CHR-RAM unbanked (T-GA23C-CHRRAM; *Famicom Yarou Vol.1*) |
98/// | 3 | v3.1.0 | a DMC load DMA refused by a write takes four cycles; sprite evaluation starts at OAMADDR as of dot 65 and keeps a misaligned OAMADDR when X is in range (AccuracyCoin re-sync to `f5f41dc2`) |
99pub const EMULATION_EPOCH: u32 = 3;
100
101/// Every host-settable option that changes what the emulated console does.
102///
103/// [`Default`] is the stock NES: the configuration every release before the
104/// option existed emulated, and what a foreign movie import (`.fm2`, `.bk2`,
105/// `.fcm`, `.fmv`, `.vmv`, `.mc2`) records, because those formats cannot say
106/// anything else.
107///
108/// One exception to "all defaults are the default build": the Vs. PPU type is
109/// a property of the cartridge header that a host may override, so its
110/// default is `None`, meaning "whatever the header declares" — applying a
111/// stock `VsPpuType::None` to a Vs. cartridge would strip its RGB PPU.
112///
113/// `#[non_exhaustive]` since v3.0.0 (T-API-EXTENSIBLE): outside this crate,
114/// start from [`HardwareOptions::default`] (the stock NES) or
115/// [`HardwareOptions::capture`] and set the fields that differ. A later
116/// option is then not a break.
117#[derive(Clone, Debug, Eq, PartialEq, Hash)]
118#[non_exhaustive]
119// Four independent switches (OAM decay, Four Score, the Zapper light model,
120// the sprite limit), each mirroring one `Nes` setter one-to-one; no two are
121// states of one thing, so an enum or bitflags would only obscure the mapping.
122#[allow(clippy::struct_excessive_bools)]
123pub struct HardwareOptions {
124    /// Which console's reset wiring is modelled ([`Nes::set_console_model`]).
125    pub console_model: ConsoleModel,
126    /// The 2C02 die revision ([`Nes::set_ppu_revision`]).
127    pub ppu_revision: PpuRevision,
128    /// The 2A03 die revision ([`Nes::set_cpu_2a03_revision`]).
129    pub cpu_2a03_revision: Cpu2A03Revision,
130    /// The optional OAM-decay model ([`Nes::set_oam_decay`]).
131    pub oam_decay: bool,
132    /// The power-on work-RAM fill ([`Nes::set_power_on_ram`]).
133    pub power_on_ram: PowerOnRam,
134    /// The power-up palette-RAM contents ([`Nes::set_power_up_palette`]).
135    pub power_up_palette: PaletteInit,
136    /// The extra-vblank-scanline overclock ([`Nes::set_extra_scanlines`]).
137    pub extra_scanlines: u16,
138    /// The CPU-multiplier overclock, `1..=MAX_CPU_OVERCLOCK`
139    /// ([`Nes::set_cpu_overclock`]); `1` is stock. v3.1.0. A value outside
140    /// that range never reaches the core as written: decoding a record
141    /// refuses it, and applying one clamps it (`0` to `1`, anything above to
142    /// the maximum), as `Nes::set_cpu_overclock` does.
143    pub cpu_overclock: u8,
144    /// Draw the sprites beyond the eighth on a scanline
145    /// ([`Nes::set_sprite_limit_disabled`]); render-only. v3.1.0.
146    pub sprite_limit_disabled: bool,
147    /// A forced MMC3 IRQ revision ([`Nes::set_mmc3_revision_override`]);
148    /// `None` = the header's. v3.1.0.
149    pub mmc3_revision: Option<rustynes_mappers::Mmc3Revision>,
150    /// Whether the Four Score adapter is plugged in ([`Nes::set_four_score`]).
151    /// It changes `$4016` / `$4017` reads 9-24 even with players 3/4 idle.
152    pub four_score: bool,
153    /// The beam-relative Zapper light model
154    /// ([`Nes::set_zapper_temporal_light`]).
155    pub zapper_temporal_light: bool,
156    /// The Vs. System DIP-switch bank ([`Nes::set_vs_dip`]); read through
157    /// `$4016` / `$4017` on Vs. carts, inert elsewhere.
158    pub vs_dip: u8,
159    /// The Vs. System PPU type ([`Nes::set_vs_ppu_type`]); `None` = the
160    /// header's. See the type-level note.
161    pub vs_ppu_type: Option<VsPpuType>,
162    /// The per-game nametable mirroring override
163    /// ([`Nes::set_mirroring_override`]); `None` = the mapper decides.
164    pub mirroring_override: Option<Mirroring>,
165    /// The active Game Genie codes, canonical upper-case strings in address
166    /// order ([`Nes::add_genie_code`]).
167    pub genie_codes: Vec<String>,
168}
169
170impl Default for HardwareOptions {
171    fn default() -> Self {
172        Self {
173            console_model: ConsoleModel::default(),
174            ppu_revision: PpuRevision::default(),
175            cpu_2a03_revision: Cpu2A03Revision::default(),
176            oam_decay: false,
177            power_on_ram: PowerOnRam::default(),
178            power_up_palette: PaletteInit::default(),
179            extra_scanlines: 0,
180            cpu_overclock: 1,
181            sprite_limit_disabled: false,
182            mmc3_revision: None,
183            four_score: false,
184            // The core's own default since v2.2.x (`zapper_temporal_light`
185            // is on in a freshly-built `Nes`); the stock machine, not `false`.
186            zapper_temporal_light: true,
187            vs_dip: 0,
188            vs_ppu_type: None,
189            mirroring_override: None,
190            genie_codes: Vec::new(),
191        }
192    }
193}
194
195impl HardwareOptions {
196    /// The options `nes` is running with right now.
197    #[must_use]
198    pub fn capture(nes: &Nes) -> Self {
199        Self {
200            console_model: nes.console_model(),
201            ppu_revision: nes.ppu_revision(),
202            cpu_2a03_revision: nes.cpu_2a03_revision(),
203            oam_decay: nes.oam_decay_enabled(),
204            power_on_ram: nes.power_on_ram(),
205            power_up_palette: nes.power_up_palette(),
206            extra_scanlines: nes.extra_scanlines(),
207            cpu_overclock: nes.cpu_overclock(),
208            sprite_limit_disabled: nes.sprite_limit_disabled(),
209            mmc3_revision: nes.mmc3_revision_override(),
210            four_score: nes.four_score(),
211            zapper_temporal_light: nes.zapper_temporal_light(),
212            vs_dip: nes.vs_dip(),
213            vs_ppu_type: Some(nes.vs_ppu_type()),
214            mirroring_override: nes.mirroring_override(),
215            genie_codes: nes.genie_codes().map(|g| g.code().to_string()).collect(),
216        }
217    }
218
219    /// Apply every option to `nes`, including the two power-on fills.
220    ///
221    /// The fills **write live state**: [`Nes::set_power_on_ram`] refills work
222    /// RAM and the open-bus latch, and [`Nes::set_power_up_palette`] rewrites
223    /// palette RAM. That is right before a power cycle or a save-state restore
224    /// (which replace that state anyway) and wrong in the middle of a game,
225    /// where [`Self::apply_live`] is the call to make.
226    ///
227    /// # Errors
228    ///
229    /// Returns the offending code if a Game Genie string does not decode.
230    /// Codes read through [`Self::read_from`] are validated there, so this
231    /// can only fail for a hand-built value.
232    pub fn apply(&self, nes: &mut Nes) -> Result<(), String> {
233        nes.set_power_on_ram(self.power_on_ram);
234        nes.set_power_up_palette(self.power_up_palette);
235        self.apply_live(nes)
236    }
237
238    /// Apply every option except the two power-on fills, never writing work
239    /// RAM or palette RAM. Each knob is compared first and set only if it
240    /// differs, so calling this once per frame to hold a movie's options in
241    /// place costs a handful of comparisons.
242    ///
243    /// The fills are excluded because they act only at the next power cycle
244    /// and applying them writes the running game's RAM; see [`Self::apply`].
245    ///
246    /// # Errors
247    ///
248    /// Returns the offending code if a Game Genie string does not decode.
249    pub fn apply_live(&self, nes: &mut Nes) -> Result<(), String> {
250        if nes.console_model() != self.console_model {
251            nes.set_console_model(self.console_model);
252        }
253        if nes.ppu_revision() != self.ppu_revision {
254            nes.set_ppu_revision(self.ppu_revision);
255        }
256        if nes.cpu_2a03_revision() != self.cpu_2a03_revision {
257            nes.set_cpu_2a03_revision(self.cpu_2a03_revision);
258        }
259        if nes.oam_decay_enabled() != self.oam_decay {
260            nes.set_oam_decay(self.oam_decay);
261        }
262        if nes.extra_scanlines() != self.extra_scanlines {
263            nes.set_extra_scanlines(self.extra_scanlines);
264        }
265        if nes.cpu_overclock() != self.cpu_overclock {
266            nes.set_cpu_overclock(self.cpu_overclock);
267        }
268        if nes.sprite_limit_disabled() != self.sprite_limit_disabled {
269            nes.set_sprite_limit_disabled(self.sprite_limit_disabled);
270        }
271        if nes.mmc3_revision_override() != self.mmc3_revision {
272            nes.set_mmc3_revision_override(self.mmc3_revision);
273        }
274        if nes.four_score() != self.four_score {
275            nes.set_four_score(self.four_score);
276        }
277        if nes.zapper_temporal_light() != self.zapper_temporal_light {
278            nes.set_zapper_temporal_light(self.zapper_temporal_light);
279        }
280        if nes.vs_dip() != self.vs_dip {
281            nes.set_vs_dip(self.vs_dip);
282        }
283        if let Some(t) = self.vs_ppu_type
284            && nes.vs_ppu_type() != t
285        {
286            nes.set_vs_ppu_type(t);
287        }
288        if nes.mirroring_override() != self.mirroring_override {
289            nes.set_mirroring_override(self.mirroring_override);
290        }
291        // The live codes iterate in address order, as `capture` records them,
292        // so an unchanged list compares equal without sorting.
293        if !nes
294            .genie_codes()
295            .map(GenieCode::code)
296            .eq(self.genie_codes.iter().map(String::as_str))
297        {
298            nes.clear_genie_codes();
299            for code in &self.genie_codes {
300                nes.add_genie_code(code).map_err(|_| code.clone())?;
301            }
302        }
303        Ok(())
304    }
305
306    /// Put `nes` back on these options after a movie ran on others, without
307    /// disturbing the game in progress.
308    ///
309    /// [`Self::apply_live`] alone would leave the movie's power-on fills
310    /// stored, so the player's next power cycle would boot with the movie's
311    /// RAM pattern. Storing the player's fills means calling their setters,
312    /// which write work RAM and palette RAM, so this takes a snapshot first
313    /// and restores it afterwards: the snapshot carries RAM, the open-bus
314    /// latch and palette RAM but not configuration, so the restore puts the
315    /// running game back exactly while the stored fills stay the player's.
316    /// Skipped when the fills already match, which is the common case.
317    ///
318    /// # Errors
319    ///
320    /// Returns the offending code if a Game Genie string does not decode.
321    pub fn restore_after_playback(&self, nes: &mut Nes) -> Result<(), String> {
322        if nes.power_on_ram() != self.power_on_ram
323            || nes.power_up_palette() != self.power_up_palette
324        {
325            let snap = nes.snapshot();
326            nes.set_power_on_ram(self.power_on_ram);
327            nes.set_power_up_palette(self.power_up_palette);
328            // A snapshot this build just took of this machine always
329            // restores; `restore_quiet` keeps the rewind history, which still
330            // describes the timeline the player is on.
331            let restored = nes.restore_quiet(&snap);
332            debug_assert!(restored.is_ok(), "own snapshot must restore");
333        }
334        self.apply_live(nes)
335    }
336
337    /// Append the canonical encoding to `w`.
338    ///
339    /// Layout (all little-endian): console model, PPU revision, 2A03
340    /// revision, OAM decay, power-on RAM kind + `u64` payload, power-up
341    /// palette, extra scanlines (`u16`), CPU overclock, the sprite-limit flag
342    /// and the MMC3 revision override (`0` = header, `1` = Sharp, `2` = the
343    /// alternate; all three since `.rnm` format 6 and netplay protocol 7,
344    /// v3.1.0), Four Score, Zapper light model, Vs.
345    /// DIP, Vs. PPU type (`0xFF` = the header's), mirroring override (`0` =
346    /// none, else variant + 1), then a code count and each Game Genie code as
347    /// a length byte plus ASCII. Every enum is an explicit byte, never a
348    /// discriminant cast, so reordering a Rust enum cannot silently change
349    /// what an old file means.
350    pub fn write_to(&self, w: &mut BinWriter) {
351        w.u8(match self.console_model {
352            ConsoleModel::Nes => 0,
353            ConsoleModel::Famicom => 1,
354        });
355        w.u8(match self.ppu_revision {
356            PpuRevision::Rp2c02H => 0,
357            PpuRevision::Rp2c02G => 1,
358        });
359        w.u8(match self.cpu_2a03_revision {
360            Cpu2A03Revision::Rp2A03G => 0,
361            Cpu2A03Revision::Rp2A03H => 1,
362        });
363        w.u8(u8::from(self.oam_decay));
364        let (kind, value) = match self.power_on_ram {
365            PowerOnRam::Zeroed => (0u8, 0u64),
366            PowerOnRam::Seeded(seed) => (1, seed),
367            PowerOnRam::Filled(byte) => (2, u64::from(byte)),
368        };
369        w.u8(kind);
370        w.u64(value);
371        w.u8(match self.power_up_palette {
372            PaletteInit::Zeroed => 0,
373            PaletteInit::Blargg => 1,
374        });
375        w.u16(self.extra_scanlines);
376        w.u8(self.cpu_overclock);
377        w.u8(u8::from(self.sprite_limit_disabled));
378        w.u8(match self.mmc3_revision {
379            None => 0,
380            Some(rustynes_mappers::Mmc3Revision::Sharp) => 1,
381            Some(rustynes_mappers::Mmc3Revision::Nec) => 2,
382        });
383        w.u8(u8::from(self.four_score));
384        w.u8(u8::from(self.zapper_temporal_light));
385        w.u8(self.vs_dip);
386        w.u8(self.vs_ppu_type.map_or(0xFF, vs_ppu_to_byte));
387        w.u8(self
388            .mirroring_override
389            .map_or(0, |m| mirroring_to_byte(m) + 1));
390        let count = self.genie_codes.len().min(MAX_GENIE_CODES);
391        w.u8(u8::try_from(count).unwrap_or(u8::MAX));
392        for code in self.genie_codes.iter().take(count) {
393            let bytes = code.as_bytes();
394            w.u8(u8::try_from(bytes.len()).unwrap_or(u8::MAX));
395            w.bytes(&bytes[..bytes.len().min(usize::from(u8::MAX))]);
396        }
397    }
398
399    /// Decode what [`Self::write_to`] wrote. Strict: an unknown enum byte, a
400    /// boolean other than 0 or 1, or a Game Genie code that does not decode is
401    /// an error, never a silent default, because a default here is exactly
402    /// the silent divergence this record exists to prevent.
403    ///
404    /// # Errors
405    ///
406    /// A fixed description of the first malformed field, or `"truncated"`.
407    pub fn read_from(r: &mut BinReader<'_>) -> Result<Self, OptionsDecodeError> {
408        let console_model = match byte(r)? {
409            0 => ConsoleModel::Nes,
410            1 => ConsoleModel::Famicom,
411            _ => return Err("unknown console-model byte"),
412        };
413        let ppu_revision = match byte(r)? {
414            0 => PpuRevision::Rp2c02H,
415            1 => PpuRevision::Rp2c02G,
416            _ => return Err("unknown PPU-revision byte"),
417        };
418        let cpu_2a03_revision = match byte(r)? {
419            0 => Cpu2A03Revision::Rp2A03G,
420            1 => Cpu2A03Revision::Rp2A03H,
421            _ => return Err("unknown 2A03-revision byte"),
422        };
423        let oam_decay = flag(r, "OAM-decay flag is not 0 or 1")?;
424        let kind = byte(r)?;
425        let value = r.u64().map_err(|_| "truncated")?;
426        let power_on_ram = match kind {
427            0 if value == 0 => PowerOnRam::Zeroed,
428            1 => PowerOnRam::Seeded(value),
429            2 => {
430                PowerOnRam::Filled(u8::try_from(value).map_err(|_| "power-on fill is not a byte")?)
431            }
432            _ => return Err("unknown power-on RAM kind"),
433        };
434        let power_up_palette = match byte(r)? {
435            0 => PaletteInit::Zeroed,
436            1 => PaletteInit::Blargg,
437            _ => return Err("unknown power-up palette byte"),
438        };
439        let extra_scanlines = r.u16().map_err(|_| "truncated")?;
440        // NC-11 (v2.9.9): the core clamps the overclock, so a larger value
441        // could only come from an edited file, and would not replay as
442        // written.
443        if extra_scanlines > crate::nes::MAX_EXTRA_SCANLINES {
444            return Err("extra-scanline overclock is above the core's maximum");
445        }
446        // The core clamps the multiplier to 1..=MAX_CPU_OVERCLOCK, so any other
447        // byte could not replay as written.
448        let cpu_overclock = byte(r)?;
449        if cpu_overclock == 0 || cpu_overclock > crate::nes::MAX_CPU_OVERCLOCK {
450            return Err("CPU overclock is outside 1..=MAX_CPU_OVERCLOCK");
451        }
452        let sprite_limit_disabled = flag(r, "sprite-limit flag is not 0 or 1")?;
453        let mmc3_revision = match byte(r)? {
454            0 => None,
455            1 => Some(rustynes_mappers::Mmc3Revision::Sharp),
456            2 => Some(rustynes_mappers::Mmc3Revision::Nec),
457            _ => return Err("unknown MMC3 revision byte"),
458        };
459        let four_score = flag(r, "Four Score flag is not 0 or 1")?;
460        let zapper_temporal_light = flag(r, "Zapper light flag is not 0 or 1")?;
461        let vs_dip = byte(r)?;
462        let vs_ppu_type = match byte(r)? {
463            0xFF => None,
464            b => Some(vs_ppu_from_byte(b).ok_or("unknown Vs. PPU type byte")?),
465        };
466        let mirroring_override = match byte(r)? {
467            0 => None,
468            b => Some(mirroring_from_byte(b - 1).ok_or("unknown mirroring byte")?),
469        };
470        let count = usize::from(byte(r)?);
471        // Canonical form, as `capture` records it and the console holds it:
472        // one code per address, the later one winning, in address order.
473        // A list in any other shape never compared equal to the live one,
474        // so `apply_live` cleared and re-added every code on every frame
475        // (NC-13, v2.9.9).
476        let mut by_addr = alloc::collections::BTreeMap::new();
477        for _ in 0..count {
478            let len = usize::from(byte(r)?);
479            let raw = r.take(len).map_err(|_| "truncated")?;
480            let text = core::str::from_utf8(raw).map_err(|_| "Game Genie code is not text")?;
481            let code = GenieCode::new(text).map_err(|_| "Game Genie code does not decode")?;
482            by_addr.insert(code.addr(), code.code().to_string());
483        }
484        let genie_codes: Vec<String> = by_addr.into_values().collect();
485        Ok(Self {
486            console_model,
487            ppu_revision,
488            cpu_2a03_revision,
489            oam_decay,
490            power_on_ram,
491            power_up_palette,
492            extra_scanlines,
493            cpu_overclock,
494            sprite_limit_disabled,
495            mmc3_revision,
496            four_score,
497            zapper_temporal_light,
498            vs_dip,
499            vs_ppu_type,
500            mirroring_override,
501            genie_codes,
502        })
503    }
504
505    /// The canonical encoding as a byte vector.
506    #[must_use]
507    pub fn to_bytes(&self) -> Vec<u8> {
508        let mut w = BinWriter::with_capacity(32);
509        self.write_to(&mut w);
510        w.into_vec()
511    }
512
513    /// Every option that differs between `self` and `other`, by name, in
514    /// field order. Empty when they agree. For an error message a person can
515    /// act on: "OAM decay, console model" rather than "the options differ".
516    #[must_use]
517    pub fn differences(&self, other: &Self) -> Vec<&'static str> {
518        let mut out = Vec::new();
519        let mut check = |differs: bool, name: &'static str| {
520            if differs {
521                out.push(name);
522            }
523        };
524        check(self.console_model != other.console_model, "console model");
525        check(self.ppu_revision != other.ppu_revision, "PPU revision");
526        check(
527            self.cpu_2a03_revision != other.cpu_2a03_revision,
528            "2A03 revision",
529        );
530        check(self.oam_decay != other.oam_decay, "OAM decay");
531        check(self.power_on_ram != other.power_on_ram, "power-on RAM");
532        check(
533            self.power_up_palette != other.power_up_palette,
534            "power-up palette",
535        );
536        check(
537            self.extra_scanlines != other.extra_scanlines,
538            "overclock scanlines",
539        );
540        check(self.cpu_overclock != other.cpu_overclock, "CPU overclock");
541        check(
542            self.sprite_limit_disabled != other.sprite_limit_disabled,
543            "sprite limit",
544        );
545        check(self.mmc3_revision != other.mmc3_revision, "MMC3 revision");
546        check(self.four_score != other.four_score, "Four Score");
547        check(
548            self.zapper_temporal_light != other.zapper_temporal_light,
549            "Zapper light model",
550        );
551        check(self.vs_dip != other.vs_dip, "Vs. DIP switches");
552        check(self.vs_ppu_type != other.vs_ppu_type, "Vs. PPU type");
553        check(
554            self.mirroring_override != other.mirroring_override,
555            "mirroring override",
556        );
557        check(self.genie_codes != other.genie_codes, "Game Genie codes");
558        out
559    }
560}
561
562/// What the cartridge header told the core to build, after any load-time
563/// correction: the facts that change emulation and that a host cannot apply.
564///
565/// Region is not here: a movie has always recorded it in its fixed header,
566/// and netplay folds it into [`config_digest`] beside this.
567///
568/// `#[non_exhaustive]` since v3.0.0 (T-API-EXTENSIBLE): build one with
569/// [`BoardDescription::capture`]. v2.9.9 added three fields to it, each a
570/// break; a later field no longer is.
571#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
572#[non_exhaustive]
573pub struct BoardDescription {
574    /// iNES / NES 2.0 mapper number.
575    pub mapper_id: u16,
576    /// NES 2.0 submapper (0 for iNES 1.0).
577    pub submapper: u8,
578    /// Header mirroring.
579    pub mirroring: Mirroring,
580    /// Console type (NES / Vs. System / PlayChoice-10 / extended).
581    pub console_type: ConsoleType,
582    /// Whether the header marks a Vs. `DualSystem` board.
583    pub vs_dual_system: bool,
584    /// PRG-RAM bytes (volatile + battery).
585    pub prg_ram_size: u32,
586    /// CHR-RAM bytes.
587    pub chr_ram_size: u32,
588    /// Battery-backed save RAM present.
589    pub has_battery: bool,
590    /// 512-byte trainer present.
591    pub has_trainer: bool,
592    /// PRG-ROM bytes. The ROM identity hashes the body after the header, so
593    /// the same body split differently between PRG and CHR (2 x 16 KiB +
594    /// 4 x 8 KiB against 1 x 16 KiB + 6 x 8 KiB) shared an identity and a
595    /// description until v2.9.9 (core re-audit NC-10).
596    pub prg_rom_len: u32,
597    /// CHR-ROM bytes (0 for CHR-RAM boards); see `prg_rom_len`.
598    pub chr_rom_len: u32,
599    /// The raw header nametable bits mappers 30 and 218 wire from
600    /// ([`rustynes_mappers::Cartridge::nametable_wiring_bits`]); v2.9.9.
601    pub nametable_wiring_bits: u8,
602}
603
604impl BoardDescription {
605    /// The board `nes` was built from.
606    #[must_use]
607    #[allow(clippy::cast_possible_truncation)] // ROM sizes are far below 4 GiB
608    pub fn capture(nes: &Nes) -> Self {
609        let c = nes.cartridge();
610        Self {
611            mapper_id: c.mapper_id,
612            submapper: c.submapper,
613            mirroring: c.mirroring,
614            console_type: c.console_type,
615            vs_dual_system: c.vs_dual_system,
616            prg_ram_size: c.prg_ram_size,
617            chr_ram_size: c.chr_ram_size,
618            has_battery: c.has_battery,
619            has_trainer: c.has_trainer,
620            prg_rom_len: c.prg_rom.len() as u32,
621            chr_rom_len: c.chr_rom.len() as u32,
622            nametable_wiring_bits: c.nametable_wiring_bits,
623        }
624    }
625
626    /// The first field that differs between `self` (recorded) and `other`
627    /// (the host's), or `None` when the boards agree.
628    #[must_use]
629    pub fn first_difference(&self, other: &Self) -> Option<&'static str> {
630        [
631            (self.mapper_id != other.mapper_id, "mapper"),
632            (self.submapper != other.submapper, "submapper"),
633            (self.mirroring != other.mirroring, "mirroring"),
634            (self.console_type != other.console_type, "console type"),
635            (
636                self.vs_dual_system != other.vs_dual_system,
637                "Vs. DualSystem",
638            ),
639            (self.prg_ram_size != other.prg_ram_size, "PRG-RAM size"),
640            (self.chr_ram_size != other.chr_ram_size, "CHR-RAM size"),
641            (self.has_battery != other.has_battery, "battery"),
642            (self.has_trainer != other.has_trainer, "trainer"),
643            (self.prg_rom_len != other.prg_rom_len, "PRG-ROM size"),
644            (self.chr_rom_len != other.chr_rom_len, "CHR-ROM size"),
645            (
646                self.nametable_wiring_bits != other.nametable_wiring_bits,
647                "header nametable wiring",
648            ),
649        ]
650        .into_iter()
651        .find_map(|(differs, name)| differs.then_some(name))
652    }
653
654    /// Append the canonical encoding to `w` (`u16` mapper, submapper,
655    /// mirroring, console type, `DualSystem`, `u32` PRG-RAM, `u32` CHR-RAM,
656    /// battery, trainer, and from v2.9.9 `u32` PRG-ROM, `u32` CHR-ROM and the
657    /// wiring byte).
658    pub fn write_to(&self, w: &mut BinWriter) {
659        w.u16(self.mapper_id);
660        w.u8(self.submapper);
661        w.u8(mirroring_to_byte(self.mirroring));
662        w.u8(match self.console_type {
663            ConsoleType::Nes => 0,
664            ConsoleType::VsSystem => 1,
665            ConsoleType::Playchoice10 => 2,
666            ConsoleType::Extended => 3,
667        });
668        w.u8(u8::from(self.vs_dual_system));
669        w.u32(self.prg_ram_size);
670        w.u32(self.chr_ram_size);
671        w.u8(u8::from(self.has_battery));
672        w.u8(u8::from(self.has_trainer));
673        w.u32(self.prg_rom_len);
674        w.u32(self.chr_rom_len);
675        w.u8(self.nametable_wiring_bits);
676    }
677
678    /// Decode what [`Self::write_to`] wrote. Strict, as
679    /// [`HardwareOptions::read_from`].
680    ///
681    /// # Errors
682    ///
683    /// A fixed description of the first malformed field, or `"truncated"`.
684    pub fn read_from(r: &mut BinReader<'_>) -> Result<Self, OptionsDecodeError> {
685        let mapper_id = r.u16().map_err(|_| "truncated")?;
686        let submapper = byte(r)?;
687        let mirroring = mirroring_from_byte(byte(r)?).ok_or("unknown board mirroring byte")?;
688        let console_type = match byte(r)? {
689            0 => ConsoleType::Nes,
690            1 => ConsoleType::VsSystem,
691            2 => ConsoleType::Playchoice10,
692            3 => ConsoleType::Extended,
693            _ => return Err("unknown console-type byte"),
694        };
695        let vs_dual_system = flag(r, "DualSystem flag is not 0 or 1")?;
696        let prg_ram_size = r.u32().map_err(|_| "truncated")?;
697        let chr_ram_size = r.u32().map_err(|_| "truncated")?;
698        let has_battery = flag(r, "battery flag is not 0 or 1")?;
699        let has_trainer = flag(r, "trainer flag is not 0 or 1")?;
700        let prg_rom_len = r.u32().map_err(|_| "truncated")?;
701        let chr_rom_len = r.u32().map_err(|_| "truncated")?;
702        let nametable_wiring_bits = byte(r)?;
703        if nametable_wiring_bits & !0x09 != 0 {
704            return Err("header nametable wiring byte has bits other than 0 and 3");
705        }
706        Ok(Self {
707            mapper_id,
708            submapper,
709            mirroring,
710            console_type,
711            vs_dual_system,
712            prg_ram_size,
713            chr_ram_size,
714            has_battery,
715            has_trainer,
716            prg_rom_len,
717            chr_rom_len,
718            nametable_wiring_bits,
719        })
720    }
721}
722
723/// SHA-256 over everything two machines must share to run one timeline from
724/// the same ROM: the region, the [`BoardDescription`] and the
725/// [`HardwareOptions`], each in its canonical encoding.
726///
727/// Netplay sends it in the handshake beside the ROM hash, so peers on the
728/// same game with different settings (or differently corrected headers)
729/// refuse with a reason instead of connecting and desyncing at the first
730/// frame the difference reaches. SHA-256 rather than a 64-bit hash because the
731/// value is compared, never searched, and 32 bytes on a once-per-session
732/// message cost nothing.
733#[must_use]
734pub fn config_digest(nes: &Nes) -> [u8; 32] {
735    let mut w = BinWriter::with_capacity(64);
736    w.u8(match nes.region() {
737        Region::Ntsc => 0,
738        Region::Pal => 1,
739        Region::Dendy => 2,
740    });
741    BoardDescription::capture(nes).write_to(&mut w);
742    HardwareOptions::capture(nes).write_to(&mut w);
743    let mut out = [0u8; 32];
744    out.copy_from_slice(&Sha256::digest(w.into_vec()));
745    out
746}
747
748fn byte(r: &mut BinReader<'_>) -> Result<u8, OptionsDecodeError> {
749    r.u8().map_err(|_| "truncated")
750}
751
752fn flag(r: &mut BinReader<'_>, what: OptionsDecodeError) -> Result<bool, OptionsDecodeError> {
753    match byte(r)? {
754        0 => Ok(false),
755        1 => Ok(true),
756        _ => Err(what),
757    }
758}
759
760const fn mirroring_to_byte(m: Mirroring) -> u8 {
761    match m {
762        Mirroring::Horizontal => 0,
763        Mirroring::Vertical => 1,
764        Mirroring::SingleScreenA => 2,
765        Mirroring::SingleScreenB => 3,
766        Mirroring::FourScreen => 4,
767        Mirroring::MapperControlled => 5,
768    }
769}
770
771const fn mirroring_from_byte(b: u8) -> Option<Mirroring> {
772    Some(match b {
773        0 => Mirroring::Horizontal,
774        1 => Mirroring::Vertical,
775        2 => Mirroring::SingleScreenA,
776        3 => Mirroring::SingleScreenB,
777        4 => Mirroring::FourScreen,
778        5 => Mirroring::MapperControlled,
779        _ => return None,
780    })
781}
782
783const fn vs_ppu_to_byte(t: VsPpuType) -> u8 {
784    match t {
785        VsPpuType::None => 0,
786        VsPpuType::Rp2C03 => 1,
787        VsPpuType::Rp2C04_0001 => 2,
788        VsPpuType::Rp2C04_0002 => 3,
789        VsPpuType::Rp2C04_0003 => 4,
790        VsPpuType::Rp2C04_0004 => 5,
791        VsPpuType::Rc2C05_01 => 6,
792        VsPpuType::Rc2C05_02 => 7,
793        VsPpuType::Rc2C05_03 => 8,
794        VsPpuType::Rc2C05_04 => 9,
795    }
796}
797
798const fn vs_ppu_from_byte(b: u8) -> Option<VsPpuType> {
799    Some(match b {
800        0 => VsPpuType::None,
801        1 => VsPpuType::Rp2C03,
802        2 => VsPpuType::Rp2C04_0001,
803        3 => VsPpuType::Rp2C04_0002,
804        4 => VsPpuType::Rp2C04_0003,
805        5 => VsPpuType::Rp2C04_0004,
806        6 => VsPpuType::Rc2C05_01,
807        7 => VsPpuType::Rc2C05_02,
808        8 => VsPpuType::Rc2C05_03,
809        9 => VsPpuType::Rc2C05_04,
810        _ => return None,
811    })
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817
818    /// A minimal NROM image (an infinite `JMP`), as `movie.rs` uses.
819    fn synth_nrom() -> Vec<u8> {
820        let mut bytes = alloc::vec![b'N', b'E', b'S', 0x1A, 1, 1, 0, 0];
821        bytes.extend_from_slice(&[0u8; 8]);
822        let mut prg = alloc::vec![0u8; 16 * 1024];
823        prg[..3].copy_from_slice(&[0x4C, 0x00, 0xC0]);
824        let len = prg.len();
825        prg[len - 6..].copy_from_slice(&[0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0]);
826        bytes.extend_from_slice(&prg);
827        bytes.extend_from_slice(&[0u8; 8 * 1024]);
828        bytes
829    }
830
831    fn non_default() -> HardwareOptions {
832        HardwareOptions {
833            console_model: ConsoleModel::Famicom,
834            ppu_revision: PpuRevision::Rp2c02G,
835            cpu_2a03_revision: Cpu2A03Revision::Rp2A03H,
836            oam_decay: true,
837            power_on_ram: PowerOnRam::Seeded(0x0123_4567_89AB_CDEF),
838            power_up_palette: PaletteInit::Blargg,
839            extra_scanlines: 40,
840            cpu_overclock: 3,
841            sprite_limit_disabled: true,
842            mmc3_revision: Some(rustynes_mappers::Mmc3Revision::Nec),
843            four_score: true,
844            zapper_temporal_light: false,
845            vs_dip: 0xA5,
846            vs_ppu_type: Some(VsPpuType::Rc2C05_03),
847            mirroring_override: Some(Mirroring::SingleScreenB),
848            // Address order, the canonical form `read_from` produces.
849            genie_codes: alloc::vec!["AAEAULPA".to_string(), "SXIOPO".to_string()],
850        }
851    }
852
853    /// NC-13 (v2.9.9 re-audit): a list in file order, or with a duplicate,
854    /// decodes to the console's own form, so `capture` of the machine it
855    /// configures compares equal and `apply_live` stops re-adding the codes.
856    #[test]
857    fn genie_codes_decode_to_the_canonical_order_without_duplicates() {
858        let canonical = non_default();
859        for list in [
860            alloc::vec!["SXIOPO", "AAEAULPA"],
861            alloc::vec!["AAEAULPA", "SXIOPO", "SXIOPO"],
862            alloc::vec!["sxiopo", "AAEAULPA"],
863        ] {
864            let opts = HardwareOptions {
865                genie_codes: list.iter().map(|c| (*c).to_string()).collect(),
866                ..non_default()
867            };
868            let decoded = HardwareOptions::read_from(&mut BinReader::new(&opts.to_bytes()));
869            assert_eq!(decoded, Ok(canonical.clone()), "list {list:?}");
870        }
871    }
872
873    /// NC-11 (v2.9.9 re-audit): an overclock above the core's maximum is
874    /// refused rather than replayed, and the maximum itself decodes.
875    #[test]
876    fn an_overclock_above_the_maximum_is_refused() {
877        for (lines, ok) in [
878            (crate::nes::MAX_EXTRA_SCANLINES, true),
879            (crate::nes::MAX_EXTRA_SCANLINES + 1, false),
880            (u16::MAX, false),
881        ] {
882            let opts = HardwareOptions {
883                extra_scanlines: lines,
884                ..HardwareOptions::default()
885            };
886            let decoded = HardwareOptions::read_from(&mut BinReader::new(&opts.to_bytes()));
887            assert_eq!(decoded.is_ok(), ok, "extra_scanlines {lines}");
888        }
889    }
890
891    #[test]
892    fn options_round_trip_through_their_encoding() {
893        for opts in [HardwareOptions::default(), non_default()] {
894            let bytes = opts.to_bytes();
895            let mut r = BinReader::new(&bytes);
896            assert_eq!(HardwareOptions::read_from(&mut r), Ok(opts));
897            assert_eq!(r.remaining(), 0, "the decoder consumes exactly the record");
898        }
899        let filled = HardwareOptions {
900            power_on_ram: PowerOnRam::Filled(0xFF),
901            ..HardwareOptions::default()
902        };
903        let bytes = filled.to_bytes();
904        assert_eq!(
905            HardwareOptions::read_from(&mut BinReader::new(&bytes)),
906            Ok(filled)
907        );
908    }
909
910    #[test]
911    fn every_truncation_is_an_error_not_a_default() {
912        let bytes = non_default().to_bytes();
913        for len in 0..bytes.len() {
914            assert!(
915                HardwareOptions::read_from(&mut BinReader::new(&bytes[..len])).is_err(),
916                "a record cut at {len} bytes must not decode"
917            );
918        }
919    }
920
921    #[test]
922    fn an_unknown_byte_is_refused() {
923        let mut bytes = HardwareOptions::default().to_bytes();
924        bytes[0] = 7; // console model
925        assert_eq!(
926            HardwareOptions::read_from(&mut BinReader::new(&bytes)),
927            Err("unknown console-model byte")
928        );
929        let mut bytes = HardwareOptions::default().to_bytes();
930        bytes[3] = 2; // OAM decay flag
931        assert!(HardwareOptions::read_from(&mut BinReader::new(&bytes)).is_err());
932    }
933
934    #[test]
935    fn differences_name_each_differing_option() {
936        let a = HardwareOptions::default();
937        assert_eq!(a.differences(&a), [] as [&str; 0]);
938        let b = HardwareOptions {
939            oam_decay: true,
940            console_model: ConsoleModel::Famicom,
941            ..HardwareOptions::default()
942        };
943        assert_eq!(a.differences(&b), ["console model", "OAM decay"]);
944    }
945
946    #[test]
947    fn a_fresh_machine_captures_the_stock_options_and_its_own_vs_type() {
948        let rom = synth_nrom();
949        let nes = Nes::from_rom(&rom).unwrap();
950        let captured = HardwareOptions::capture(&nes);
951        assert_eq!(
952            captured,
953            HardwareOptions {
954                vs_ppu_type: Some(VsPpuType::None),
955                ..HardwareOptions::default()
956            }
957        );
958    }
959
960    #[test]
961    fn board_round_trips_and_names_the_differing_field() {
962        let rom = synth_nrom();
963        let nes = Nes::from_rom(&rom).unwrap();
964        let board = BoardDescription::capture(&nes);
965        let mut w = BinWriter::new();
966        board.write_to(&mut w);
967        let bytes = w.into_vec();
968        assert_eq!(
969            BoardDescription::read_from(&mut BinReader::new(&bytes)),
970            Ok(board)
971        );
972        let other = BoardDescription {
973            submapper: board.submapper + 1,
974            ..board
975        };
976        assert_eq!(board.first_difference(&other), Some("submapper"));
977        assert_eq!(board.first_difference(&board), None);
978    }
979
980    #[test]
981    fn the_config_digest_follows_every_option() {
982        let rom = synth_nrom();
983        let mut nes = Nes::from_rom(&rom).unwrap();
984        let base = config_digest(&nes);
985        assert_eq!(base, config_digest(&Nes::from_rom(&rom).unwrap()));
986        nes.set_oam_decay(true);
987        assert_ne!(config_digest(&nes), base);
988    }
989
990    /// NC-10 (v2.9.9 re-audit): the identity hashes the body after the
991    /// header, so headers that build different machines from one body must
992    /// differ in the board description, which movies and netplay check.
993    #[test]
994    fn headers_that_build_different_machines_differ_in_the_description() {
995        // One 64 KiB body: CNROM read as 2 x 16 KiB PRG + 4 x 8 KiB CHR, and
996        // as 1 x 16 KiB PRG + 6 x 8 KiB CHR.
997        let body: Vec<u8> = (0u32..64 * 1024).map(|i| (i % 251) as u8).collect();
998        let image = |prg16: u8, chr8: u8, flags6: u8, mapper: u8| {
999            let mut v = alloc::vec![
1000                b'N',
1001                b'E',
1002                b'S',
1003                0x1A,
1004                prg16,
1005                chr8,
1006                flags6 | (mapper << 4),
1007                mapper & 0xF0
1008            ];
1009            v.extend_from_slice(&[0u8; 8]);
1010            v.extend_from_slice(&body);
1011            v
1012        };
1013        let a = Nes::from_rom(&image(2, 4, 0, 3)).unwrap();
1014        let b = Nes::from_rom(&image(1, 6, 0, 3)).unwrap();
1015        assert_eq!(a.rom_sha256(), b.rom_sha256(), "one identity");
1016        assert_eq!(
1017            BoardDescription::capture(&a).first_difference(&BoardDescription::capture(&b)),
1018            Some("PRG-ROM size")
1019        );
1020        assert_ne!(config_digest(&a), config_digest(&b));
1021
1022        // Mapper 218: four-screen with bit 0 set or clear wires CIRAM A10 to
1023        // a different PPU line; both read as `FourScreen`.
1024        let c = Nes::from_rom(&image(2, 0, 0x08, 218)).unwrap();
1025        let d = Nes::from_rom(&image(2, 0, 0x09, 218)).unwrap();
1026        assert_eq!(
1027            BoardDescription::capture(&c).first_difference(&BoardDescription::capture(&d)),
1028            Some("header nametable wiring")
1029        );
1030    }
1031
1032    #[test]
1033    fn a_wiring_byte_with_other_bits_is_refused() {
1034        let board = BoardDescription::capture(&Nes::from_rom(&synth_nrom()).unwrap());
1035        let mut w = BinWriter::with_capacity(32);
1036        board.write_to(&mut w);
1037        let mut bytes = w.into_vec();
1038        let last = bytes.len() - 1;
1039        bytes[last] = 0x02;
1040        assert!(BoardDescription::read_from(&mut BinReader::new(&bytes)).is_err());
1041    }
1042}