Skip to main content

rustysnes_core/
facade.rs

1//! The pure emulation-core facade: load a ROM, step a frame, expose the framebuffer + audio.
2//!
3//! `std`-only (`#[cfg(feature = "std")]`) since it needs heap collections beyond `alloc` alone
4//! (`zip` archive extraction) and is meant for a real host process — every consumer that embeds
5//! this crate as a library (the native/wasm frontend, `rustysnes-libretro`, any future host) drives
6//! the emulator through [`EmuCore`] rather than reaching into [`crate::scheduler::System`]
7//! directly. Debugger-only concerns (breakpoints, single-step, register/VRAM/CGRAM/OAM snapshots)
8//! deliberately stay OUTSIDE this facade — those are frontend-owned (built on
9//! [`EmuCore::system_mut`]'s raw access), not something every consumer needs.
10//!
11//! `v1.2.0`: relocated here from `rustysnes-frontend::emu` (that crate's dependency weight —
12//! winit/wgpu/cpal/egui — is wrong for a libretro core or any other headless embedding). The
13//! frontend keeps a thin wrapper (`rustysnes-frontend::emu::EmuCore`) around this type that adds
14//! the debugger fields; zero behavior change for existing frontend call sites.
15
16use alloc::borrow::Cow;
17use alloc::format;
18use alloc::string::String;
19use alloc::vec;
20use alloc::vec::Vec;
21
22use rustysnes_cart::{Cart, Coprocessor, Region};
23
24use crate::scheduler::System;
25
26/// SNES native width (constant across the NTSC/PAL active-region heights and the lo-res mode).
27pub const SNES_W: u32 = 256;
28/// SNES NTSC active-region height (224 visible scanlines).
29pub const SNES_H_NTSC: u32 = 224;
30/// SNES PAL active-region height (239 visible scanlines).
31pub const SNES_H_PAL: u32 = 239;
32/// SNES hi-res / pseudo-hi-res width (mode 5/6 + interlace double the base dims).
33pub const SNES_W_HIRES: u32 = 512;
34/// SNES hi-res / interlace height (448 = 224 active * 2 fields).
35pub const SNES_H_HIRES: u32 = 448;
36
37/// The maximum framebuffer size a consumer's texture/surface needs to be sized for (hi-res worst
38/// case), so a video-mode change never needs a realloc. Sub-modes occupy the top-left sub-rect.
39pub const MAX_W: u32 = SNES_W_HIRES;
40/// The maximum framebuffer height (see [`MAX_W`]).
41pub const MAX_H: u32 = SNES_H_HIRES;
42
43/// The active-region framebuffer height for a region (256 wide always).
44#[must_use]
45pub const fn active_height(region: Region) -> u32 {
46    match region {
47        Region::Ntsc => SNES_H_NTSC,
48        Region::Pal => SNES_H_PAL,
49    }
50}
51
52/// Expand a 15-bit SNES **BGR555** color word (`0bbbbbgggggrrrrr`) to a packed little-endian
53/// RGBA8 (`0xAABBGGRR`) value suitable for an RGBA8 framebuffer / texture upload.
54///
55/// The 5-bit channels are left-justified to 8 bits (`c << 3 | c >> 2`), matching how Mesen2 /
56/// bsnes expand CGRAM. Alpha is forced opaque.
57#[must_use]
58pub const fn bgr555_to_rgba8(bgr555: u16) -> u32 {
59    let r5 = (bgr555 & 0x1F) as u32;
60    let g5 = ((bgr555 >> 5) & 0x1F) as u32;
61    let b5 = ((bgr555 >> 10) & 0x1F) as u32;
62    let r8 = (r5 << 3) | (r5 >> 2);
63    let g8 = (g5 << 3) | (g5 >> 2);
64    let b8 = (b5 << 3) | (b5 >> 2);
65    // Pack as 0xAABBGGRR (little-endian RGBA8 byte order: R,G,B,A).
66    0xFF00_0000 | (b8 << 16) | (g8 << 8) | r8
67}
68
69/// Coprocessor firmware dumps a consumer should try, in order, for a cart that carries a
70/// chip-ROM-dump coprocessor. The matching dump (when supplied) is the only one
71/// [`Cart::install_coprocessor_firmware`] accepts; the rest are rejected and left unchanged.
72const fn firmware_candidates(co: Coprocessor) -> &'static [&'static str] {
73    match co {
74        // The µPD77C25 DSP-1..4 family — the right dump depends on the game; try the common ones.
75        Coprocessor::Dsp => &["dsp1.rom", "dsp1b.rom", "dsp2.rom", "dsp3.rom", "dsp4.rom"],
76        Coprocessor::Cx4 => &["cx4.rom"],
77        // Logic-only / on-die coprocessors (Super FX, SA-1, S-DD1, SPC7110, OBC1) need no external
78        // firmware dump in this core.
79        _ => &[],
80    }
81}
82
83/// The pure emulation-core facade: load a ROM, step a frame, expose the framebuffer + audio.
84///
85/// For the present path. Lives behind an `Arc<Mutex<…>>` when a consumer drives it from a
86/// dedicated thread; stepped synchronously otherwise.
87pub struct EmuCore {
88    /// The master-clock scheduler + Bus (owns every chip).
89    system: System,
90    /// The current console region (drives the active framebuffer height + pacing).
91    region: Region,
92    /// The RGBA8 framebuffer (sized to the hi-res worst case; the active sub-rect is `fb_dims`
93    /// large). Copied out under one brief lock by the present path.
94    framebuffer: Vec<u8>,
95    /// The active framebuffer dims `(w, h)` for the current video mode.
96    fb_dims: (u32, u32),
97    /// The 32 kHz stereo samples the S-DSP emitted during the most recent [`Self::run_frame`].
98    audio: Vec<(i16, i16)>,
99    /// The latest latched controller state for P1 / P2 (late-latched by the host).
100    pads: [u16; 2],
101    /// Whether a ROM is currently loaded (the present path shows a blank frame otherwise).
102    rom_loaded: bool,
103    /// The raw ROM image, retained so Power-Cycle can rebuild a clean machine deterministically.
104    rom: Vec<u8>,
105    /// The coprocessor firmware dump installed for this cart (if any), retained for Power-Cycle.
106    firmware: Vec<u8>,
107}
108
109impl EmuCore {
110    /// Power on with a determinism seed and a region. No ROM is loaded yet.
111    #[must_use]
112    pub fn new(seed: u64, region: Region) -> Self {
113        Self {
114            system: System::new(seed),
115            region,
116            framebuffer: vec![0u8; (MAX_W * MAX_H * 4) as usize],
117            fb_dims: (SNES_W, active_height(region)),
118            audio: Vec::new(),
119            pads: [0, 0],
120            rom_loaded: false,
121            rom: Vec::new(),
122            firmware: Vec::new(),
123        }
124    }
125
126    /// Load a raw ROM image, transparently unwrapping a zip archive first if `rom` is one (the
127    /// common case of a `.sfc`/`.smc` distributed zipped). Header detection + board selection
128    /// happen in `rustysnes-cart`. On success the cart is installed
129    /// in a fresh Bus and the system is left ready to boot from the cart's reset vector on the
130    /// first [`Self::run_frame`].
131    ///
132    /// # Errors
133    /// Returns an [`EmuError`] if the image is empty, is a zip archive that can't be opened or
134    /// contains no recognizable ROM entry, or no valid SNES header is found.
135    pub fn load_rom(&mut self, rom: &[u8]) -> Result<(), EmuError> {
136        if rom.is_empty() {
137            return Err(EmuError::Empty);
138        }
139        let rom = extract_rom_bytes(rom)?;
140        if rom.is_empty() {
141            return Err(EmuError::Empty);
142        }
143        let cart = Cart::from_rom(&rom).map_err(|e| EmuError::Header(format!("{e:?}")))?;
144        // Preserve the determinism seed this `EmuCore` was constructed with — `System::seed`'s
145        // own doc: a different seed gives different power-on phase alignment, breaking
146        // bit-identical determinism (found by PR #62 review; a `System::new(0)` here would have
147        // silently discarded the caller's seed on every ROM load).
148        let mut system = System::new(self.system.seed());
149        system.bus.cart = Some(cart);
150        self.system = system;
151        self.rom = rom.into_owned();
152        self.firmware.clear();
153        self.rom_loaded = true;
154        self.audio.clear();
155        Ok(())
156    }
157
158    /// The coprocessor firmware dumps this cart will accept (filenames), in try order. Empty when
159    /// the cart needs no external firmware. A host uses this to locate + install the dump.
160    #[must_use]
161    pub fn firmware_candidates(&self) -> &'static [&'static str] {
162        self.system
163            .bus
164            .cart
165            .as_ref()
166            .map_or(&[], |c| firmware_candidates(c.header.coprocessor))
167    }
168
169    /// Whether the loaded cart carries a coprocessor that needs a (not-yet-installed) firmware dump
170    /// to function. The honesty posture of `docs/adr/0003`: without the dump the coprocessor is
171    /// non-functional, so a host should prompt for it.
172    #[must_use]
173    pub fn needs_firmware(&self) -> bool {
174        !self.firmware_candidates().is_empty() && self.firmware.is_empty()
175    }
176
177    /// Supply a coprocessor firmware dump. Returns `true` if the cart's board accepted it (right
178    /// coprocessor + size); on success the bytes are retained for Power-Cycle.
179    pub fn install_firmware(&mut self, bytes: &[u8]) -> bool {
180        let accepted = self
181            .system
182            .bus
183            .cart
184            .as_mut()
185            .is_some_and(|c| c.install_coprocessor_firmware(bytes));
186        if accepted {
187            self.firmware = bytes.to_vec();
188        }
189        accepted
190    }
191
192    /// Restore battery SRAM from a `.srm` image (truncated/zero-padded to the board's SRAM size).
193    pub fn load_sram(&mut self, data: &[u8]) {
194        if let Some(c) = self.system.bus.cart.as_mut() {
195            c.load_sram(data);
196        }
197    }
198
199    /// The current battery SRAM contents (empty when the cart has no SRAM), for a `.srm` save.
200    #[must_use]
201    pub fn save_sram(&self) -> &[u8] {
202        self.system.bus.cart.as_ref().map_or(&[], Cart::save_sram)
203    }
204
205    /// Soft reset: re-run the cart's reset vector without clearing RAM (the SNES front-panel
206    /// Reset button). A no-op when no ROM is loaded.
207    pub fn reset(&mut self) {
208        if self.rom_loaded {
209            self.system.reset();
210            self.audio.clear();
211        }
212    }
213
214    /// Power-cycle (hard reset): rebuild a clean machine from the retained ROM + firmware. Battery
215    /// SRAM is NOT preserved here (the caller reloads `.srm` if desired).
216    pub fn power_cycle(&mut self) {
217        if !self.rom_loaded {
218            return;
219        }
220        if let Ok(cart) = Cart::from_rom(&self.rom) {
221            // Preserve the determinism seed (see `Self::load_rom`'s matching fix/comment).
222            let mut system = System::new(self.system.seed());
223            system.bus.cart = Some(cart);
224            if let Some(c) = system
225                .bus
226                .cart
227                .as_mut()
228                .filter(|_| !self.firmware.is_empty())
229            {
230                let _ = c.install_coprocessor_firmware(&self.firmware);
231            }
232            self.system = system;
233            self.audio.clear();
234        }
235    }
236
237    /// Close the loaded ROM and present a clean blank frame.
238    pub fn close_rom(&mut self) {
239        // Preserve the determinism seed (see `Self::load_rom`'s matching fix/comment).
240        self.system = System::new(self.system.seed());
241        self.rom.clear();
242        self.firmware.clear();
243        self.rom_loaded = false;
244        self.audio.clear();
245        self.framebuffer.iter_mut().for_each(|b| *b = 0);
246        self.fb_dims = (SNES_W, active_height(self.region));
247    }
248
249    /// Whether a ROM is loaded.
250    #[must_use]
251    pub const fn rom_loaded(&self) -> bool {
252        self.rom_loaded
253    }
254
255    /// The loaded cartridge's board name (for a status display), if any.
256    #[must_use]
257    pub fn cart_name(&self) -> Option<&'static str> {
258        self.system.bus.cart.as_ref().map(|c| c.board.name())
259    }
260
261    /// The raw ROM byte image currently loaded (empty if none) — for TAS movie recording's
262    /// ROM-identity hash ([`crate::movie::hash_rom`]/[`crate::movie::Movie::verify_rom`]), which
263    /// needs the exact bytes rather than `Cart`'s parsed/header-stripped internal representation.
264    #[must_use]
265    pub fn rom(&self) -> &[u8] {
266        &self.rom
267    }
268
269    /// Direct mutable access to the deterministic core, for TAS movie record/playback
270    /// ([`crate::movie`]), Lua scripting, or a debugger overlay — all need genuine read/write
271    /// reach into the running [`System`]/`Bus`.
272    // Deliberately not `const fn` (PR #62 review): trivially const today, but fragile on a
273    // struct this size — a future field that isn't const-constructible would silently force
274    // this to change, and clippy's `missing_const_for_fn` would just re-flag it if it regressed.
275    #[allow(clippy::missing_const_for_fn)]
276    pub fn system_mut(&mut self) -> &mut System {
277        &mut self.system
278    }
279
280    /// Latch the controller state for a player (`0` = P1, `1` = P2), applied to the Bus at the
281    /// top of the next [`Self::run_frame`]/[`Self::apply_pads`]. Taken as a raw button-state
282    /// word, same as [`crate::bus::Bus::set_joypad`] — a host is responsible for any input
283    /// hygiene it wants (e.g. the frontend's `Buttons::sanitize_dpad` clearing illegal opposite
284    /// D-pad directions before calling this), matching the honest "this facade stores what it's
285    /// told" posture the rest of its setters already have.
286    pub fn set_pad(&mut self, player: usize, buttons: u16) {
287        if let Some(slot) = self.pads.get_mut(player) {
288            *slot = buttons;
289        }
290    }
291
292    /// Push the latched pad state to the Bus without running a frame — split out of
293    /// [`Self::run_frame`] so a host that needs to interleave conditional stepping (a debugger's
294    /// pause/breakpoint gate) can latch input once and then choose how to advance the `System`.
295    pub fn apply_pads(&mut self) {
296        self.system.bus.set_joypad(0, self.pads[0]);
297        self.system.bus.set_joypad(1, self.pads[1]);
298    }
299
300    /// Select which peripheral is attached to controller port `port` — see
301    /// [`crate::bus::Bus::set_port_device`].
302    pub fn set_port_device(&mut self, port: usize, device: crate::controller::PortDevice) {
303        self.system.bus.set_port_device(port, device);
304    }
305
306    /// Feed one frame's worth of SNES Mouse input for port `port` — see
307    /// [`crate::bus::Bus::set_mouse`].
308    pub fn set_mouse(&mut self, port: usize, dx: i32, dy: i32, left: bool, right: bool) {
309        self.system.bus.set_mouse(port, dx, dy, left, right);
310    }
311
312    /// Set the 8 per-voice audio mute toggles — see [`crate::bus::Bus::set_voice_mutes`]'s doc.
313    // Deliberately not `const fn` (PR #62 review) — same rationale as `Self::system_mut` above.
314    #[allow(clippy::missing_const_for_fn)]
315    pub fn set_voice_mutes(&mut self, mutes: [bool; 8]) {
316        self.system.bus.set_voice_mutes(mutes);
317    }
318
319    /// Feed one frame's worth of Super Scope input for port `port` — see
320    /// [`crate::bus::Bus::set_superscope`].
321    pub fn set_superscope(&mut self, port: usize, x: i32, y: i32, buttons: u8) {
322        self.system.bus.set_superscope(port, x, y, buttons);
323    }
324
325    /// Feed one frame's worth of input for Super Multitap sub-pad `sub_index` of port `port` —
326    /// see [`crate::bus::Bus::set_multitap_pad`].
327    pub fn set_multitap_pad(&mut self, port: usize, sub_index: usize, buttons: u16) {
328        self.system.bus.set_multitap_pad(port, sub_index, buttons);
329    }
330
331    /// Advance one full video frame unconditionally: latch pads, run the scheduler to the next
332    /// frame boundary, then decode the PPU framebuffer + drain the S-DSP audio. A host that needs
333    /// conditional stepping (pause/breakpoints) should call [`Self::apply_pads`] +
334    /// [`Self::system_mut`] + [`Self::present_current_frame`] directly instead.
335    pub fn run_frame(&mut self) {
336        self.apply_pads();
337        self.system.run_frame();
338        self.present_current_frame();
339    }
340
341    /// Decode the PPU framebuffer + drain the S-DSP audio from the `System`'s CURRENT state,
342    /// without advancing it — the second half of [`Self::run_frame`], split out for a host that
343    /// drives `System::run_frame` (or `step_instruction`) directly (netplay rollback, a debugger's
344    /// single-step) and needs to pick up the result afterward.
345    pub fn present_current_frame(&mut self) {
346        self.audio.clear();
347        if self.rom_loaded {
348            self.system.bus.apu.drain_audio(&mut self.audio);
349            self.render_framebuffer();
350        }
351    }
352
353    /// Decode the PPU's (256|512)×(224|239) BGR555 framebuffer into the RGBA8 present buffer.
354    /// Width tracks `Ppu::visible_width` — 512-wide for a hi-res (Modes 5/6) frame, 256-wide
355    /// otherwise (`docs/ppu.md` §Hi-res (Modes 5/6) color-math precision).
356    fn render_framebuffer(&mut self) {
357        let h = u32::from(self.system.bus.ppu.visible_height()).min(SNES_H_PAL);
358        // `visible_width()` is always SCREEN_WIDTH (256) or MAX_SCREEN_WIDTH (512) — never near
359        // u32::MAX, so this narrowing cast can't actually truncate.
360        #[allow(clippy::cast_possible_truncation)]
361        let w = self.system.bus.ppu.visible_width() as u32;
362        self.fb_dims = (w, h);
363        let count = (w * h) as usize;
364        let src = self.system.bus.framebuffer();
365        for (i, &px) in src.iter().take(count).enumerate() {
366            let bytes = bgr555_to_rgba8(px).to_le_bytes();
367            let o = i * 4;
368            self.framebuffer[o..o + 4].copy_from_slice(&bytes);
369        }
370    }
371
372    /// The current RGBA8 framebuffer slice (the active mode's `w*h*4` bytes), for the present path.
373    #[must_use]
374    pub fn framebuffer(&self) -> &[u8] {
375        let (w, h) = self.fb_dims;
376        let len = (w * h * 4) as usize;
377        &self.framebuffer[..len.min(self.framebuffer.len())]
378    }
379
380    /// The audio samples (32 kHz stereo) produced during the most recent [`Self::run_frame`].
381    #[must_use]
382    pub fn audio(&self) -> &[(i16, i16)] {
383        &self.audio
384    }
385
386    /// The active framebuffer dimensions `(w, h)`.
387    #[must_use]
388    pub const fn fb_dims(&self) -> (u32, u32) {
389        self.fb_dims
390    }
391
392    /// The active region.
393    #[must_use]
394    pub const fn region(&self) -> Region {
395        self.region
396    }
397
398    /// Snapshot the full deterministic core state ([`System::save_state`], `docs/adr/0006`) for
399    /// rewind/run-ahead/quick-save. Host-only state (the decoded RGBA8 framebuffer, the retained
400    /// ROM/firmware bytes for Power-Cycle, latched pads) is NOT part of this — it's outside the
401    /// deterministic core and is re-derived after [`Self::load_state`].
402    #[must_use]
403    pub fn save_state(&self) -> Vec<u8> {
404        self.system.save_state()
405    }
406
407    /// Restore a snapshot taken by [`Self::save_state`] from a `System` with the SAME cart
408    /// already loaded (a save-state never embeds ROM bytes, `docs/adr/0006`) — the caller must
409    /// have already `load_rom`'d the matching ROM. Re-renders the framebuffer immediately so a
410    /// host reflects the restored frame without waiting for the next [`Self::run_frame`], and
411    /// clears the audio FIFO (a state load jumps time discontinuously; there is no continuous
412    /// audio stream to drain across that jump).
413    ///
414    /// # Errors
415    /// Propagates [`rustysnes_savestate::SaveStateError`] if `bytes` is truncated/corrupt, from
416    /// an incompatible format version, or doesn't match this `System`'s currently-loaded cart
417    /// (SRAM size, coprocessor presence) — the state is left unchanged on error.
418    pub fn load_state(&mut self, bytes: &[u8]) -> Result<(), rustysnes_savestate::SaveStateError> {
419        self.system.load_state(bytes)?;
420        self.audio.clear();
421        if self.rom_loaded {
422            self.render_framebuffer();
423        }
424        Ok(())
425    }
426}
427
428/// ROM-load / emulation errors surfaced to a host.
429#[derive(Debug, thiserror::Error)]
430pub enum EmuError {
431    /// The ROM image was empty.
432    #[error("empty ROM image")]
433    Empty,
434    /// No valid SNES header was found (LoROM/HiROM/ExHiROM detection failed).
435    #[error("invalid SNES ROM header: {0}")]
436    Header(String),
437    /// The image looked like a zip archive but couldn't be opened, or contained no recognizable
438    /// SNES ROM entry.
439    #[error("zip archive: {0}")]
440    Archive(String),
441}
442
443/// SNES ROM file extensions recognized inside a zip archive, checked case-insensitively
444/// (`.sfc`/`.smc` are by far the most common; `.fig`/`.swc` are older copier-header dumps this
445/// project's header detection already strips — see `docs/cartridge-format.md`).
446const ROM_EXTENSIONS: [&str; 4] = ["sfc", "smc", "fig", "swc"];
447
448/// Hard cap on a zip entry's decompressed size, enforced while reading (not just checked against
449/// the (attacker-controlled, spoofable) declared size up front). The largest official SNES ROM is
450/// 6 MiB and the largest known fan hack is ~12 MiB; 32 MiB leaves generous headroom while still
451/// bounding a "zip bomb" (a small archive that claims/produces a huge decompressed stream) to a
452/// sane memory ceiling instead of unbounded growth.
453const MAX_DECOMPRESSED_ROM_SIZE: u64 = 32 * 1024 * 1024;
454
455/// If `bytes` is a zip archive (sniffed by the local-file-header magic `PK\x03\x04`, or the
456/// empty-archive end-of-central-directory magic `PK\x05\x06`), extract the first non-directory
457/// entry whose extension is in [`ROM_EXTENSIONS`] and return its decompressed bytes. Otherwise
458/// returns `bytes` unchanged — a plain `.sfc`/`.smc` file passes straight through. Pure in-memory
459/// (a `Cursor` over the slice already in hand), so this is identical on native and wasm32.
460fn extract_rom_bytes(bytes: &[u8]) -> Result<Cow<'_, [u8]>, EmuError> {
461    let is_zip = bytes.len() >= 4 && (bytes[..4] == *b"PK\x03\x04" || bytes[..4] == *b"PK\x05\x06");
462    if !is_zip {
463        return Ok(Cow::Borrowed(bytes));
464    }
465    let cursor = std::io::Cursor::new(bytes);
466    let mut archive =
467        zip::ZipArchive::new(cursor).map_err(|e| EmuError::Archive(format!("{e}")))?;
468    let rom_index = (0..archive.len())
469        .find(|&i| {
470            archive.name_for_index(i).is_some_and(|name| {
471                // Directory entries conventionally end with `/` (zip spec) — a directory named
472                // e.g. `Game.sfc/` must not match, or extraction below fails on an empty read.
473                !name.ends_with('/')
474                    && std::path::Path::new(name)
475                        .extension()
476                        .and_then(|ext| ext.to_str())
477                        .is_some_and(|ext| {
478                            ROM_EXTENSIONS.iter().any(|r| ext.eq_ignore_ascii_case(r))
479                        })
480            })
481        })
482        .ok_or_else(|| {
483            EmuError::Archive(format!(
484                "no .sfc/.smc/.fig/.swc entry found (tried {} entries)",
485                archive.len()
486            ))
487        })?;
488    let mut entry = archive
489        .by_index(rom_index)
490        .map_err(|e| EmuError::Archive(format!("{e}")))?;
491    // `read_to_end` grows the buffer as needed; no need to pre-size from `entry.size()` (a
492    // `u64` that would need a lossy cast on 32-bit targets for a capacity hint only) — and that
493    // declared size is attacker-controlled anyway, which is exactly what `take` below guards
494    // against: capping the ACTUAL bytes read, not trusting the header's claim.
495    let mut limited = std::io::Read::take(&mut entry, MAX_DECOMPRESSED_ROM_SIZE + 1);
496    let mut out = Vec::new();
497    std::io::Read::read_to_end(&mut limited, &mut out)
498        .map_err(|e| EmuError::Archive(format!("{e}")))?;
499    if out.len() as u64 > MAX_DECOMPRESSED_ROM_SIZE {
500        return Err(EmuError::Archive(format!(
501            "entry exceeds the {MAX_DECOMPRESSED_ROM_SIZE}-byte decompressed-size limit \
502             (zip bomb protection)"
503        )));
504    }
505    Ok(Cow::Owned(out))
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn blank_core_presents_cleared_frame_of_region_size() {
514        let core = EmuCore::new(0, Region::Ntsc);
515        let (w, h) = core.fb_dims();
516        assert_eq!((w, h), (256, 224));
517        assert_eq!(core.framebuffer().len(), (256 * 224 * 4) as usize);
518        assert!(core.framebuffer().iter().all(|&b| b == 0));
519        assert!(!core.rom_loaded());
520        assert!(core.audio().is_empty());
521    }
522
523    #[test]
524    fn empty_rom_rejected() {
525        let mut core = EmuCore::new(0, Region::Ntsc);
526        assert!(matches!(core.load_rom(&[]), Err(EmuError::Empty)));
527    }
528
529    #[test]
530    fn run_frame_does_not_panic_without_rom() {
531        let mut core = EmuCore::new(0, Region::Ntsc);
532        core.run_frame();
533    }
534
535    #[test]
536    fn bgr555_decode_matches_known_values() {
537        assert_eq!(bgr555_to_rgba8(0x0000), 0xFF00_0000); // opaque black
538        assert_eq!(bgr555_to_rgba8(0x7FFF), 0xFFFF_FFFF); // opaque white
539        assert_eq!(bgr555_to_rgba8(0x001F), 0xFF00_00FF); // pure red
540    }
541
542    fn zip_containing(name: &str, bytes: &[u8]) -> Vec<u8> {
543        let mut buf = std::io::Cursor::new(Vec::new());
544        let mut writer = zip::ZipWriter::new(&mut buf);
545        writer
546            .start_file(name, zip::write::SimpleFileOptions::default())
547            .unwrap();
548        std::io::Write::write_all(&mut writer, bytes).unwrap();
549        writer.finish().unwrap();
550        buf.into_inner()
551    }
552
553    #[test]
554    fn non_zip_bytes_pass_through_unchanged() {
555        let rom = b"not a zip, just a plain ROM image";
556        let out = extract_rom_bytes(rom).unwrap();
557        assert_eq!(&*out, rom);
558    }
559
560    #[test]
561    fn zip_wrapped_rom_is_transparently_extracted() {
562        let rom = vec![0xAB_u8; 512];
563        let zipped = zip_containing("Game.sfc", &rom);
564        let out = extract_rom_bytes(&zipped).unwrap();
565        assert_eq!(&*out, rom.as_slice());
566    }
567
568    #[test]
569    fn zip_with_no_rom_entry_errors() {
570        let zipped = zip_containing("readme.txt", b"not a ROM");
571        assert!(matches!(
572            extract_rom_bytes(&zipped),
573            Err(EmuError::Archive(_))
574        ));
575    }
576
577    #[test]
578    fn zip_directory_entry_named_like_a_rom_is_not_matched() {
579        // A directory entry conventionally ends with `/` in the zip central directory; a folder
580        // literally named "Game.sfc" must not be picked over (or instead of) a real ROM entry.
581        let mut buf = std::io::Cursor::new(Vec::new());
582        let mut writer = zip::ZipWriter::new(&mut buf);
583        writer
584            .add_directory("Game.sfc/", zip::write::SimpleFileOptions::default())
585            .unwrap();
586        let rom = vec![0xCD_u8; 128];
587        writer
588            .start_file("Real Game.sfc", zip::write::SimpleFileOptions::default())
589            .unwrap();
590        std::io::Write::write_all(&mut writer, &rom).unwrap();
591        writer.finish().unwrap();
592        let zipped = buf.into_inner();
593        let out = extract_rom_bytes(&zipped).unwrap();
594        assert_eq!(&*out, rom.as_slice());
595    }
596
597    #[test]
598    fn oversized_zip_entry_is_rejected_not_read_unbounded() {
599        let huge = vec![0u8; usize::try_from(MAX_DECOMPRESSED_ROM_SIZE + 1).unwrap()];
600        let zipped = zip_containing("Big.sfc", &huge);
601        assert!(matches!(
602            extract_rom_bytes(&zipped),
603            Err(EmuError::Archive(_))
604        ));
605    }
606
607    #[test]
608    fn zip_wrapped_rom_loads_end_to_end() {
609        let zipped = zip_containing("test.sfc", &minimal_lorom());
610        let mut core = EmuCore::new(0, Region::Ntsc);
611        core.load_rom(&zipped).expect("zip-wrapped ROM should load");
612        assert!(core.rom_loaded());
613    }
614
615    /// A minimal-but-valid, all-zero-body LoROM image with just enough of a header at $7FC0 for
616    /// `Cart::from_rom` to accept it (mirrors `rustysnes-cart::header`'s permissive scoring —
617    /// only the size/map-mode bytes need to line up).
618    fn minimal_lorom() -> Vec<u8> {
619        let mut rom = vec![0u8; 0x8000];
620        rom[0x7FC0..0x7FC0 + 21].copy_from_slice(b"TEST ROM             ");
621        rom[0x7FD5] = 0x20; // LoROM
622        rom[0x7FD6] = 0x00; // no coprocessor
623        rom[0x7FD7] = 0x08; // ROM size (2^8 KiB = 256 KiB, permissive)
624        rom
625    }
626
627    #[test]
628    fn set_pad_and_apply_pads_reach_the_bus() {
629        let mut core = minimal_lorom_core();
630        core.set_pad(0, 0x8000); // Button::B in the standard SNES bit layout
631        core.apply_pads();
632        assert_eq!(core.system_mut().bus.joypad(0), 0x8000);
633    }
634
635    fn minimal_lorom_core() -> EmuCore {
636        let mut core = EmuCore::new(0, Region::Ntsc);
637        core.load_rom(&minimal_lorom())
638            .expect("minimal LoROM should load");
639        core
640    }
641
642    #[test]
643    fn load_rom_preserves_the_constructor_seed() {
644        let mut core = EmuCore::new(42, Region::Ntsc);
645        core.load_rom(&minimal_lorom())
646            .expect("minimal LoROM should load");
647        assert_eq!(
648            core.system_mut().seed(),
649            42,
650            "load_rom rebuilds System and must carry the original seed forward, \
651             not silently reset it to 0"
652        );
653    }
654
655    #[test]
656    fn power_cycle_preserves_the_constructor_seed() {
657        let mut core = EmuCore::new(7, Region::Ntsc);
658        core.load_rom(&minimal_lorom())
659            .expect("minimal LoROM should load");
660        core.power_cycle();
661        assert_eq!(core.system_mut().seed(), 7);
662    }
663
664    #[test]
665    fn close_rom_preserves_the_constructor_seed() {
666        let mut core = EmuCore::new(99, Region::Ntsc);
667        core.load_rom(&minimal_lorom())
668            .expect("minimal LoROM should load");
669        core.close_rom();
670        assert_eq!(core.system_mut().seed(), 99);
671    }
672}