jevsnes.git / packages / console / README.md

Chapter 2: console, a Super Nintendo you can keep in a variable

Most projects that let a program play a game run an emulator as a separate process and poke at it from outside: a Lua script inside it, a socket, a screenshot to read pixels off. This one does the opposite. The emulator core is a library, and Console is a value:

let mut snes = Console::boot(rom_bytes, Saves::default())?;
snes.set_button(SnesButton::Start, true);
snes.run_frame()?;
snes.frame      // the picture the PPU just finished: size and RGBA pixels
snes.samples    // stereo audio produced during that frame
snes.saves      // cartridge SRAM, keyed by extension, held in memory
snes.wram()     // all 128 KiB of work RAM; wram()[0x10] is $7E0010
snes.wram_mut() // the same, writable, between frames

let machine = snes.snapshot()?;   // the whole machine, between frames
snes.restore(&machine)?;          // and back; refused for another core or cartridge
snes.power_cycle();               // off and on again, battery save kept

Everything else in this repository is built on those few lines. The game state, the bot, Jev's questions and the on-screen controller all come from the same machine the picture comes from, in the same process, with no copying through a socket.

Aside: a frame, not a step. A real SNES runs its CPU, its picture unit and its sound chip on their own clocks, and the core emulates all three cycle by cycle. run_frame keeps ticking until the core says a frame was rendered, so one call is one sixtieth of a second of game, never one CPU instruction. NTSC is actually 60.0988 frames a second, which is why target_fps() exists: a host paces on it, never on its monitor.

The core underneath, jgenesis' snes-core, is written to be driven by a frontend: every tick it is handed somewhere to draw, somewhere to put sound, somewhere to keep saves and something to ask about the controller. This crate supplies all four as plain in-memory values (Frame, Samples, Saves, the pad), which is what lets it run the same natively and, one day, in a browser. Where the picture goes, where the sound plays and where a save is kept are the app's decisions, made after the frame.

A snapshot is the machine, not something like it. It starts with a header naming the core's save-state version and the CRC-32 of the cartridge image, so a snapshot from another build of the core or another ROM is refused rather than half-loaded. There are two encodings: snapshot (compact varint integers, the format of every state kept on disk) and snapshot_fixed (every integer at full width, so the same field sits at the same offset every time, which is what lets chapter 6 store keyframes as small deltas). The two do not read each other.

Try it. apps/headless (chapter 10) boots your cartridge, runs it, then takes a snapshot, runs 300 frames, goes back, runs the same 300 again, and asserts the two futures are the same picture and the same work RAM:

nix develop -c buck2 run //apps/headless:headless -- \
  "roms/Legend of Zelda, The - A Link to the Past (USA).sfc" 1500 run/out.ppm

Look for snapshot … bytes, identical after 300 frames.

For the people who maintain it

In this folder

PathWhat
src/lib.rsConsole (boot, run_frame, target_fps, buttons, power_cycle, both snapshot formats, wram/wram_mut), the in-memory Frame, Samples and Saves, sound-device resampling (audio_output, audio_queued), and SnapshotError.
BUCKThe rust_library, over //third-party/rust:snes-core, snes-config and jgenesis-common. No test target: apps/headless is its test.

wram() and wram_mut() exist only because this repository carries two small patches on the vendored core, which upstream keeps private (chapter 19).

← Previous: Chapter 1, packages/ · Up: packages · Next: Chapter 3, alttp/ →