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.
197 lines
10 KiB
Markdown
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`.
|