# flybrain-gb The Game Boy half of `flysim`: the binjgb emulator core linked natively, plus the game adapters that turn WRAM into reward and progress. It knows nothing about neurons, and does not depend on `flybrain-core`. ```text ROM bytes -> Emulator::run_frame -> RGBA frame + PCM + WRAM | GameAdapter::sample -> RewardEvent | Ratchet::observe -> recover_game ``` | Module | What it is | | --- | --- | | `emulator` | Safe wrapper over binjgb: frames, framebuffer, buttons, WRAM, audio, save states, ROM hash | | `adapter` | `GameAdapter`, `RewardEvent`, `ProgressSnapshot`, `MemoryReader`, `adapter_for` | | `pokemon_red` | The `pokered-unique8-v7` reward adapter, its catalog and its generated symbol table | | `platformer` | The `sml-progress-v1` Super Mario Land adapter, its catalog and its RAM map | | `ratchet` | The progress ratchet, generic over the adapter's rank and its `RecoveryPolicy` | | `recovery` | Rolling the game back to the ratchet's best safe snapshot | | `compatibility` | The checkpoint compatibility string | One `flysim` binary serves both demos, so everything game-specific sits behind `GameAdapter` and nothing above this crate hardcodes a milestone ladder: `ProgressSnapshot` carries `rank`, `rank_max` and the adapter's own `rank_label`. The same rule covers the things the two games disagree about: `decoder_preset` picks the readout, `boot` says when Start should be permissive, and `game_over` plus `recovery_policy` give the ratchet its triggers and budgets. Each of those has a default that is exactly the Pokémon behaviour, so adding a game does not change an existing one. The one bound that is *not* a per-game setting is the ratchet's rank ceiling: `Ratchet::import` takes the running adapter's `rank_ladder().len()`, 38 for Pokémon and 16 for the platformer, so a checkpoint can never carry a rank the live ladder has no label for. Two ROM pins, two mechanisms. Pokémon Red's is the constant `pokemon_red::SUPPORTED_ROM`; the platformer's arrives from configuration through `adapter_for_with_rom_pin`, because `docs/design/platformer.md` §8.5 records only the SHA-1 of the revision its RAM map describes. An adapter with no usable pin reports `SEMANTIC REWARDS OFF` and pays nothing, which is also what a wrong cartridge does. ## What is vendored `services/flysim/vendor/binjgb` holds the sources of binjgb's own `binjgb` library target at revision `c60e138da5a795ebb55e56b11b7e90024e41112c` (, MIT). `PROVENANCE.md` there records exactly which files were taken and why, and they are unmodified upstream: emulator.c/h common.c/h memory.c/h joypad.c/h builtin-palettes.def No host, SDL, OpenGL, ImGui, debugger, tester, options or Emscripten source is vendored. `rewind.c` is not either: only binjgb's Emscripten wrapper needed it, and nothing here rewinds. `build.rs` compiles `emulator.c`, `common.c`, `memory.c` and `joypad.c` with the `cc` crate and no `-D` defines, which is the configuration the prototype's `binjgb.js` was built in (`RGBDS_LIVE` and `GBSTUDIO` default to `OFF` and the `js`/`wasm` Makefile targets pass neither). One consequence: `EMULATOR_EVENT_BREAKPOINT` can never fire, because `emulator_set_breakpoint` is not compiled in. `run_frame` still checks for it, as the prototype did. `csrc/shim.c` replaces binjgb's `src/emscripten/wrapper.c`. It mirrors only the entry points the prototype's `src/emulator/binjgb.ts` called, and differs from `wrapper.c` deliberately: - every function takes an explicit handle, where `wrapper.c` keeps a file-static `Emulator*` and static joypad and rewind globals, so `Emulator` is `Send`; - joypad state is applied directly with `emulator_set_joypad_buttons` and no joypad callback is installed, matching the prototype's own patch to `wrapper.c`; - the handle hands binjgb a heap copy of the ROM and never frees it. `set_rom_file_data` stores the `FileData` by value and `emulator_delete` frees it through `file_data_delete`, so freeing it in the shim too is a double free. The prototype leaked its WASM-heap ROM allocation, which hid this. FFI declarations in `src/ffi.rs` are hand-written; there is no `bindgen` dependency. `emulator_read_mem` and `emulator_get_wram_ptr` are declared in the shim, because at this revision `emulator.c` defines them and no header declares them. ### Known quirk binjgb's `init_emulator` calls `log_cart_info`, which unconditionally prints the cartridge title, type and header checksum to stdout on every `Emulator::new`. Silencing it would mean patching a vendored source, so `flysim` should redirect its own stdout if the noise matters. ## Regenerating the symbol table `src/pokemon_red/symbols.rs` is generated. The prototype owns the extraction from the pret/pokered disassembly and is read-only to us, so `services/flysim/tools/gen_symbols.py` re-emits the prototype's own generated `src/reward/symbols.ts` as Rust rather than re-deriving anything: ```sh uv run python3 services/flysim/tools/gen_symbols.py ``` (`uv run python3`, not plain `python3`, which is blocked on this box. Pass `--prototype ` for a checkout somewhere other than `~/fly-plays-pokemon`.) That yields the same 24 addresses, 368 selected event flags, 16 story milestones and pokered commit `0cd19d3b877b7dc66d12c7050bed9a7f38154d4b`. `EVENTS` is emitted as an **ordered slice, not a map**: `pokemon-red.ts` iterates `Object.entries(EVENTS)`, so that order decides which of several simultaneous payouts is emitted first, which is visible in the recent-event ticker. Commit the script and its output together. ## Audio format binjgb's native buffer is **unsigned 8-bit, 2 channels, interleaved (left, right), at the configured frequency** — `AudioBuffer` in `emulator.h` and `write_audio_frame` in `emulator.c`. `Emulator::new` takes that frequency; `DEFAULT_AUDIO_FREQUENCY` is 48,000 Hz, matching Web Audio, which is 803.6 stereo frames (1,607.2 interleaved samples) per Game Boy frame. **The samples are unipolar: silence is 0, not mid-scale.** binjgb's mixer sums each channel's unsigned `0..15` sample, scales by the master volume and divides by the channel count, so nothing offsets the result to the middle of the range. Measured over a 600-frame Pokémon Red boot: raw range `0..=44`, mean 1.2. `take_audio()` therefore uses binjgb's own host conversion, `sample / 255` (`AUDIO_CONVERT_SAMPLE_FROM_U8` in `host.c`), giving interleaved stereo `f32` in `[0, 1]` with silence at exactly `0.0` and no clipping. `take_audio_u8()` returns the bytes untouched. `take_audio_bipolar()` rescales to fill `[-1, 1]` by treating mid-scale as zero, which is louder but sits at -1.0 during silence and needs a high-pass filter downstream. The residual DC while the APU plays is binjgb's mixer, not the conversion. `docs/feed-protocol.md` in this repository currently says 44,100 Hz; the flysim design and the stage page want 48,000. The rate is a constructor parameter, so whichever the feed settles on is one argument, not a code change. ### Draining `emulator_run_until` resets the audio write cursor only when the *previous* event carried `EMULATOR_EVENT_AUDIO_BUFFER_FULL`, so a caller that wants per-frame audio has to drain explicitly or silently lose blocks. The prototype never drained at all. `run_frame` drains on `AUDIO_BUFFER_FULL` and again at the frame boundary, and accumulates into one buffer that `take_audio*` empties, so audio is continuous across frames. The ROM test asserts the sample count matches the emulated tick count to within 0.1%. Note that a `run_frame` during the boot interval, where the LCD is off, consumes several frame periods (the 120-attempt retry loop), so it produces several frames' worth of audio. Expected sample counts must come from the tick delta, not from the number of `run_frame` calls. ## State format `export_state` returns exactly what binjgb's `emulator_write_state` emits, which is what the prototype's checkpoints embedded. Three caveats: 1. **It is a `memcpy` of `EmulatorState`, so the bytes are ABI dependent.** Measured: 199,616 bytes on `x86_64-unknown-linux-gnu`, against 199,608 in the prototype's Emscripten build. A prototype milestone save therefore does **not** import here, and the probe test in `tests/rom.rs` confirms it: `Game Boy state size mismatch: 199608 != 199616`. `compatibility.rs` appends a `statefmt:-` segment to the prototype's compatibility string so this is an honest mismatch instead of a silent misparse. There is no migration; the fly starts its native run from a cold boot. 2. **The framebuffer is not part of `EmulatorState`.** After `import_state` the previous image is still on screen until the next `run_frame`. That is why `ratchet::Snapshot` carries the framebuffer alongside the state, and why the prototype's checkpoint had a separate `framebuffer` chunk. 3. **The audio resampler's phase is not part of it either.** `AudioBuffer.freq_counter` and `.divisor` live outside `EmulatorState`, so audio after a restore is not bit-identical to the same run without one. The framebuffer is; the ROM test asserts that and only checks the audio block length. ## Tests ```sh cargo test # unit tests; ROM tests skip cargo clippy --all-targets FLY_ROM="$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb" \ FLY_CHECKPOINT="$HOME/fly-plays-pokemon/local/saves/milestone-3.checkpoint" \ cargo test --release --test rom -- --nocapture ``` The reward and ratchet unit tests are ports of the prototype's `tests/unit/reward.test.ts` and `tests/unit/ratchet.test.ts`, using the same synthetic WRAM traces; `FakeMemory` implements the same read interface the emulator's frame cache does. The ROM tests are gated on `FLY_ROM` and skip cleanly without it. The cartridge never enters this repository: the repo `.gitignore` excludes `*.gb`, `*.gbc`, `*.rom`, `*.sav` and `*.state`. `FLY_CHECKPOINT` is optional and only drives the save-state probe above. Measured on the development laptop (WSL2, release build): **about 5,650 emulator frames per second, 95x real time at 59.7275 fps**, with audio drained every frame and no neural work. The boot walkthrough test reaches `OVERWORLD` in Red's bedroom at about frame 3,835 by alternating Start and A, paying exactly one reward, `ADVENTURE STARTED`.