flybrain/services/flysim/crates/flybrain-gb/README.md
acamilo 2e7eed0a78 rewards: talk and item, boundary indoors pays nothing, adapter v7 with a v6 migration
The operator's decision of 2026-09-23: pay the fly for engaging with what is
inside a building, and stop paying it for walking back out of one. Chosen over
a pad rule and over weighting the choice, and like v0.5.0's catch reward it is
a catalog change, not a loop-review fix.

Indoors. engage::indoor is two of the cartridge's own tables: not outside by
CheckIfInOutsideMap (tileset OVERWORLD or PLATEAU; WarpFound2 labels the other
branch .indoorMaps) and not a place BikeRidingTilesets lets the bike be ridden
(OVERWORLD, FOREST, UNDERGROUND, SHIP_PORT, CAVERN). That is every house, mart,
center, gym, gate, lab, museum, ship, tower, mansion and hideout, and not
Viridian Forest, a cave, the Underground Path or the dock.

`talk`, +0.10, 100 ms, the catalog's tenth kind. Paid when the text box closes
on a conversation that (1) opened on the sample after one where the fly had the
joypad (state::controllable) and was standing still (wWalkCounter zero, the only
state the overworld reads A in) on the same tile; (2) is with the thing in
front: DisplayTextID copies its argument into wSpriteIndex, a sprite slot up to
wNumSprites whose sprite stands on the tile the player faces (or one further
across a counter, on a tileset with counter tiles), or a text id matching the
sign on that tile; an item ball is not a person; (3) opened indoors; (4) closed
on the same map. Keyed talk:<map>:sprite:<slot> / talk:<map>:sign:<id> in the
lifetime `seen` ledger, which is checkpointed and survives a rollback -- not the
macros' session `talked` ledger.

`item`, +0.15, 120 ms, the eleventh. An item ball is a toggleable sprite of this
map (wToggleableObjectList) whose wMapSpriteExtraData is (item id, 0), the shape
LoadMapHeader writes for an ITEM object_event and nothing else; PickUpItem sets
its global bit in wToggleableObjectFlags after GiveItem succeeds. A hidden item
is a bit of wObtainedHiddenItemsFlags, set by FoundHiddenItemText after GiveItem
and by nothing else. Either pays when its bit rises between two playable
samples, once per item (item:<global> / hidden:<index>) for the ledger's life.

`boundary` still writes every key indoors, so exit_visited answers what it did
and the macros see no change, but emits nothing on an indoor map. Outdoor,
forest and cave exits pay as before.

v6 -> v7. STATE_VERSION stays 4 and no field is added: the new ledgers are keys
in `seen`. A v6 state restores with no talk: keys, and the first playable sample
that finds `items:seeded` absent writes one key per item the game already shows
as taken, pays for none, and marks the seed -- so a rollback that un-takes a v6
pickup cannot pay for it. The two item balls a script reveals (the Rocket
Hideout's Silph Scope and Lift Key, toggles $87 and $88, the only ITEM entries
toggleable_objects.asm starts OFF) are left out of the seed. MIGRATES_FROM is
["pokered-unique8-v6"]; v5 is no longer migrated.

Feed kinds: both publish on `explore`, the family of new ground and a door found,
at the same quiet scale; not `area` (maps, notable), `story` or `wildwin`. No
feed-protocol change.

The compatibility string differs from main's in exactly one segment:
pokered-unique8-v6 -> pokered-unique8-v7.

Tests: catalog values and order; indoor over all 24 tilesets; a talk pays once
per (map, object), not while the box is open, not re-talked, not outdoors/in the
forest/in a cave, not for text opened with the joypad taken, simulated, scripted,
mid-step, about someone not in front, or the start menu; not across a warp or a
rollback; counter reach only with counter tiles; item balls, people, trainers,
hidden items, the seed and the script-shown balls; boundary indoors records and
pays nothing; a v6 state and a v6 FLYSIM01 envelope migrate with the new ledgers
empty and the items seeded. rom.rs's bedroom walk now proves the stairs are
recorded and unpaid on the cartridge.
2026-09-23 14:34:10 +00:00

197 lines
10 KiB
Markdown

# 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`
(<https://github.com/binji/binjgb>, 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 <path>` 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:<size>-<target triple>` 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`.