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}