Compare commits

...

47 commits
v0.5.5 ... main

Author SHA1 Message Date
acamilo
862e343e1c docs: v0.6.2 status
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
2026-09-23 23:48:20 +00:00
acamilo
d0ce10e3c9 Merge fix/loop-row61: a trainer's challenge is the cartridge's until its battle is over 2026-09-23 23:48:20 +00:00
acamilo
9b117dc4b2 docs: 12.25 and the row 61 audit on top of row 59, the bubble, game.scene in macros mode 2026-09-23 23:47:36 +00:00
acamilo
cff15804fb state, tests: the challenge covers the "!" bubble too, the doc block back above its impl, and the ROM test asserts no pad inside a challenge 2026-09-23 23:47:36 +00:00
acamilo
d6597b3e98 docs: the row 61 audit, a trainer's challenge text walled the forest's corridor 2026-09-23 23:28:17 +00:00
acamilo
0117a85354 docs: macros.md 12.24, a trainer's challenge is the cartridge's until its battle is over 2026-09-23 23:28:12 +00:00
acamilo
c78ca352f6 tests: the ROM proof from the forest's south gate checkpoint 2026-09-23 23:28:05 +00:00
acamilo
0e366cd4e4 state: a trainer's challenge is the cartridge's until its battle is over, in the macros' own scene 2026-09-23 23:27:54 +00:00
acamilo
16bc65b5a8 survey: FLY_PROBE_CATCH_FRAME reads the first free frame from a given one, FLY_PROBE_WHOLE prints a big map whole 2026-09-23 23:27:54 +00:00
acamilo
510727ccc0 docs: v0.6.1 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-23 23:08:10 +00:00
acamilo
f32352bd5e Merge fix/loop-row59: the map graph as the cartridge's headers and warps draw it, pieces and all 2026-09-23 23:08:09 +00:00
acamilo
9ccc0592b1 docs: macros.md 12.24 and the row 59 audit 2026-09-23 22:33:47 +00:00
acamilo
437dfa84a9 tests: from the live Route 4 checkpoint, the fly goes into Mt. Moon and Route 4's west doors are not a ring 2026-09-23 22:33:47 +00:00
acamilo
d181f952a7 tests: from the badge, the road to Mt. Moon is not a ring at the Pewter end, and Route 4's doors and sides are the cartridge's 2026-09-23 22:23:27 +00:00
acamilo
5147ab1a87 geography: a header connection no step on foot crosses is named and is not a way out; Pallet's shore, the sea routes, the League fence 2026-09-23 22:23:01 +00:00
acamilo
97a97a501d geography: a split map is pieces with their own doors and neighbours; Route 4 and Mt. Moon's lower floors, the hop matched on where an exit lands 2026-09-23 22:23:01 +00:00
acamilo
4108fec5ce macros: a push-back is a refusal once the overworld has stayed the fly's for half a second, not on the first frame of it 2026-09-23 22:23:01 +00:00
acamilo
d85ee5026f geography: Route 3/4, 14/15 and 24/25 as the headers connect them, and Mt. Moon's doors on Route 4 2026-09-23 22:23:01 +00:00
acamilo
288e958d19 survey: FLY_PROBE_SAVE_RANK writes the first safe overworld frame at a rung as a checkpoint 2026-09-23 22:23:01 +00:00
acamilo
4d82f7db74 Merge refactor/legacy-frame: FND-01, one LegacyFrame for the service and every harness, FLY_TRACE and the sugar journal 2026-09-23 21:43:45 +00:00
acamilo
2f7b7cebd0 docs: v0.6.0 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-23 21:23:57 +00:00
acamilo
ff49b56e2c docs: the explore row stays "new place" 2026-09-23 21:23:40 +00:00
acamilo
b484a077d4 stage: the explore row reads "new place" again (the operator, 2026-09-23) 2026-09-23 21:23:05 +00:00
acamilo
9301e395e8 docs: the trap hunt and the bench run the service's frame since FND-01, and hunts across the change do not compare 2026-09-23 20:15:20 +00:00
acamilo
9e2f90749f flysim: FLY_TRACE's boundary half read back with fly-session-types, the journal held to the service test, rustfmt on the new files 2026-09-23 20:15:20 +00:00
acamilo
60f09b79a3 flysim: every harness runs the legacy frame: the trap hunt, the benches, the ROM tests' and the probe's stub drivers
trap_hunt, palette_bench and room_escape ran the frame through NeuralAgent::tick, which installs
a frame and its rewards after the next ticks: one frame behind the stream, with the ratchet
observed without the objective signal and a rollback that never re-observed the scene. They now
restore the way the stream restores and run LegacyFrame::transition and ::boundary, looking in
through FrameObserver; FLY_TRACE works in each of them. The stub-readout drivers of
rom_macros_mode, rom_catch and scene_probe run LegacyFrame::execute and ::stub_advance, which are
the same calls they made, in the same order.
2026-09-23 20:15:20 +00:00
acamilo
ad1c0e3693 flysim: a frame-stamped sugar journal in the hot directory
Every admitted sugar and operator reward pulse is appended to sugar-journal.jsonl with the frame
counter it was applied at, which is the step of the next transition in FLY_TRACE, so a shadow run
can replay the audience's input. Append-only, not checkpointed, never read back into the fly.
2026-09-23 20:14:01 +00:00
acamilo
94cc91369a flysim: the legacy frame is one type, flysim::frame::LegacyFrame, and the sim loop runs it
prepare, execute, advance, evaluate, commit, then the boundary's ratchet capture and rollback,
in the order the stream runs them. The frame owns the remainder, the frame counter, the frame on
screen, the mask and the blocked-direction window; the sim loop keeps the feed, the event log,
the milestone archive between commit and boundary, and the checkpoints. Two moves between
disjoint state change no byte: the visual frame is installed at commit, and the stimulations
follow the scene's observation. The FLY_TRACE of a run from the rung-10 checkpoint is
byte-identical to the one the inline hooks wrote.
2026-09-23 20:14:00 +00:00
acamilo
ff74c07622 flysim: FLY_TRACE, a per-frame trace of the legacy loop in the step trace's field names
One JSON line per transition: ticks and the exact remainder, digests of the rates, of the
transition's spikes, of the frame and of work RAM, the decision and the mask, macro and reward
events in order, the rank, and the boundary's slot saves, rollback and captures in the shape of
TraceBehaviour.boundaryActions and TraceOperational.captures. Off unless FLY_TRACE names a file.
Recorded inline in step_frame first, so the frame's extraction can be held to it.
2026-09-23 20:14:00 +00:00
acamilo
bc117ad470 flybrain-gb: say what a move with power does not promise 2026-09-23 19:23:48 +00:00
acamilo
fa021918b4 Merge fix/loop-row60: a move the cartridge would refuse is not dealt beside one that works 2026-09-23 19:23:43 +00:00
acamilo
4591e7907a docs: macros.md 12.23, macros-wram.md section 13, the row 60 audit 2026-09-23 17:59:29 +00:00
acamilo
07f88c443a tests: the ROM proof from the Route 1 checkpoint 2026-09-23 17:23:05 +00:00
acamilo
b33af9e772 scene_probe: the route survey prints the battle bytes a MOVE n button's effect rests on 2026-09-23 17:23:05 +00:00
acamilo
a02d5f7995 trap_hunt: payouts by kind, battle lengths and wins, and MOVE n on a move without effect 2026-09-23 17:23:05 +00:00
acamilo
f3e3e9cf98 palette: a move the cartridge answers with nothing is not dealt beside one it does not
Row 60: TAIL WHIP against a Pidgey at DEFENSE -6 was MOVE 2 183 times. The
rule is PP's: a refused move leaves the pad while another move is usable, and
with none usable the moves stay as PP deals them, so an open list never
comes down to BACK alone.
2026-09-23 17:23:05 +00:00
acamilo
438b2d8540 state: the move table, and whether the battle engine will answer a move with nothing
move_data reads a row of Moves from the cartridge image ($0E:$4000, each row
checked against its own id); move_without_effect answers the refusals the
effect routines make on bytes already in WRAM: a stat stage at its limit or a
stat at 1/999, Mist or a substitute against a stat-lowering move, a status
move against a statused, Poison-type or (Electric) Ground-type target.
MacroState::move_without_effect defaults to false.
2026-09-23 17:23:05 +00:00
acamilo
c8ecf06db5 symbols: the stat stages and the enemy's status, type and stats, resolved from wram.asm
resolve_wram.py now follows the decomp's const and _RS counters and a struct
macro's field labels, so battle_struct fields resolve and 77 of 81 pinned
addresses are re-derived with no disagreement.
2026-09-23 17:23:05 +00:00
acamilo
37adeb9417 docs: a map script's text can pay talk when it names the thing in front, and where it can 2026-09-23 17:22:11 +00:00
acamilo
5d527f0910 Merge feat/engage-reward: the fly is paid for talking to people indoors and picking up items, not for leaving buildings (adapter v7) 2026-09-23 17:20:02 +00:00
acamilo
56b02ff758 rewards: a frame on which no item bit moved reads nothing more 2026-09-23 14:34:10 +00:00
acamilo
8d1d5c44f5 docs: the engagement rewards, and the v6 migration in deploy examples
rewards-learning.md carries the v7 table, the definition of indoors, the four
conditions a conversation pays under, the item and hidden-item reads, the seed,
boundary's indoor rule with the warp-transition frame it was measured on, the
feed-kind choice and the ticker word, and the honesty note on paying for a
conversation. flysim.md records the v6 -> v7 migration; macros-wram.md the six
new reads; the architecture tour and integration.md point at the live catalog.
The deploy comments, example.env and the runbook name FLY_ACCEPT_ADAPTERS=
pokered-unique8-v6 for the v0.6.0 deploy.
2026-09-23 14:34:10 +00:00
acamilo
3cf706c96a rewards: read the conversation's argument once the cartridge has written it, and a ROM test
The first cartridge run found what the synthetic trace could not. In the
Viridian Forest north gate the font bit rose on one frame and DisplayTextID's
argument reached wSpriteIndex twenty frames later: DisplayTextIDInit loads the
font's tiles into VRAM first. Until then the byte still names the previous
text's subject, which may be the person in front of the fly from a conversation
that did not pay. So the watch now remembers the byte from the last ready
frame and reads it once, when it changes or after 45 samples, and only while
the bottom dialogue box is drawn (the start menu draws its own elsewhere); a
box that closes first is read on the closing sample if the dialogue box was up.

tests/rom_engage.rs, gated on FLY_ROM and FLY_ENGAGE_CHECKPOINT: from the rung-10
Pewter checkpoint, with the checkpoint's own v6 ledger restored (the migration,
on real game state), a scripted walker goes down Route 2 into the forest's north
gate, talks to the old man three times (one payout, 0.10) and picks up the
Antidote ball at (25, 11) (one payout, 0.15, "FOUND ITEM #11"); pressing A at
the empty tile pays nothing, and after rolling the emulator back to before the
pickup, taking it again pays nothing. No boundary payout lands while an indoor
header is loaded.

It also measured the warp transition: for about thirty frames of
PlayMapChangeSound wCurMap already names the destination while the tileset and
warp table are still the map being left, so the exit the fly stands on is
classified by the map it belongs to -- a town door still pays its on-exit half,
keyed under the building's id as before, and a building's door does not. A unit
test pins both directions.
2026-09-23 14:34:10 +00:00
acamilo
2d288b0146 stage: the explore row reads "new find"
Since pokered-unique8-v7 the explore feed kind carries four adapter rules --
new ground, an exit, a conversation indoors, an item picked up -- and "new
place" was true of the first two only. The per-game copy now names what all
four are; the feed protocol and the kind set are unchanged.
2026-09-23 14:34:10 +00:00
acamilo
52811bf512 session types: the legacy composition names pokered-unique8-v7
The example composition's adapter and FLYSIM01 string move to v7 with the live
adapter, and the fixtures are regenerated by update_fixtures; valid.json and
invalid.json's hand-written cases follow, with the one deliberately mismatched
string moving from v5 to v6 so it is still the previous adapter.
2026-09-23 14:34:10 +00:00
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
acamilo
ed0080ab2c symbols: the six WRAM names the engagement rewards read
wSpriteIndex, wWalkCounter, wMapSpriteExtraData, wToggleableObjectFlags,
wToggleableObjectList and wObtainedHiddenItemsFlags, resolved by
tools/resolve_wram.py from ram/wram.asm at the pinned commit and bracketed by
addresses symbols.rs already carries. Two of the brackets needed the tool to
count NUM_STATS and NUM_CITY_MAPS, which the decomp defines as const_value over
an enumeration; both are now counted up to the line that defines them.
2026-09-23 14:34:10 +00:00
58 changed files with 6572 additions and 1351 deletions

View file

@ -89,7 +89,9 @@ decoder preset to use.
disassembly at commit 0cd19d3 (`symbols.rs`), gates rewards on a playable state, baselines already disassembly at commit 0cd19d3 (`symbols.rs`), gates rewards on a playable state, baselines already
achieved flags on the first sample so a restore never replays them, and pays only positive achieved flags on the first sample so a restore never replays them, and pays only positive
rewards: story flags, exploration coverage (capped per map), new areas, Pokédex entries, trainer rewards: story flags, exploration coverage (capped per map), new areas, Pokédex entries, trainer
flags, decaying wild wins, badges. Version `pokered-unique8-v5`. flags, decaying wild wins, badges, exits found outdoors, catches, conversations indoors and items
picked up (`docs/rewards-learning.md`). Version `pokered-unique8-v7`; a deploy that names the
previous version in `FLY_ACCEPT_ADAPTERS` migrates its checkpoints instead of refusing them.
- Ratchet (`ratchet.rs`): a 38-rung ladder (boot, bedroom, Pallet Town, Oak's lab, starter, parcel, - Ratchet (`ratchet.rs`): a 38-rung ladder (boot, bedroom, Pallet Town, Oak's lab, starter, parcel,
Pokédex, each town, each badge, the Elite Four, Champion). On first reaching a higher rung in a Pokédex, each town, each badge, the Elite Four, Champion). On first reaching a higher rung in a
safe state it archives the emulator snapshot; on a stall (120 s without new exploration) or a game safe state it archives the emulator snapshot; on a stall (120 s without new exploration) or a game

View file

@ -387,6 +387,17 @@ on-screen ticker cannot disagree with what the sim did.
absent from a `v5` state and restores empty, which is the truth about a run that was never paid absent from a `v5` state and restores empty, which is the truth about a run that was never paid
for a catch. `STATE_VERSION` does not move, because the schema did not. for a catch. `STATE_VERSION` does not move, because the schema did not.
**`v6` -> `v7`** (2026-09-23, the engagement rewards) is the same migration for the next pair,
and `pokered-unique8-v7`'s `migrates_from()` is `["pokered-unique8-v6"]` and nothing else -- `v5`
is no longer migrated, because the live run is `v6`. The deploy that ships it sets
`FLY_ACCEPT_ADAPTERS=pokered-unique8-v6`. No field is added this time: `talk` and `item` key
their ledgers into the existing `seen` array, as `boundary` did, so a `v6` state restores
unchanged with no `talk:` keys. The one step is at the first sample after the restore, not in
`import_state`: a ledger without the `items:seeded` key writes an `item:`/`hidden:` key for every
item the cartridge already shows as taken, pays for none, and marks the seed, so a rollback to a
slot from before a `v6`-era pickup cannot pay for it (`docs/rewards-learning.md`, "The seed").
`STATE_VERSION` stays 4.
- **Restarting a run from an earlier rung** (2026-09-22). `FLY_RESET_STATE=1` throws the run away; - **Restarting a run from an earlier rung** (2026-09-22). `FLY_RESET_STATE=1` throws the run away;
`infra/bin/fly-reset-to-milestone <N>` keeps it and rewinds it. It archives both stores to a `infra/bin/fly-reset-to-milestone <N>` keeps it and rewinds it. It archives both stores to a
dated directory, rewrites `milestone-<N>.checkpoint` with the ratchet's `attempts` and dated directory, rewrites `milestone-<N>.checkpoint` with the ratchet's `attempts` and

View file

@ -133,6 +133,28 @@ a trade and a Pokémon withdrawn from the PC. Reading a catch off it would need
tell those apart. The cartridge's own flag needs none, which is why the row above is the one the tell those apart. The cartridge's own flag needs none, which is why the row above is the one the
adapter reads. adapter reads.
### The engagement rewards' reads (2026-09-23)
**New 2026-09-23** (`talk` and `item`, `docs/rewards-learning.md`, "Engagement rewards"). All six
were resolved by `services/flysim/tools/resolve_wram.py` from `ram/wram.asm` at the pinned commit
and are bracketed by addresses `symbols.rs` already carried; two of the brackets needed the tool
to count `NUM_STATS` and `NUM_CITY_MAPS`, which the decomp defines as `const_value` over an
enumeration.
| what | symbol | address | notes | verified |
| --- | --- | --- | --- | --- |
| the text's subject | `wSpriteIndex` | `$cf13` | `DisplayTextID` copies its argument here: a sprite slot up to `wNumSprites`, else a text id. It arrives **about twenty frames after** `wFontLoaded` bit 0 rises, because `DisplayTextIDInit` loads the font's tiles first; until then it still holds the previous text's subject. | survey (`tests/rom_engage.rs`: the Viridian Forest north gate, the old man at slot 2, font bit on frame 820 and the argument on frame 840) |
| mid-step | `wWalkCounter` | `$cfc5` | non-zero for the frames of a step; the overworld only reads A at zero. Right after `wFontLoaded` in `ram/wram.asm`. | ROM, trace |
| an item ball's item | `wMapSpriteExtraData` | `$d504` | two bytes per sprite slot (slot 1 first): `(item id, 0)` for an `ITEM` `object_event`, `(trainer class, trainer number)` for a `TRAINER` one, zeroes otherwise -- `LoadMapHeader`'s `.itemBallSprite` / `.trainerSprite` / `.regularSprite` | survey (the forest's Antidote ball read `(11, 0)`) |
| taken or hidden, per object | `wToggleableObjectFlags` | `$d5a6` | `flag_array $100`, one bit per global toggleable index (`constants/toggle_constants.asm`); `PickUpItem`'s `HideObject` sets an item ball's bit after `GiveItem` succeeded | survey (the forest's Antidote ball's bit rose on the pickup frame) |
| this map's toggleables | `wToggleableObjectList` | `$d5ce` | up to sixteen `(sprite slot, global index)` pairs, `$ff`-terminated, written by `MarkTownVisitedAndLoadToggleableObjects` | survey |
| hidden items found | `wObtainedHiddenItemsFlags` | `$d6f0` | `flag_array MAX_HIDDEN_ITEMS` (112); `FoundHiddenItemText` sets the bit after `GiveItem` succeeded, and nothing else writes it | ROM (disassembly), trace |
Not used, and why: `hJoyPressed`/`hJoyHeld` would say "A was pressed" directly, but they are HRAM,
which neither `gen_symbols.py` nor `resolve_wram.py` resolves, and a hand-written address is the one
thing those tools exist to refuse. "The fly had the joypad and was standing still on the frame
before the box opened, and the box is about the thing it faces" is the same fact read out of WRAM.
### Battle menu and cursor, own turn against forced switch ### Battle menu and cursor, own turn against forced switch
`HandleMenuInput` is shared by every menu in the game, so which menu is up is read from where it `HandleMenuInput` is shared by every menu in the game, so which menu is up is read from where it
@ -931,3 +953,45 @@ where that layout puts them, so the byte is `wBattleType - 1` = `$d059`, asserte
neighbours in `scene/tests.rs`. Measured on the cartridge in the Pewter Gym: `$00` in the neighbours in `scene/tests.rs`. Measured on the cartridge in the Pewter Gym: `$00` in the
overworld, `$cd` (`OPP_JR_TRAINER_M`) from the last box of the trainer's challenge through the overworld, `$cd` (`OPP_JR_TRAINER_M`) from the last box of the trainer's challenge through the
219-frame transition and the battle. `controllable` reads it as zero. 219-frame transition and the battle. `controllable` reads it as zero.
## 13. What a move will do: the move table and the bytes its effect reads (2026-09-23, `docs/design/macros.md` 12.23)
Row 60. Seven addresses, resolved by `tools/resolve_wram.py` and emitted into `symbols.rs`; the
tool now follows the decomp's `const` and `_RS` counters and a struct macro's field labels, which
is what reaches the `battle_struct` fields, and it re-derives 77 of 81 pinned addresses with no
disagreement.
| symbol | address | what reads it |
| --- | --- | --- |
| `wPlayerMonStatMods` | `$cd1a` | six stages, ATTACK DEFENSE SPEED SPECIAL ACCURACY EVASION; 1 is -6, 7 normal, 13 is +6 |
| `wEnemyMonStatMods` | `$cd2e` | the same for the enemy |
| `wEnemyMonStatus` | `$cfe9` | the enemy's status byte (`battle_struct` +4) |
| `wEnemyMonType1` | `$cfea` | and `Type2` after it |
| `wEnemyMonAttack` | `$cff6` | the enemy's modified ATTACK DEFENSE SPEED SPECIAL, big-endian words |
| `wBattleMonAttack` | `$d025` | the same for the fly's Pokémon |
| `wEnemyBattleStatus2` | `$d068` | bit 1 Mist, 4 substitute, 5 must recharge |
**The move table is ROM.** `Moves` opens `SECTION "Battle Engine 7"`, which `layout.link` places
first in bank `$0E`, so it is `$0E:$4000`, six bytes a row in move-id order from `POUND`:
animation (the move id itself), effect, power, type, accuracy, PP. `state::move_data` reads a row
through `MemoryReader::read_rom`, the cartridge image, and refuses a row whose first byte is not the
id asked for. Measured from the row-60 checkpoint: TACKLE (`$21`) effect `$00` power 35, TAIL
WHIP (`$27`) effect `$13` (`DEFENSE_DOWN1_EFFECT`) power 0.
**`state::move_without_effect`** answers only refusals decided before the roll, for a move with no
power, from `engine/battle/effects.asm` and `MoveHitTest`:
- `*_UP1` / `*_UP2`: the user's stage is 13, or (ATTACK..SPECIAL) the stat is 999;
- `*_DOWN1` / `*_DOWN2`: the target has Mist or a substitute, its stage is 1, or (ATTACK..SPECIAL)
the stat is 1 -- `StatModifierDownEffect` restores the stage and prints "Nothing happened!"
then, so a low-level target reaches it before -6;
- `SLEEP_EFFECT`: any status, unless the target must recharge;
- `POISON_EFFECT`: a substitute, any status, or a Poison type;
- `PARALYZE_EFFECT`: any status, or an Electric move against a Ground type.
`None` outside a battle, without a cartridge, or with a stage byte outside 1..13.
**Not covered** (the reading would be the same kind, and nothing early in the game reaches it):
Confuse Ray and Supersonic on a confused target, Leech Seed on a seeded or Grass target, Focus
Energy, Mist, Reflect and Light Screen already up, Disable on a disabled target, and a damaging
move the type chart makes "doesn't affect" (the chart is another ROM table).

View file

@ -1505,6 +1505,151 @@ has a person to walk to where it had none, `GO OUT` is withheld by 12.5's own ru
frames that were never the fly's deal nothing. The decoder, the reward catalog, the adapter frames that were never the fly's deal nothing. The decoder, the reward catalog, the adapter
version, the roles and the compatibility string are untouched. version, the roles and the compatibility string are untouched.
### 12.23 A move the cartridge answers with nothing is not dealt beside one it does not (2026-09-23, row 60)
Live on v0.5.5, early game after the reset to milestone 1: Route 1, Squirtle L5 (TACKLE, TAIL
WHIP) against a wild Pidgey, "Nothing happened!" on the screen. Since the reset `MOVE 2` 183
times and `MOVE 1` once; the last reward 25 brain minutes before the checkpoint, one wild win in
the whole run. Check 10 flagged `unrewarded` (1,600 decisions, no reward event, no new ground on
two probes), which is right, and it is unchanged.
- **The pad was at fault, not only the choice.** `MOVE n`'s precondition was "the slot holds a
move with PP", so TAIL WHIP stayed on the pad after it had walked the Pidgey's DEFENSE to the
point the cartridge refuses it. `StatModifierDownEffect` answers "Nothing happened!" when the
stage is already -6 **or the stat itself is already 1**, restoring the stage. The checkpoint is
the frame Squirtle fainted to a Pidgey L3 at DEFENSE -6 (stat 2); in the next battle the stat
reached 1 at -5. From there the pad dealt `MOVE 1, MOVE 2, RUN` and the fly pressed `MOVE 2`
until Squirtle fainted, woke at home and walked back: every battle lost, one wild win in the
run. The readout's favourite being `MOVE 2` is the fly's; a button that can do nothing at all
being on the pad is a macro that knows nothing about its own effect, section 12.2's trap.
- **What a move does is the cartridge's, read the same way for every move.** `state::move_data`
reads the move's row of `Moves` from the cartridge image (`$0E:$4000`, each row checked against
its own id) and `state::move_without_effect` answers the refusals the effect routines make on
bytes already in WRAM: a stat stage at its limit or a stat at 1 or 999, Mist or a substitute in
front of a stat-lowering move, a sleep, poison or paralysis move against a target that already has
a status, is Poison type, or is Ground type to an Electric move. No move is named; a miss is a
roll and is not answered. `macros-wram.md` section 13 has the bytes.
- **It is PP's rule.** A move the cartridge answers with nothing is not dealt beside one it does
not, exactly as a spent move is not (12.6, 12.8), and `MOVE 1` over the menu stops being FIGHT's
backstop only in that case. When no move would do anything the moves stay as PP deals them:
taking the last ones away would leave an open list whose only button is `BACK`, 12.11's pair,
and a turn that ends on "Nothing happened!" still ends. RUN, ITEM and SWITCH are untouched.
Nothing is ranked, weighted or pressed for the fly: a button leaves the pad while it cannot change
anything and comes back when it can (a new battle resets the stages). The decoder, the reward
catalog, the adapter version, the roles and the compatibility string are untouched.
### 12.24 The map graph is the disassembly's, piece by piece (2026-09-23, row 59)
Opened pre-emptively: the row-58 review carried the route survey past the Boulder Badge and the
fly walked Pewter City (39, 17) to Route 3 (0, 9) and back from about frame 68,000, `GO OBJECTIVE`
done on Route 3 538 times and `GO ROUTE` done on Pewter 537. Reproduced from a rank-11 checkpoint
the survey writes (`FLY_PROBE_SAVE_RANK=11`): 509 and 508, never on Route 4. The live fly got
through Route 3 anyway and met the next half on v0.6.0 at 22:20 UTC: rank 12, MT. MOON, the
objective Cerulean, on Route 4 per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103,
two new tiles, in and out of the Pokécenter and the cave mouth. Route 4 was one node with Cerulean
off its east edge, which the mountain cuts off from the cave mouth's side. From the live
checkpoint the route survey on `main` walks it 930 times in 72,000 frames.
**The geography table disagreed with the headers.** Checked row by row against
`data/maps/headers/*.asm` and `data/maps/objects/*.asm` at the pinned commit:
- Route 4 is **north** of Route 3, not east (`Route3.asm`: `connection north, Route4`); Route 3's
top edge is the road to Mt. Moon's Pokécenter. Mt. Moon's doors are both **on Route 4**:
(18, 5) into the first floor and (24, 5) into B1F, whose (27, 3) is the way out. Route 3 has no
warps. So Route 3's north edge named no map, and nothing on Route 3 was the way to the rung.
- Route 14 / 15 and Route 24 / 25 had the right neighbour in the wrong column (west and east,
not south and north). Route 24's east edge is Nugget Bridge's far end, rung 16's road.
**Four maps are pieces the player cannot walk between** (12.7's rule, measured by flooding every
tile of every map on the graph from the blocks, blockset, collision list, tile-pair walls and
ledges): Route 2 as before; **Route 4**, cut by the mountain into the cave mouth's side and
Cerulean's side; **Mt. Moon B1F**, four chambers of two ladders each; **B2F**, one large piece and
two small ones. The one road through is 1F (5, 5), B1F (21, 17), B2F (5, 7), B1F (27, 3); the
other two ladders on 1F lead to dead ends.
A split row is now any number of pieces, each with its doors (the warp index and tile) and what is
one step from it, a whole map or another map's piece. Three rules keep it honest:
- **Which piece the fly is in** is what its walk can reach on the decoded grid (section 15): the
piece whose doors it reaches, when exactly one piece's are. The grid has no ledges, so where it
reaches none (Route 4 below the ledges) the nearest door answers. Flooded over every tile of
the four maps' ground in the disassembly, the rule names the right piece for all of them.
- **Which piece a door lands in** is the cartridge's own answer: a warp names the destination
warp it arrives at (`wWarpEntries` byte 2), and each piece lists its warps. An edge lands in
the piece that lists the map it is stepped off.
- **The hop is a piece**, and an exit is toward the objective only if it lands in that piece. On
1F three ladders go down to B1F and one of them is the road.
**A connection nobody can walk across is not a road.** Four header connections have no tile where
both sides are land: Pallet Town / Route 21, Cinnabar / Route 20, Route 20 / 19 (sea) and Route 22 /
23 (the League's fence). They keep their name and offer no exit and no hop. Without this the road
from Pallet Town to Cerulean was by sea, and a fly that whited out in Mt. Moon, which the survey's
did, walked into Pallet's shore every two seconds.
**One seam frame, found on the way.** Route 3's first trainer closes his challenge onto five frames
of plain overworld before `StartTrainerBattle` decides the battle (`home/trainers.asm`: it runs
after `DisplayTextID`'s close-down). Row 58's pending push-back was decided on the first of them
and walled (11, 6), the one gap between Route 3's west end and the rest of the road, for the
session. A push-back is now a refusal only once the overworld has been the fly's for thirty frames
running; a battle inside them drops it.
From the badge, the route survey reaches Route 4 at frame 70,356 and Mt. Moon at 70,707 (rung 12),
no pushed tile; after whiting out in the cave it walks the land road back from Pallet Town. The
ROM test on the stub rotation reaches Mt. Moon in 56.7 brain minutes with 4 Pewter / Route 3
crossings; the base makes 3,391 in 80.4 and never stands on Route 4. From the live 2026-09-22
rank-11 checkpoint the branch reaches Mt. Moon too, where the base ends fenced on Route 3. From
the live Route 4 checkpoint the branch is in the cave on frame 279 and stays on the road; on the
stub rotation Route 4's west doors are crossed 13 times in twenty brain minutes (whiteouts and
walks back included) against 56 on `main`.
Nothing is ranked and nothing presses for the fly: a table of maps says what the headers say, a
split map has the pieces its ground has, and a frame that was the cartridge's is not read as the
fly's. The decoder, the reward catalog, the adapter version, the roles and the compatibility
string are untouched.
### 12.25 A trainer's challenge is the cartridge's until its battle is over (2026-09-23, row 61)
Live on v0.5.5, rung 9, for twenty minutes: `GO OBJECTIVE` into Viridian Forest's south gate (map
50, `$32`), `GO OUT` straight back onto Route 2, `GO WARP` back from the forest, `GO OBJECTIVE
blocked` in the forest, no reward. Reproduced with the route survey from the live checkpoint
(uniform choice per hold, xorshift seed 7), which walks the same ring for twenty brain minutes.
- **The ring's cause was a wall, not the gate.** The forest's only road to its north gate is a
two-wide corridor at x = 1-2; a Bug Catcher stands on (2, 18) facing west. A walk up the
corridor steps onto (1, 18), the trainer takes the joypad, and row 58's held push-back waits for
the joypad to come back. `DisplayEnemyTrainerTextAndStartBattle` clears `wJoyIgnore` before the
challenge text and `StartTrainerBattle` writes `wCurOpponent` only after that text's close-down:
**five frames** with no box, no script bit and `wCurOpponent` zero, which the seam read as the
fly's overworld. The push was written there; (1, 18) went into the pushed ledger, which has no
window, and from then on every walk to the north gate had no road. `GO OBJECTIVE` walked to the
nearest reachable tile, a dead end at (6, 1), and was blocked; `GO WARP`'s last tier took the
south gate, whose `GO OUT` is Route 2, whose `GO OBJECTIVE` is the gate.
- **The fact is `wStatusFlags7` bit 3, `BIT_TRAINER_BATTLE`**: set by `CheckFightingMapTrainers`
on the "!", cleared at `.battleOccurred` after every battle (before the blackout check). It
covers more than the five frames: the "!" bubble runs about sixty frames before `wJoyIgnore` is
set, and they read as the fly's overworld too -- about sixty-six free-looking frames per
engagement, measured. An overworld frame with the bit set is `Unknown` in the macros' own
scene, with no text box, so the pad is empty, no ground is recorded and no held entry is
decided on it. A trainer talked to by the fly never sets it; its `wCurOpponent` is written
inside the text.
- **On main the five frames are already row 59's** (12.24: a held push-back is written only after
thirty frames of overworld), and that alone keeps (1, 18) clear. This row is the cartridge-fact
layer under it: the pad is empty through the bubble as well, no ground is recorded, and it does
not depend on the gap staying under thirty frames.
- **The macros' reading only.** `controllable` and `scene::detect` are shared with the reward
adapter and do not change; `PokeState`'s `scene` and `scripted` read the bit beside them. In
macros mode the feed's `game.scene` is the palette's, so it reads `unknown` on those frames,
which is what the contract says of a frame the cartridge is driving.
- **The gates were modelled right.** Both forest gates are on the graph and `next_hop` answers
the forest from the south gate and Route 2 from the north one. The south gate's `GO OUT` is the
"a room has to be leavable" tier, a way back that is the fly's choice: with the corridor open,
the survey seed that walked into the gate twelve times still earned the badge, on both arms.
Nothing is ranked or pressed for the fly: frames that were never the fly's deal nothing. The
decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched.
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a ## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
## priority to visit the shop at least once per area; make shop macros item purchases. same ## priority to visit the shop at least once per area; make shop macros item purchases. same
## for the Pokécenter. heal should be a macro.") ## for the Pokécenter. heal should be a macro.")

View file

@ -177,6 +177,10 @@ achievements.
| Wild win | +0.1, +0.05, +0.0333 | at most three observed wild KOs per `(map,species,level)` | | Wild win | +0.1, +0.05, +0.0333 | at most three observed wild KOs per `(map,species,level)` |
| Badge | +3 | each newly set badge bit | | Badge | +3 | each newly set badge bit |
That is the prototype's catalog as it shipped, kept here as history. The live one is
`docs/rewards-learning.md`: `pokered-unique8-v7` adds `boundary` (v5), `catch` (v6), and `talk` and
`item` with no `boundary` payout indoors (v7, the operator's decision of 2026-09-23).
Every value is positive; there were no loss or blackout penalties. Values in a frame summed into Every value is positive; there were no loss or blackout penalties. Values in a frame summed into
`R`, then `m = tanh(R)`. PAM stimulation ran 80 to 400 ms depending on reward kind, with `R`, then `m = tanh(R)`. PAM stimulation ran 80 to 400 ms depending on reward kind, with
overlapping pulses taking their maximum. overlapping pulses taking their maximum.

View file

@ -1,6 +1,6 @@
# Rewards and learning # Rewards and learning
The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v6`. The code of record is The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v7`. The code of record is
`services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the `services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the
gates and the rules); this page says what each rule pays for and why it is allowed to. The gates and the rules); this page says what each rule pays for and why it is allowed to. The
prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules
@ -23,8 +23,10 @@ change what the fly can do.
| `trainer` | `trainer` | +0.5 | 200 ms | Each named `EVENT_BEAT_*` flag once, except the flags classified as story milestones | | `trainer` | `trainer` | +0.5 | 200 ms | Each named `EVENT_BEAT_*` flag once, except the flags classified as story milestones |
| `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` | | `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` |
| `badge` | `badge` | +3 | 400 ms | Each newly set badge bit | | `badge` | `badge` | +3 | 400 ms | Each newly set badge bit |
| `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger | | `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger. **Nothing on an indoor map** (since v7): the exit is still recorded, and pays 0 |
| `catch` | `wildwin` | +0.30, +0.10 | 150 ms | A wild Pokémon kept by a ball: +0.30 for a species this run had never owned, +0.10 for a repeat; at most three payouts per species for the lifetime of the ledger | | `catch` | `wildwin` | +0.30, +0.10 | 150 ms | A wild Pokémon kept by a ball: +0.30 for a species this run had never owned, +0.10 for a repeat; at most three payouts per species for the lifetime of the ledger |
| `talk` | `explore` | +0.10 | 100 ms | A conversation the fly opened with a person or a sign **indoors**, paid when its box closes; once per `(map, sprite slot or sign text id)` for the lifetime of the ledger |
| `item` | `explore` | +0.15 | 120 ms | An item ball or a hidden item picked up, on any map; once per item for the lifetime of the ledger |
Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")` Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")`
is `None` by test. The values in one frame sum into `R`, and the network reinforces once with is `None` by test. The values in one frame sum into `R`, and the network reinforces once with
@ -43,6 +45,18 @@ currently reads "wild win". The event's own label, `CAUGHT #<species>`, is what
log, `/status` and the checkpoint. Changing the ticker copy means opening the feed's closed kind log, `/status` and the checkpoint. Changing the ticker copy means opening the feed's closed kind
set, which this rule deliberately did not do. set, which this rule deliberately did not do.
`talk` and `item` publish on `explore`, for the same reason `boundary` does: each is the fly
finding what is in a place -- new ground, a door, a person or sign it opened, an item it picked up
-- at the same quiet scale (0.05 to 0.15). Not `area`, which counts maps and is a notable row; not
`story`, which is the plot; not `wildwin`, which is a battle. No feed kind was added, so
`docs/feed-protocol.md` and the stage's switch statements did not move. The Pokémon Red ticker's
`explore` row still says "new place" ("3 new places" collapsed) for all four: "new find" was
proposed and the operator kept "new place" (2026-09-23), so a conversation or an item reads as a
new place on screen. The event labels -- `TALKED TO #<slot> IN AREA <map>`, `READ SIGN #<id> IN
AREA <map>`, `FOUND ITEM #<item>`, `FOUND A HIDDEN ITEM` -- reach the event log, `/status` and the
checkpoint. Both also reset the stage's stall meter, which counts `explore`: engaging with a
building is progress in the sense the operator asked for.
## Catch rewards ## Catch rewards
The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not only for The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not only for
@ -82,6 +96,87 @@ well below a badge (3.0). A catch of a new species pays 0.80 across two kinds, w
a story flag and a badge -- deliberately, because it is the one event that is both a discovery and a story flag and a badge -- deliberately, because it is the one event that is both a discovery and
a thing the fly had to do on purpose. a thing the fly had to do on purpose.
## Engagement rewards
The operator's decision of 2026-09-23, recorded with the port decisions: reward the fly for
engaging *inside* buildings and stop paying it for leaving them. It was chosen over a pad rule and
over weighting the choice, and it is a catalog change -- an operator decision, like the catch
reward -- not a loop-review fix (`docs/loop-review.md`). It answers a shape the loop reviews kept
finding in Pewter: `GO OBJECTIVE` into a building and `GO OUT` straight back, paid for the door on
the way out and for nothing inside.
**Indoors** is two of the cartridge's own tables, and nothing hand-classified
(`pokemon_red/engage.rs`, `indoor`). `CheckIfInOutsideMap` (`home/overworld.asm`) is the game's
outdoor test -- tileset `OVERWORLD` or `PLATEAU` -- and `WarpFound2` labels its other branch
`.indoorMaps`; on its own that would call Viridian Forest and every cave indoors, and their exits
are how the fly gets anywhere. `BikeRidingTilesets` (`data/tilesets/bike_riding_tilesets.asm`) is
the list of places the bicycle may be ridden -- `OVERWORLD`, `FOREST`, `UNDERGROUND`, `SHIP_PORT`,
`CAVERN` -- and the bike is the one thing the cartridge refuses inside a building by rule. A map is
indoors when its `wCurMapTileset` is in neither: every house, mart, Pokémon Center, gym, gate, lab
and museum, the S.S. Anne, Silph Co., the Pokémon Tower, the Mansion, the Rocket Hideout and the
Indigo Plateau's rooms. Not the forest, a cave, the Underground Path or Vermilion's dock.
**`talk`, +0.10.** Paid on the sample the text box closes, for a conversation that
1. *the fly opened*: on the last sample before the font bit (`wFontLoaded` bit 0) rose, the fly had
the joypad -- no `wJoyIgnore`, no simulated input, no scripted movement -- was standing still
(`wWalkCounter` zero, the only state the overworld reads A in) and stood where it stands now. A
script's text usually opens with the joypad taken; one opened by a map script the frame after a
step ends can still look opened by the fly, and is then held to rule 2 (below);
2. *is with the thing in front of it*: `DisplayTextID` copies its argument into `wSpriteIndex` --
a sprite slot up to `wNumSprites`, or a text id -- and the sprite must stand on the tile the
player faces (or one further, across a counter, on a tileset that has counter tiles, which is
`IsSpriteOrSignInFrontOfPlayer`'s own long reach), or the text id must be the sign's on that
tile. The byte arrives about **twenty frames after** the font bit (measured on the cartridge:
`DisplayTextIDInit` loads the font's tiles first) and until then still names the previous
text's subject, so it is read once it has changed or 45 samples have passed, and only while the
bottom dialogue box is drawn -- the start menu draws its own box elsewhere. An item ball is a
sprite but not a person, and pays `item`;
3. *opened indoors*, on the map the box opened on;
4. *finished*: the box closed on the same map. A conversation that ends in a warp, a rollback or a
restore pays nothing.
The ledger is the adapter's lifetime `seen` set, keyed `talk:<map>:sprite:<slot>` or
`talk:<map>:sign:<text id>` -- the same "map and object index" the macros' `talked` ledger uses,
but **not** that ledger: the macros' ledger is session state and is thrown away on a restore; this
one is checkpointed and survives a rollback, so talking to the same person again, after a restore
or not, pays nothing. The trainer the fly speaks to before a battle is a person and pays once; the
nurse, a clerk and a sign each pay once per map.
**`item`, +0.15.** Read from the cartridge's own "this one has been taken" bits, on any map.
An item ball is one of the map's toggleable sprites (`wToggleableObjectList`, sprite slot and
global index) whose `wMapSpriteExtraData` is `(item id, 0)` -- the shape `LoadMapHeader` writes for
an `ITEM` `object_event` and for nothing else (a trainer's is `(class, number)` with numbers from
1, a person's two zeroes); `PickUpItem` sets its global bit in `wToggleableObjectFlags` through
`HideObject`, and only after `GiveItem` succeeded, so a full bag pays nothing. A hidden item is a
bit of `wObtainedHiddenItemsFlags`, set by `FoundHiddenItemText` after `GiveItem` and by nothing
else; hidden *coins* have their own bitset and are not items. Either pays when its bit rises
between two playable samples, keyed `item:<global index>` or `hidden:<index>`, once for the life
of the ledger. A gift item from a script (the Old Amber, a TM from a person) is not an item ball:
the conversation pays `talk`, and the item nothing.
**The seed.** The first playable sample that finds the key `items:seeded` absent -- a fresh
adapter, or a `v6` ledger restored under `v7` -- writes a key for every bit the game already shows
as taken and pays for none of them, so a rollback to a slot from before a `v6`-era pickup cannot
pay for taking it again. The two item balls a script *reveals* are left out of the seed, because
their bits are set from a new game until Giovanni's defeat clears them: the Rocket Hideout's Silph
Scope and Lift Key (`$87`, `$88`), the only `ITEM` entries `data/maps/toggleable_objects.asm` starts
`OFF`.
**`boundary` indoors.** Every exit on an indoor map is still written to the ledger, so
`exit_visited` answers exactly what it did and the macros see no change, but nothing is paid. A
town's doors, a route's edges, the forest's gates and a cave's ladders pay as before. One
consequence, measured on the cartridge (`tests/rom_engage.rs`): for the thirty-odd frames of
`PlayMapChangeSound` the cartridge has written the destination into `wCurMap` while the tileset and
the warp table are still the map being left, so the exit the fly is standing on is classified by
the map it belongs to. Walking into a building through a town door still pays that door's on-exit
half, 0.10, once, keyed under the building's id as it always was; walking out pays nothing.
**The scale.** A building's worth of engagement -- a few people, a sign, perhaps a ball -- is
0.3 to 0.6: more than the 0.15 its door paid for being left, less than a new Pokédex entry per
person, far below a badge. Everything is once per thing for the lifetime of the ledger, so no
building can be farmed.
## Gates ## Gates
Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other
@ -110,6 +205,10 @@ therefore replays none of it.
## Boundary rewards ## Boundary rewards
Since `pokered-unique8-v7` everything below holds **outdoors** -- towns, routes, the forest,
caves -- and on an indoor map the same keys are written and nothing is paid ("Engagement rewards"
above has the definition of indoors and the one transition frame worth knowing about).
`docs/design/room-escape.md` section 2. The rule pays 0.05 the first time the fly stands on a tile `docs/design/room-escape.md` section 2. The rule pays 0.05 the first time the fly stands on a tile
orthogonally adjacent to one of the current map's exits, and 0.10 the first time it stands on the orthogonally adjacent to one of the current map's exits, and 0.10 the first time it stands on the
exit tile itself. Both are keyed into the adapter's lifetime `seen` ledger as exit tile itself. Both are keyed into the adapter's lifetime `seen` ledger as
@ -177,7 +276,20 @@ body picks the macro; the descending neurons press the buttons.**
## Honesty ## Honesty
The catalog now includes catches. The honesty panel's copy is not data-driven from the catalog -- The catalog now includes conversations and items (v7). Paying for a conversation is the closest
the catalog has come to paying for a *button*: A is what opens one. It is still a reward, not a
press. Nothing in the adapter presses A, chooses when, or tells the fly who is there; the payout is
read out of WRAM after a conversation the fly's own buttons -- or the macro the mushroom body chose
-- opened and finished, and it is once per person or sign for the life of the run, so the thing
that is learned is "the people in a building are worth a visit", not "press A". It is also why the
rule demands evidence that the fly opened the box. That evidence is not proof: a map script runs
one frame after a step ends and may open text while the fly is still, controllable and on the same
tile. Such text pays only if it names the person or sign the fly is facing, once per key; in the
early game none can (checked: the museum ticket man, the Route 22 and Route 5 guards, Viridian
Mart, Oak's Lab), and a few late ones can once each (the Fighting Dojo master, the Elite Four
after their battles). Review of 2026-09-23.
The catalog also includes catches. The honesty panel's copy is not data-driven from the catalog --
`apps/stage/src/lib/schedule.ts`'s rotating card is four written lines and lists no kinds -- so `apps/stage/src/lib/schedule.ts`'s rotating card is four written lines and lists no kinds -- so
there was nothing to regenerate and the copy is unchanged. The sentences below are where the there was nothing to regenerate and the copy is unchanged. The sentences below are where the
argument lives. argument lives.

View file

@ -832,3 +832,49 @@ median; a two-fly transition near 10 to 12 ms at the median in every execution m
10 gains `unrewarded` (100+ decisions, no reward, no new ground, two probes), still never 10 gains `unrewarded` (100+ decisions, no reward, no new ground, two probes), still never
acting. Also shipped: EDGE-01, the feed over flybus behind FLY_FEED_VIA, off (`direct`) acting. Also shipped: EDGE-01, the feed over flybus behind FLY_FEED_VIA, off (`direct`)
everywhere. Next: row 59, Route 3's neighbours and Mt. Moon's doors in the geography table. everywhere. Next: row 59, Route 3's neighbours and Mt. Moon's doors in the geography table.
- 2026-09-23 (the operator's reset): the live run restarted from milestone 1, the bedroom, with the
brain as it was then (65 brain seconds), to watch the macros in the early game. The v5 archive
migrated to v6. It reached rung 9, Viridian Forest, 31 minutes later.
- 2026-09-23 (v0.6.0, the operator's decision, plus loop review row 60): the fly is paid for
engaging inside buildings and not for leaving them. New catalog kinds `talk` (+0.10, the first
conversation the fly opens with each person or sign on an indoor map, once per key for the run)
and `item` (+0.15, each item ball or hidden item, once); `boundary` pays nothing for an indoor
exit. Indoor is the cartridge's own tables (not CheckIfInOutsideMap's outdoor tilesets and not a
bike tileset). Both publish on `explore`; the ticker still reads "new place" (the operator kept
the wording). Adapter `pokered-unique8-v7`; a deploy with FLY_ACCEPT_ADAPTERS=pokered-unique8-v6
migrates the run and seeds every item the game already shows taken without paying it. Row 60:
from the fresh run, TAIL WHIP (MOVE 2) was dealt beside TACKLE after the cartridge had begun
refusing it ("Nothing happened!": the stage at -6 or the stat at 1), and the brain's MOVE 2
preference drew battles out until Squirtle fainted. A move the cartridge would refuse -- read
from its move table and the target's stages, status, types, Mist and substitute -- is not dealt
beside one that works, as a move out of PP is not. ROM test: 2,657 of 2,657 own-turn frames dealt
the refused move before, 0 of 568 after; battles won 0 -> 3 of 12 -> 14. The real-brain hunt did
not finish at this load; recorded as a deviation. Ethos check held.
- 2026-09-23 (v0.6.1, loop review, auto): row 59, Mt. Moon. With the Boulder Badge the live fly
reached rung 12 and rang on Route 4 (GO ROUTE 233, GO OBJECTIVE 122, GO OUT 111 per ten minutes,
one new tile), and restarts did not hold: the map graph was wrong, not a ledger. The geography
table is now the cartridge's own headers and warps: Route 3 connects north to Route 4, Mt.
Moon's doors are on Route 4 (1F at (18,5), B1F's exit at (24,5)); Routes 14/15 and 24/25 were
in the wrong compass columns and Routes 22/23 were missing; a map can be several pieces (Route
4 two, Mt. Moon B1F four chambers, B2F three), the fly's piece read from the decoded grid and a
warp landing in the piece that holds its destination; sea and fence connections (Pallet/21,
Cinnabar/20, 20/19, 22/23) are no longer exits. A trainer's challenge leaves five frames of
plain overworld before the battle, and a push-back is now written only after thirty frames of
the fly's own. From the live Route 4 checkpoint: crossings of the west doors 56 -> 13, into Mt.
Moon at frame 278; from the badge checkpoint the fly reaches Mt. Moon and rung 12 where the base
rang at Pewter. The reviewer checked all 36 connection rows and every piece against the
disassembly. The trap hunt was not run at this load (recorded deviation). Also shipped: FND-01,
one LegacyFrame for the service and every harness (no live behaviour change), FLY_TRACE and the
sugar journal. Ethos check held.
- 2026-09-24 (v0.6.2, loop review, auto): row 61, the Viridian Forest corridor. In the fresh run the
fly rang between the forest, its south gate and Route 2 (GO OBJECTIVE / GO OUT / GO ROUTE) for
twenty minutes at rung 9. A Bug Catcher stands on the forest's only corridor north; when he saw
the fly, the frames of his "!" bubble and the five frames between his text and the battle read as
the fly's own overworld, and a held push-back walled (1,18) for good, so GO OBJECTIVE had no road
north. The macros now read the cartridge's BIT_TRAINER_BATTLE (wStatusFlags7 bit 3, set at the
"!", cleared after every battle, a lost one too) and treat those frames as the cartridge's: no
pad, no ground recorded, no push decided. Row 59's 30-frame debounce already closed the five-frame
gap; this is the cartridge-fact layer under it. ROM test: base walls (1,18) and stays at rung 9;
branch reaches Pewter on frame 23,755, and offers no button on any of the 67 challenge frames
(67 of 67 before). Survey seed 7: rung 9 -> BOULDER BADGE. The trap hunt's stub cannot see this
trap; the ROM test and survey are the proof (recorded deviation). Ethos check held.

View file

@ -300,8 +300,9 @@ if [ -n "$RELEASE_TARBALL" ]; then
# #
# FLY_ACCEPT_ADAPTERS is the *other* override, and the opposite one: it keeps # FLY_ACCEPT_ADAPTERS is the *other* override, and the opposite one: it keeps
# the run. It names adapter version strings whose checkpoints the new build # the run. It names adapter version strings whose checkpoints the new build
# may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 for the deploy # may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 for the deploy
# that adds the catch reward. It only applies when the adapter segment is the # that adds the engagement rewards (v7; v5 -> v6 was the catch reward's).
# It only applies when the adapter segment is the
# ONLY difference between the two strings and the new build's adapter says it # ONLY difference between the two strings and the new build's adapter says it
# can read that one; a dataset, kernel, emulator or state-format change is # can read that one; a dataset, kernel, emulator or state-format change is
# still a refusal, because none of those has a migration. The same variable is # still a refusal, because none of those has a migration. The same variable is
@ -354,7 +355,7 @@ The difference is usually an adapter/ladder or dataset version bump. Three ways
* deploy a build whose string matches (check out the commit the running release was built from), or * deploy a build whose string matches (check out the commit the running release was built from), or
* if the ADAPTER VERSION is the only segment that differs and the new build documents a * if the ADAPTER VERSION is the only segment that differs and the new build documents a
migration from the old one, re-run with FLY_ACCEPT_ADAPTERS set to the adapter id in the live migration from the old one, re-run with FLY_ACCEPT_ADAPTERS set to the adapter id in the live
string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5). The run is kept; flysim applies the same string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v6). The run is kept; flysim applies the same
rule at restore. See docs/design/flysim.md, \"Restoring across an adapter version\", or rule at restore. See docs/design/flysim.md, \"Restoring across an adapter version\", or
* accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which * accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which
archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and

View file

@ -20,7 +20,8 @@ FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
cargo run --release -p flysim --example palette_bench cargo run --release -p flysim --example palette_bench
``` ```
Both arms are the sim loop's own frame order over the real connectome (`data/fafb-v783`), the real Both arms are the sim loop's own frame order (`flysim::frame::LegacyFrame` since 2026-09-23;
before that `NeuralAgent::tick`'s, one frame behind the stream) over the real connectome (`data/fafb-v783`), the real
Game Boy readout preset with nothing overridden, the real Pokémon adapter paying the real reward Game Boy readout preset with nothing overridden, the real Pokémon adapter paying the real reward
catalog, and the real ratchet on the adapter's own recovery policy. The only difference between catalog, and the real ratchet on the adapter's own recovery policy. The only difference between
them is `flysim::macros::MacroLayer`, built from the configuration the way `Sim::boot` builds it, them is `flysim::macros::MacroLayer`, built from the configuration the way `Sim::boot` builds it,

View file

@ -151,6 +151,15 @@ window every 15 brain seconds, so a loop is caught wherever it starts) in which
A window with no macro in it is not flagged: silence waits, and that is the doctrine working. A window with no macro in it is not flagged: silence waits, and that is the doctrine working.
**2026-09-23, FND-01.** The hunt now runs `flysim::frame::LegacyFrame`, the frame the service
runs, restored the way the service restores (no held channel, no location, the blocked window at
brain time 0). Before that it ticked the brain through `NeuralAgent::tick`, one frame behind the
stream: each frame and its rewards reached the brain after the next ticks, the ratchet was
observed without the objective signal, and a rollback did not re-observe the scene. Hunts from
before and after the change are not comparable number for number; compare two arms built from
the same side of it. `FLY_TRACE=<path>` writes the run in the service's own per-frame trace
format (`flysim::trace`), so a hunt can be diffed against the service from the same checkpoint.
```sh ```sh
FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \ FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
FLY_TRAP_CHECKPOINT=.local/checkpoints/release-viridian-loop.checkpoint \ FLY_TRAP_CHECKPOINT=.local/checkpoints/release-viridian-loop.checkpoint \
@ -2757,5 +2766,288 @@ route survey above is the reproduction; the hunt is reported, not smoothed.
- `cargo clippy --all-targets`: **0 warnings**. - `cargo clippy --all-targets`: **0 warnings**.
- `npm test` 663 passed; `npm run typecheck` clean. - `npm test` 663 passed; `npm run typecheck` clean.
- `infra/tests/lint.sh`: ALL CHECKS PASSED, check 10's two new cases and the de-PII guard included. - `infra/tests/lint.sh`: ALL CHECKS PASSED, check 10's two new cases and the de-PII guard included.
- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had:
648 bytes, sha256 `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68`
on `main` after the engagement rewards (adapter v7). Decoder, reward catalog, adapter version
and roles untouched.
## 2026-09-23, row 60: TAIL WHIP after it stopped working
### What was live
Map 12 (Route 1), rank 9, v0.5.5, after the operator reset the run to milestone 1: Squirtle L5
(TACKLE, TAIL WHIP) in wild battles with Pidgey, "Nothing happened!" on the screen. Since the reset
`MOVE 2` 183 starts and `MOVE 1` one; the last reward 25 brain minutes before the checkpoint,
1,600 decisions since, one wild win in the whole run. Check 10 flagged `unrewarded` -- correctly:
100+ decisions, no reward event, no new ground on two probes. It is unchanged.
### The survey: the pad on the fly's own turn
The route probe from the checkpoint (`FLY_PROBE_CATCH=route`, `FLY_PROBE_PREFER="MOVE 2"`, the
live readout's favourite; 72,000 frames) now prints the battle bytes on every pad change. The
checkpoint is the frame Squirtle fainted: Pidgey L3, DEFENSE stage 1 (-6), DEFENSE 2, TAIL WHIP's
row `$27`: effect `$13` power 0. In the next battle DEFENSE reached **1 at stage -5**, which
`StatModifierDownEffect` refuses as well. From then on the own-turn pad was `MOVE 1, MOVE 2, RUN`
over the menu and `BACK, MOVE 1, MOVE 2` over the list: **every own-turn pad with TAIL WHIP
refused dealt `MOVE 2`** (72 of 72; a 73rd frame was a list with no placeable cursor, `NEXT`
alone), TACKLE beside it every time. `MOVE 1` was never missing; `MOVE 2` was a
button that could change nothing.
| # | trap | trigger | test | fix, or why it is left |
| --- | --- | --- | --- | --- |
| 60 | `MOVE n` is bound by PP alone, so a move the cartridge refuses ("Nothing happened!", "didn't affect") stays on the pad beside one that works | any stat move at its stage or stat limit, any status move at a target it cannot affect; Route 1, TAIL WHIP at a Pidgey's DEFENSE 1, every battle lost | `a_move_the_cartridge_answers_with_nothing_is_off_the_pad_beside_one_it_does_not`, `a_spent_move_and_a_move_without_effect_leave_the_one_that_works`, `with_no_move_that_does_anything_the_moves_stay_as_pp_deals_them`, six `state` tests, `tail_whip_at_its_limit_is_not_dealt_and_a_route_one_battle_is_won` (ROM) | **fixed**: `state::move_data` reads the move's row of `Moves` (`$0E:$4000`) from the cartridge image; `state::move_without_effect` answers the refusals decided before the roll (stage 1/13, stat 1/999, Mist or substitute, a statused, Poison or Ground target); the palette treats such a move as it treats a spent one. `docs/design/macros.md` 12.23, `macros-wram.md` section 13 |
### Before and after
The ROM-gated run, 72,000 frames (20.1 brain minutes) from the checkpoint, the real palette
driven with `MOVE 2` preferred and a uniform choice otherwise; base is this branch with the
palette commit reverted:
| measure | base | branch |
| --- | ---: | ---: |
| own-turn frames with a refused move beside a useful one | 2,657 | 568 |
| ... of them dealing the refused move | **2,657** | **0** |
| battles ended / won | 12 / **0** | 14 / **3** |
| battles ended with the fly's Pokémon fainted | 12 | 8 |
| battle length, frames, median / max | 4,970 / 8,994 | 3,594 / 4,793 |
| `MOVE 2` / `MOVE 1` / `RUN` starts | 94 / 0 / 0 | 56 / 5 / 4 |
The route survey, same driver and checkpoint: base never leaves Pallet Town, Red's house and
Route 1 (637 tiles at the end); the branch reaches Viridian City, its Pokémon Center and mart and
Route 2 (748).
The survey itself, same driver: own-turn pads dealing a refused TAIL WHIP 72 -> **0** (of 11 at
the limit); `MOVE 2` / `MOVE 1` / `RUN` done 94 / 0 / 0 -> 56 / 4 / 4; maps with a macro done 4 -> 10.
**The real-brain trap hunt was not run to the end.** Both 20-minute arms (`trap_hunt` now reports
payouts by kind, battle lengths and wins, and `MOVE n` starts on a move without effect) were
started from the checkpoint and stopped after 1 h 48 min wall at about 19 CPU-minutes each: the box
sat at load 25-40 and a stub arm ticks the same brain. The ROM-gated run and the survey above are
the before/after; they drive the real palette with the live readout's measured preference instead
of the brain, which is the deviation.
### Residuals, named rather than worked around
- **The fly still spends TAIL WHIP while it works.** Six presses at stage 7 to 1 are the fly's
choice, and a Squirtle at 8/20 can faint doing it; eight of fourteen battles on the branch
still ended that way. What changed is that the seventh is not on the pad.
- **Not covered, same kind, nothing early reaches it:** Confuse Ray and Supersonic on a confused
target, Leech Seed on a seeded or Grass target, Focus Energy, Mist, Reflect and Light Screen
already up, Disable on a disabled target, and a damaging move the type chart makes "doesn't
affect" (the chart is another ROM table). `macros-wram.md` section 13.
- **With no move that would do anything, the moves stay as PP deals them.** Taking them away would
leave an open list with `BACK` alone (12.11); a turn that ends on "Nothing happened!" still ends.
### Gates
- `cargo test --release -p flybrain-gb` with `FLY_ROM`: 426 + 27 passed, 0 failed.
- `cargo test --release -p flysim --no-fail-fast` with `FLY_ROM` and the row-60 checkpoint:
all passed but `integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
the known load failure (`total` 1 against 38, or the feed at 14-18 Hz, on a box at load 30),
failing the same two ways on the base.
- `cargo clippy --workspace --all-targets -- -D warnings`: clean.
- `npm test` 663 passed; `npm run typecheck` clean; `infra/tests/lint.sh` ALL CHECKS PASSED.
- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had:
648 bytes, sha256 `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68`
on `main` after the engagement rewards (adapter v7). Decoder, reward catalog, adapter version
and roles untouched.
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`**, byte-identical to the - `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`**, byte-identical to the
base. Decoder, reward catalog, adapter version and roles untouched. base. Decoder, reward catalog, adapter version and roles untouched.
## 2026-09-23, row 59: the road to Mt. Moon, and Route 4 in two
### What was coming, and what came
Opened pre-emptively. The row-58 review carried the route survey past the Boulder Badge (v0.5.5's fix):
from about frame 68,000, Pewter City (39, 17) and Route 3 (0, 9) in a ring, `GO OBJECTIVE` done
on Route 3 538 times, `GO ROUTE` done on Pewter 537. Route 3's pad was `GO ROUTE` alone, because
`GO OBJECTIVE` had nothing on Route 3 to aim at. The live fly was due there with the badge.
It got through Route 3 and met the other half on v0.6.0 at 22:20 UTC: rank 12 (MT. MOON), map 15,
per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103, five distinct macros, two new
tiles; the coordinator restarted flysim. The live checkpoint from inside the ring is v7. From it the
route survey on `main` (`4d82f7d`, 72,000 frames) walks between Route 4 and the Pokécenter (map
68): 930 map changes, `GO OBJECTIVE` done on Route 4 465 and `GO ROUTE` done on the Pokécenter
462 with `GO OBJECTIVE` preferred; with a uniform choice 706 changes, `GO ROUTE` 350,
`GO OBJECTIVE` 183, `GO OUT` 170, the live shape. That is 59c.
### Reproduction
`examples/scene_probe.rs` gains `FLY_PROBE_SAVE_RANK`: the route survey writes the first safe
overworld frame at a rung as a checkpoint. From the row-58 checkpoint, `FLY_PROBE_CATCH=route
FLY_PROBE_PREFER="GO OBJECTIVE,TALK" FLY_PROBE_FRAMES=120000`, the survey is byte-identical to the
review's and writes rank 11 on frame 19,620, in the gym beside BROCK
(`.local/checkpoints/survey-rank11-row59.checkpoint`, untracked). From that checkpoint, with empty
ledgers, the base walks the ring again: `GO OBJECTIVE` done on Route 3 509, `GO ROUTE` done on
Pewter 508, never on Route 4.
### The audit: every row of the map graph against the disassembly
`geography.rs` against `data/maps/headers/*.asm` and `data/maps/objects/*.asm` at `0cd19d3`, all
outdoor rows and every `LINKS` pair; then every map on the graph flooded tile by tile (blocks,
blockset, collision list, `TilePairCollisionsLand`, `LedgeTiles`, lower-left quadrant as section
15 reads it) for pieces, and every connection for a strip that lands on land.
| # | trap | trigger | test | fix, or why it is left |
| --- | --- | --- | --- | --- |
| 59 | Route 3 / Route 4 in the wrong columns (`[-,-,PEWTER,ROUTE_4]` / `[-,-,ROUTE_3,CERULEAN]`; the headers say Route 4 is **north** of Route 3), and Mt. Moon 1F linked to **Route 3**, which has no warps; B1F's exit to Route 4 missing | any objective past Pewter: Route 3's north edge named no map, so it was nobody's hop | `the_rows_the_disassembly_corrected_say_what_its_headers_say`, `the_road_to_mt_moon_is_not_a_ring_at_the_pewter_end_from_the_badge_checkpoint` (ROM) | **fixed**: rows as `Route3.asm` / `Route4.asm`; `(MT_MOON_1F, ROUTE_4)`, `(MT_MOON_B1F, ROUTE_4)` per `Route4.asm`'s warps (18, 5) and (24, 5) and B1F's (27, 3) |
| 59b | Route 14 / 15 and Route 24 / 25 in the wrong columns (south/north for west/east) | Nugget Bridge's far end (rung 16) named no map; Route 14's west edge likewise | the same unit test | **fixed**: `Route14.asm`, `Route15.asm`, `Route24.asm`, `Route25.asm` |
| 59c | Route 4 is one node, though Mt. Moon cuts it in two: the cave mouth's side (Pokécenter (11, 5), 1F (18, 5), Route 3) and Cerulean's side (B1F's exit (24, 5), Cerulean) | every hop across Route 4 | `route_4s_sides_are_told_apart_by_the_doors_where_the_ground_cannot`, `route_4s_doors_and_sides_are_the_cartridges_from_the_badge_checkpoint` (ROM) | **fixed**: a `SPLIT` row |
| 59d | Mt. Moon B1F and B2F are one node each, though B1F is four chambers of two ladders and B2F three pieces; 1F has three ladders to B1F and only (5, 5) is the road, so a map-level hop sends the fly up dead ends and back | rung 13 (Cerulean) from the first floor | `the_road_from_pewter_to_cerulean_is_through_mt_moon_one_chamber_at_a_time`, `a_door_or_an_edge_lands_in_the_piece_it_opens_onto` | **fixed**: `SPLIT` rows; a piece lists its warps by index, a warp lands in the piece its destination warp is in (`wWarpEntries` byte 2), and the hop is a piece. The fly's own piece is what its walk reaches on the decoded grid, the nearest door where the grid (no ledges) reaches none; flooded over all four maps' ground, exact for every tile |
| 59e | four header connections have no tile where both sides are land -- Pallet / Route 21, Cinnabar / Route 20, Route 20 / 19 (sea), Route 22 / 23 (the League's fence) -- and the graph routed along them: from Pallet the road to Cerulean was by sea | whiting out in Mt. Moon, which the survey's fly did: `GO ROUTE` walked into Pallet's shore every 108 frames | `a_connection_nobody_can_walk_across_is_named_and_is_not_a_road` | **fixed**: `NO_CROSSING`: named, no exit, no hop. Route 22 / 23 go back into the table from the same list |
| 59f | a trainer's challenge closes onto five frames of plain overworld before `StartTrainerBattle` decides the battle; row 58's pending push-back was written on the first of them | Route 3's first trainer: (11, 6), the one gap between the road's west end and the rest of it, walled for the session | `a_challenge_closing_onto_a_few_frames_of_overworld_is_still_a_challenge` | **fixed**: a push-back is a refusal only after thirty frames running of the fly's overworld; a battle inside them drops it |
Measured and not the same kind, so left: Cerulean's south and east (Route 5, Route 9, the trashed
house's back door) are reached from the town only through the trashed house, which is off the
graph, and back over a ledge -- rung 17's road, with the Saffron gates and the Underground Path
behind it. Routes 5-8, 10-12, 15, 16, 18 and 23 are pieces joined by gate buildings off the graph,
and Rock Tunnel 1F is four pieces (rung 20). And many edges have walkable tiles past the strip
that lands on land: a walk aimed at
one is blocked and rests the whole edge for the window. On the road through rung 16 these are
Route 3's (62, 0) and (63, 0), Route 4's (6, 17) and Cerulean's (0, 13) and (25, 0), all beside the
landing tiles a walk from the road reaches first.
### Before and after
Route survey from the rank-11 checkpoint, 120,000 frames, `GO OBJECTIVE` and `TALK` preferred:
| measure | base `7784a9d` | branch |
| --- | ---: | ---: |
| `GO OBJECTIVE` done on Route 3 | **509** | 9 |
| `GO ROUTE` done on Pewter | **508** | 0 |
| Route 4 / Mt. Moon first | never / never | frame 70,356 / 70,707 |
| rung at the end | 11 | **12, MT. MOON** |
| pushed tiles at the end | (11, 6), (14, 6), (14, 9) on Route 3 | none |
From the live Route 4 checkpoint, 72,000 frames, rebased on `main` `4d82f7d` (v7):
| measure | base `4d82f7d` | branch |
| --- | ---: | ---: |
| map changes, `GO OBJECTIVE` preferred / uniform | **930 / 706** | 29 / 50 |
| `GO OBJECTIVE` done on Route 4, preferred / uniform | **465 / 183** | 3 / 2 |
| `GO ROUTE` done on the Pokécenter, preferred / uniform | **462 / 350** | 0 / 0 |
| into Mt. Moon, preferred / uniform | frame 541 / 826, and back out | frame 279 / 1,910 |
| rung at the end | 12 | 12 |
`the_fly_goes_into_mt_moon_from_the_live_route_4_checkpoint`, twenty brain minutes on the stub
rotation: the base crosses Route 4's west doors 56 times, 18 through the Pokécenter's and 38
through the cave's (fails); the branch 13, over three whiteouts and walks back (passes), in the
cave on frame 278.
The same survey from the live rank-11 checkpoint of 2026-09-22 (Pewter, the badge won):
the base stays on Route 3 with five pushed tiles fencing it, rank 11; the branch reaches Route 4 at
frame 118,621 and Mt. Moon at 118,972, rank 12, nothing pushed.
The ROM-gated run, `the_road_to_mt_moon_is_not_a_ring_at_the_pewter_end_from_the_badge_checkpoint`,
on the stub rotation: the base crosses between Pewter City and Route 3 **3,391** times in 80.4 brain
minutes and never stands on Route 4 (fails); the branch crosses 4 times, whites out twice on the
way, and is on Route 4 at frame 202,734 and at Mt. Moon's door at 203,232, 56.7 brain minutes
(passes). `route_4s_doors_and_sides_are_the_cartridges_from_the_badge_checkpoint` reads Route 4's
warp table off the cartridge -- (11, 5) to `$44`, (18, 5) to `$3b`, (24, 5) to `$3c`, the table's
doors in its order -- and the decoded grid puts the fly, arrived from Route 3 at (9, 17), on the cave
mouth's side.
**No trap hunt numbers.** Two 30-brain-minute stub arms were started from the badge checkpoint on
v0.5.5 with the box at load 35-40, had written nothing after two hours, and were stopped when the
live fly reached Route 4 and this row became the live priority; FND-01 has since changed the
hunt's frame, so hunts across the rebase would not compare (`9301e39`). The proof is the route
survey and the three ROM tests, which is a deviation from the ethos check's letter, recorded.
### Gates
- `cargo test --workspace --no-fail-fast` in release with `FLY_ROM`, `FLY_DATASET` and
`FLY_BADGE_CHECKPOINT`: 1,285 passed, 1 failed --
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
the known load-sensitive test (feed at 11.28 Hz with the box at load 35-40); the base fails it
too at the same load (the boot-time `total` assertion).
- `cargo clippy --workspace --all-targets`: **0 warnings**.
- `npm test` 663 passed; `npm run typecheck` clean.
- `infra/tests/lint.sh`: ALL CHECKS PASSED, the de-PII guard included.
- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had:
648 bytes, `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68` on
`4d82f7d` (v7). Decoder, reward catalog, adapter version and roles untouched.
## 2026-09-23, row 61: the forest's south gate, in and out
### What was live
Map 50 (`VIRIDIAN_FOREST_SOUTH_GATE`, `$32`) and Route 2's south half, rung 9, v0.5.5, about 20:45
to 21:05 UTC: `GO OBJECTIVE` into the gate (0.3 s), `GO OUT` back onto Route 2 (0.2 s), `GO WARP`
back out of the forest, `GO OBJECTIVE blocked` in the forest, `GO ROUTE` refused now and then; no
reward and about eight new tiles in twenty minutes. The checkpoint is the fly at (5, 1) in the
gate, under the forest's doorway, objective Pewter City; `next_hop` answers the forest, correctly.
### The survey: what walled the road
The route survey from the checkpoint (`FLY_PROBE_CATCH=route`, uniform choice per hold,
`FLY_PROBE_RNG=7`, 72,000 frames) walks the live ring and never leaves rung 9. Caught at the
refusal (`FLY_PROBE_CATCH_FRAME`, `FLY_PROBE_WHOLE=1`): the forest's only road to the north gate
is the corridor at x = 1-2 from row 22 up to row 0; the Bug Catcher of
`EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2` stands on (2, 18) facing west, and **(1, 18) was in the
pushed ledger**. It was written at frame 16,389: a `GO ITEM` walk stepped onto (1, 18), the trainer
took the joypad, and his text closed onto five frames with no box, no script bit and
`wCurOpponent` zero (`StartTrainerBattle` runs after `DisplayTextID`'s close-down). Row 58's held
entry was decided on the first of them. The "!" bubble before it, about sixty frames before
`wJoyIgnore` is set, reads the same way: about sixty-six free-looking frames per engagement. The pushed ledger has no window, so the north gate had no
road for the session; `GO OBJECTIVE` walked to the nearest reachable tile, (6, 1), a dead end, and
was blocked, and the last tiers walked the fly back to the south gate and Route 2. Every base
survey arm walls (1, 18); only a fly that has to come back up the corridor is trapped by it.
| # | trap | trigger | test | fix, or why it is left |
| --- | --- | --- | --- | --- |
| 61 | the "!" bubble and the frames between a sighted trainer's challenge text and `StartTrainerBattle` (about 66 per engagement) read as the fly's overworld: a pad is dealt, ground recorded, and row 58's held push-back written, walling the tile the trainer fired on for the session | any trainer whose line of sight a walk crosses; Viridian Forest's (1, 18), the one free tile of the corridor to the north gate | `a_trainers_challenge_is_the_cartridges_until_its_battle_is_over`, `the_frames_between_a_trainers_text_and_its_battle_deal_no_pad_and_record_no_ground`, `a_trainers_challenge_does_not_wall_the_road_to_the_forests_north_gate` (ROM) | **fixed** in the macro seam: `state::trainer_engaged` reads `wStatusFlags7` bit 3 (`BIT_TRAINER_BATTLE`, set by `CheckFightingMapTrainers`, cleared at `.battleOccurred`); `PokeState`'s `scene` is `Unknown` on an overworld frame with it set and `scripted` is true. `controllable` and `scene::detect` unchanged; in macros mode `game.scene` reads `unknown` on those frames. `docs/design/macros.md` 12.25 |
| 61b | the south gate deals `GO OUT` (back to Route 2) beside the forest door the objective's road takes | the "a room has to be leavable" tier in a gate whose way on is a passage | -- | **left**: a way back is the fly's choice; with the corridor open no survey stays on it |
### Before and after
The route survey, 72,000 frames (20.1 brain minutes), base `main` vs branch:
| driver | base | branch |
| --- | --- | --- |
| uniform, seed 7 (the live ring) | rung 9, 1,202 tiles, north gate never, (1, 18) walled | **BOULDER BADGE**, 1,647 tiles, north gate f20273, Pewter f23756 |
| uniform, default seed | rung 10, 1,356 | rung 11, 1,476 |
| `GO OBJECTIVE` preferred | rung 11, 1,529 | rung 12, 1,492 |
| `GO OUT`, `GO OBJECTIVE` preferred | rung 11, 1,376 | rung 11, 1,446 |
| uniform, seed 12345 | rung 11, 1,765 | identical |
(1, 18) is walled on every base arm and on no branch arm. The ROM-gated run (the seed-7 driver,
40,000 frames): base walled at frame 16,389, rung 9, **fails**; branch: north gate f20272, Route 2
f21209, Pewter f23755, passes.
The stub-readout trap hunt (30 brain minutes, service frame since FND-01; `main` `4d82f7d` vs
branch): both arms rung 10, 362 distinct tiles, 147 macros all done, 11 of 113 windows flagged.
**The stub does not walk the ring**: it passes the corridor northward and never comes back, so
the only difference is 134 overworld frames the branch reads `Unknown`, the trainer gaps. The
survey and the ROM test are the reproduction.
### Other gates
The north gate's `GO OUT` is toward Pewter (tier 2) and its forest door is withheld; the Route 2
gate, Diglett's Cave's Route 2 house and the Route 22 gate are off the graph (every door `LAST_MAP`
but Diglett's passage). The Route 22 gate is the one building with `LAST_MAP` on both sides of two
different maps (its script sets `wLastMap` by row: under 4 is Route 23); `outdoor_of` cannot name
both and will need a per-door answer when a rung routes through it. The trainer gap is not a gate
fact: every sighted trainer has it.
### Overlap
Row 59 (59f, merged in v0.6.1) found the same five frames on Route 3 and holds the push until
thirty frames of overworld; on main that alone keeps (1, 18) clear. This row is the cartridge-fact
layer under it: the pad is empty through the bubble too, no ground is recorded, and it does not
depend on the gap staying under thirty frames. The ROM test asserts no pad on an overworld frame
with the bit set, which row 59 alone does not meet.
### Gates
On `main` `510727c` (v0.6.1, row 59 merged):
- `cargo test --release -p flybrain-gb` with `FLY_ROM`: 461 passed, 0 failed.
- `rom_macros_mode` with the row-61 and row-59 checkpoints: row 61's test and row 59's three pass.
Row 61's test on the branch with `trainer_engaged` neutered (main's behaviour, row 59's settle
alone): the wall stays clear, and a pad is dealt on 67 of 67 challenge frames -- **fails**; on
the branch 0 of 67 -- passes.
- `cargo clippy --workspace --all-targets -- -D warnings`: clean.
- `npm test` 663 passed; `npm run typecheck` clean; `infra/tests/lint.sh` ALL CHECKS PASSED.
- `flysim --print-compatibility`, raw and macros: 648 bytes, sha256 `8ce67b97...a8f68`, the same
as `main`. Decoder, reward catalog, adapter version and roles untouched.

View file

@ -263,7 +263,7 @@ pct exec $CTID -- /opt/fly/bin/fly-reset-to-milestone $N
# (a) the running release already wrote that checkpoint -> nothing to deploy, skip to 5. # (a) the running release already wrote that checkpoint -> nothing to deploy, skip to 5.
# (b) the new build bumps the ADAPTER VERSION and nothing else -> name the checkpoint's # (b) the new build bumps the ADAPTER VERSION and nothing else -> name the checkpoint's
# adapter so the gate and flysim both migrate instead of refusing: # adapter so the gate and flysim both migrate instead of refusing:
FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 infra/05-deploy.sh <release-env> <release-tarball> FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 infra/05-deploy.sh <release-env> <release-tarball>
# The gate logs "the adapter version is the only difference, and it is named; the run is KEPT # The gate logs "the adapter version is the only difference, and it is named; the run is KEPT
# and migrated", and writes FLY_ACCEPT_ADAPTERS into /etc/fly/fly.env so flysim applies the # and migrated", and writes FLY_ACCEPT_ADAPTERS into /etc/fly/fly.env so flysim applies the
# same rule at restore. Anything else about the string differing is still a refusal. # same rule at restore. Anything else about the string differing is still a refusal.

View file

@ -335,8 +335,9 @@ FLY_FEED_VIA=direct
# two strings AND the new build's adapter declares a migration from that one. A # two strings AND the new build's adapter declares a migration from that one. A
# dataset, kernel, plasticity, emulator or state-format difference is still a # dataset, kernel, plasticity, emulator or state-format difference is still a
# refusal. Set it for the one deploy that needs it and leave it out afterwards; # refusal. Set it for the one deploy that needs it and leave it out afterwards;
# 05-deploy writes it into /etc/fly/fly.env only while it is set. # 05-deploy writes it into /etc/fly/fly.env only while it is set. The v0.6.0
# FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 # deploy (pokered-unique8-v7, the engagement rewards) is the one that needs:
# FLY_ACCEPT_ADAPTERS=pokered-unique8-v6
# --- push mode -------------------------------------------------------------- # --- push mode --------------------------------------------------------------
# local: flypush.service stays disabled, everything else identical to prod. # local: flypush.service stays disabled, everything else identical to prod.

View file

@ -508,6 +508,7 @@ dependencies = [
"jsonschema", "jsonschema",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",

View file

@ -52,7 +52,7 @@ pub fn example_composition() -> LegacyComposition {
byte_length: 1_048_576, byte_length: 1_048_576,
format: "gb-rom".to_owned(), format: "gb-rom".to_owned(),
}, },
adapter: "pokered-unique8-v6".to_owned(), adapter: "pokered-unique8-v7".to_owned(),
symbol_provenance: pokered.to_owned(), symbol_provenance: pokered.to_owned(),
mode: "macros".to_owned(), mode: "macros".to_owned(),
macro_channels: decoder_vector("macros")["macroChannels"] macro_channels: decoder_vector("macros")["macroChannels"]
@ -71,7 +71,7 @@ pub fn example_composition() -> LegacyComposition {
audio_sample_rate: 48_000, audio_sample_rate: 48_000,
}, },
flysim_compatibility: format!( flysim_compatibility: format!(
"{}/pokered-unique8-v6/{}/{}/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:{pokered}/statefmt:199616-x86_64-unknown-linux-gnu", "{}/pokered-unique8-v7/{}/{}/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:{pokered}/statefmt:199616-x86_64-unknown-linux-gnu",
gameboy::KERNEL_VERSION, gameboy::KERNEL_VERSION,
gameboy::FAFB_V783_FINGERPRINT, gameboy::FAFB_V783_FINGERPRINT,
gameboy::PLASTICITY_VERSION, gameboy::PLASTICITY_VERSION,

View file

@ -492,7 +492,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -560,9 +560,9 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"digest": "77d8a88ff7fea51eb29b1ae75fc9cf8c17c184e6a1e5a585e86399f41fd9c9b0", "digest": "44916db0a0846d3338f50d4f24d9d4e0214fa239af7d15e4174213cd93b07e28",
"recipeLines": [ "recipeLines": [
"fly-session/composition-v1", "fly-session/composition-v1",
"session=<sessionId>", "session=<sessionId>",

View file

@ -5202,7 +5202,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5243,7 +5243,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "the profile AssetRef digest is the legacy profile's" "reason": "the profile AssetRef digest is the legacy profile's"
}, },
@ -5267,7 +5267,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "raw", "mode": "raw",
"macroChannels": [ "macroChannels": [
@ -5308,7 +5308,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "raw mode deals no macro channels" "reason": "raw mode deals no macro channels"
}, },
@ -5332,7 +5332,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [] "macroChannels": []
@ -5368,7 +5368,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "macros mode needs its channels" "reason": "macros mode needs its channels"
}, },
@ -5392,7 +5392,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5433,7 +5433,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "the executor extension is pokered-macros-v1" "reason": "the executor extension is pokered-macros-v1"
}, },
@ -5457,7 +5457,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5498,7 +5498,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "exact", "restore": "exact",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "the legacy composition declares legacy-transient-reset" "reason": "the legacy composition declares legacy-transient-reset"
}, },
@ -5522,7 +5522,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5563,7 +5563,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "the rollback policy needs gameboy-slots-v1" "reason": "the rollback policy needs gameboy-slots-v1"
}, },
@ -5587,7 +5587,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5628,7 +5628,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "one Game Boy frame is 8572265625/512 ns, not 1/60 s" "reason": "one Game Boy frame is 8572265625/512 ns, not 1/60 s"
}, },
@ -5652,7 +5652,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5693,7 +5693,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v5/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "the FLYSIM01 string and the declaration describe the same fly" "reason": "the FLYSIM01 string and the declaration describe the same fly"
}, },
@ -5717,7 +5717,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5758,7 +5758,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSESS1", "checkpointFormatOfRecord": "FLYSESS1",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "FLYSIM01 stays until RETIRE-01" "reason": "FLYSIM01 stays until RETIRE-01"
}, },

View file

@ -3337,7 +3337,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -3405,11 +3405,11 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"note": "placeholder ROM digest; real macros-mode decoder digest and channels", "note": "placeholder ROM digest; real macros-mode decoder digest and channels",
"canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"82b6601f0390b742a67eca44ef935b49e16a73315db70f5644f1cbd21f7c8a52\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v6\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[\"macro_go_objective\",\"macro_go_out\",\"macro_go_warp\",\"macro_go_route\",\"macro_go_item\",\"macro_go_npc\",\"macro_go_frontier\",\"macro_go_shop\",\"macro_go_heal\",\"macro_talk\",\"macro_menu\",\"macro_next\",\"macro_yes\",\"macro_no\",\"macro_close\",\"macro_confirm\",\"macro_back\",\"macro_move_1\",\"macro_move_2\",\"macro_move_3\",\"macro_move_4\",\"macro_switch\",\"macro_item\",\"macro_throw_ball\",\"macro_run\",\"macro_buy_potion\",\"macro_buy_ball\",\"macro_buy_antidote\",\"macro_buy_repel\",\"macro_heal\",\"macro_leave\"],\"mode\":\"macros\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}", "canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"82b6601f0390b742a67eca44ef935b49e16a73315db70f5644f1cbd21f7c8a52\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v7\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[\"macro_go_objective\",\"macro_go_out\",\"macro_go_warp\",\"macro_go_route\",\"macro_go_item\",\"macro_go_npc\",\"macro_go_frontier\",\"macro_go_shop\",\"macro_go_heal\",\"macro_talk\",\"macro_menu\",\"macro_next\",\"macro_yes\",\"macro_no\",\"macro_close\",\"macro_confirm\",\"macro_back\",\"macro_move_1\",\"macro_move_2\",\"macro_move_3\",\"macro_move_4\",\"macro_switch\",\"macro_item\",\"macro_throw_ball\",\"macro_run\",\"macro_buy_potion\",\"macro_buy_ball\",\"macro_buy_antidote\",\"macro_buy_repel\",\"macro_heal\",\"macro_leave\"],\"mode\":\"macros\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}",
"digest": "77d8a88ff7fea51eb29b1ae75fc9cf8c17c184e6a1e5a585e86399f41fd9c9b0" "digest": "44916db0a0846d3338f50d4f24d9d4e0214fa239af7d15e4174213cd93b07e28"
}, },
{ {
"name": "an example legacy composition in raw mode", "name": "an example legacy composition in raw mode",
@ -3431,7 +3431,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "raw", "mode": "raw",
"macroChannels": [] "macroChannels": []
@ -3467,11 +3467,11 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"note": "", "note": "",
"canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"6234e4a0363cfe82b8d515943c4f645a3960eda8fa5bf9465009061f80d50812\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v6\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[],\"mode\":\"raw\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}", "canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"6234e4a0363cfe82b8d515943c4f645a3960eda8fa5bf9465009061f80d50812\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v7\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[],\"mode\":\"raw\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}",
"digest": "ac42e702ec6c9b09482b1ee335743ef85fe599a72e2c34a4d32aea67d67d0201" "digest": "568eceeb052ebf4fbd0c10661e227783cecc8ac7fee75ae6ba84335c684bfb7c"
}, },
{ {
"name": "a rank climb: slot saved, then the milestone capture", "name": "a rank climb: slot saved, then the milestone capture",

View file

@ -259,10 +259,10 @@ impl NeuralAgent {
/// scene has put on the pad (`docs/design/macros.md` section 12). A host with no macro group /// scene has put on the pad (`docs/design/macros.md` section 12). A host with no macro group
/// -- every caller that came before it -- passes `None` and decodes exactly as it always did. /// -- every caller that came before it -- passes `None` and decodes exactly as it always did.
/// ///
/// `flysim`'s sim loop does not come through here (it drives the network and the decoder /// `flysim` does not come through here: its frame (`flysim::frame::LegacyFrame`) drives the
/// itself, so that the macro layer can read the emulator between the two), but the bench that /// network and the decoder itself, so that the macro layer can read the emulator between the
/// measures the two arms against each other does, and a bench whose macro group could win a /// two, and it installs a frame and its rewards straight after the frame rather than after the
/// channel the scene never bound would be measuring something the stream cannot do. /// next ticks. Every `flysim` harness runs that frame too.
pub fn tick_bound( pub fn tick_bound(
&mut self, &mut self,
frame: &[u8], frame: &[u8],

View file

@ -16,7 +16,7 @@ ROM bytes -> Emulator::run_frame -> RGBA frame + PCM + WRAM
| --- | --- | | --- | --- |
| `emulator` | Safe wrapper over binjgb: frames, framebuffer, buttons, WRAM, audio, save states, ROM hash | | `emulator` | Safe wrapper over binjgb: frames, framebuffer, buttons, WRAM, audio, save states, ROM hash |
| `adapter` | `GameAdapter`, `RewardEvent`, `ProgressSnapshot`, `MemoryReader`, `adapter_for` | | `adapter` | `GameAdapter`, `RewardEvent`, `ProgressSnapshot`, `MemoryReader`, `adapter_for` |
| `pokemon_red` | The `pokered-unique8-v5` reward adapter, its catalog and its generated symbol table | | `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 | | `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` | | `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 | | `recovery` | Rolling the game back to the ratchet's best safe snapshot |

View file

@ -56,10 +56,10 @@ impl MemoryReader for &mut dyn MemoryReader {
/// One reward payout in one frame. /// One reward payout in one frame.
/// ///
/// `kind` is an adapter-owned interned name (Pokémon: `milestone`, /// `kind` is an adapter-owned interned name (Pokémon: `milestone`,
/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`); it is the /// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`,
/// key the statistics counters and the on-screen ticker group by. Field names /// `talk`, `item`); it is the key the statistics counters and the on-screen ticker group
/// serialize exactly as the prototype's `RewardEvent` did, so a checkpoint /// by. Field names serialize exactly as the prototype's `RewardEvent` did, so a
/// written by either implementation reads in the other. /// checkpoint written by either implementation reads in the other.
#[derive(Debug, Clone, PartialEq, Serialize)] #[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RewardEvent { pub struct RewardEvent {
pub kind: &'static str, pub kind: &'static str,
@ -241,7 +241,7 @@ impl std::error::Error for AdapterError {}
/// A game, as the sim loop sees it. /// A game, as the sim loop sees it.
pub trait GameAdapter: Send { pub trait GameAdapter: Send {
/// Adapter version string, pinned into the checkpoint compatibility string. /// Adapter version string, pinned into the checkpoint compatibility string.
/// Pokémon: `pokered-unique8-v6`. /// Pokémon: `pokered-unique8-v7`.
fn id(&self) -> &'static str; fn id(&self) -> &'static str;
/// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration /// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration

View file

@ -30,7 +30,7 @@ pub const PROTOTYPE_PLASTICITY_VERSION: &str = "fly-kc-mbon-rstdp-v2";
pub struct Compatibility<'a> { pub struct Compatibility<'a> {
/// `kernelVersion(config)` from the neural library. /// `kernelVersion(config)` from the neural library.
pub neural_kernel_version: &'a str, pub neural_kernel_version: &'a str,
/// The adapter's version string, e.g. `pokered-unique8-v6`. /// The adapter's version string, e.g. `pokered-unique8-v7`.
pub adapter: &'a str, pub adapter: &'a str,
/// The dataset's seven SHA-256 digests joined with `:`. /// The dataset's seven SHA-256 digests joined with `:`.
pub dataset_fingerprint: &'a str, pub dataset_fingerprint: &'a str,
@ -78,7 +78,7 @@ const ADAPTER_SEGMENT: usize = 1;
/// The environment variable that opts a deploy into the adapter migration. /// The environment variable that opts a deploy into the adapter migration.
/// ///
/// Read by flysim at restore and by `infra/05-deploy.sh`'s compatibility gate. Comma- or /// Read by flysim at restore and by `infra/05-deploy.sh`'s compatibility gate. Comma- or
/// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v5`. /// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v6`.
pub const ACCEPT_ADAPTERS_ENV: &str = "FLY_ACCEPT_ADAPTERS"; pub const ACCEPT_ADAPTERS_ENV: &str = "FLY_ACCEPT_ADAPTERS";
/// What a build may do with a checkpoint whose compatibility string is not its own. /// What a build may do with a checkpoint whose compatibility string is not its own.
@ -180,7 +180,7 @@ mod tests {
assert_eq!( assert_eq!(
fixture().prototype_string(), fixture().prototype_string(),
concat!( concat!(
"lif-1ms-f64-v2/pokered-unique8-v6/aa:bb:cc:dd:ee:ff:00/", "lif-1ms-f64-v2/pokered-unique8-v7/aa:bb:cc:dd:ee:ff:00/",
"fly-kc-mbon-rstdp-v2/", "fly-kc-mbon-rstdp-v2/",
"binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/", "binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/",
"pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
@ -194,37 +194,37 @@ mod tests {
#[test] #[test]
fn an_identical_string_restores_without_any_opt_in() { fn an_identical_string_restores_without_any_opt_in() {
let current = with_adapter("pokered-unique8-v6"); let current = with_adapter("pokered-unique8-v7");
assert_eq!(decide(&current, &current, &[], &[]), RestoreDecision::Exact); assert_eq!(decide(&current, &current, &[], &[]), RestoreDecision::Exact);
} }
#[test] #[test]
fn a_v5_checkpoint_restores_under_v6_only_with_the_opt_in() { fn a_v6_checkpoint_restores_under_v7_only_with_the_opt_in() {
let old = with_adapter("pokered-unique8-v5"); let old = with_adapter("pokered-unique8-v6");
let new = with_adapter("pokered-unique8-v6"); let new = with_adapter("pokered-unique8-v7");
let migrates = ["pokered-unique8-v5"]; let migrates = ["pokered-unique8-v6"];
assert!(matches!(decide(&old, &new, &migrates, &[]), RestoreDecision::Refuse(_))); assert!(matches!(decide(&old, &new, &migrates, &[]), RestoreDecision::Refuse(_)));
assert_eq!( assert_eq!(
decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v5"))), decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v6"))),
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() } RestoreDecision::MigrateAdapter { from: "pokered-unique8-v6".to_string() }
); );
// And only for a pair the running adapter says it can migrate. // And only for a pair the running adapter says it can migrate.
assert!(matches!( assert!(matches!(
decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v5"))), decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v6"))),
RestoreDecision::Refuse(_) RestoreDecision::Refuse(_)
)); ));
} }
#[test] #[test]
fn nothing_but_the_adapter_segment_may_move() { fn nothing_but_the_adapter_segment_may_move() {
let migrates = ["pokered-unique8-v5"]; let migrates = ["pokered-unique8-v6"];
let accepted = accepted_adapters(Some("pokered-unique8-v5")); let accepted = accepted_adapters(Some("pokered-unique8-v6"));
let new = with_adapter("pokered-unique8-v6"); let new = with_adapter("pokered-unique8-v7");
// A different dataset, with the same adapter bump, is not a migration. // A different dataset, with the same adapter bump, is not a migration.
let other_dataset = Compatibility { let other_dataset = Compatibility {
adapter: "pokered-unique8-v5", adapter: "pokered-unique8-v6",
dataset_fingerprint: "00:11:22:33:44:55:66", dataset_fingerprint: "00:11:22:33:44:55:66",
..fixture() ..fixture()
} }
@ -236,7 +236,7 @@ mod tests {
// Neither is a different kernel, and neither is a string of another shape. // Neither is a different kernel, and neither is a string of another shape.
let other_kernel = let other_kernel =
Compatibility { adapter: "pokered-unique8-v5", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() } Compatibility { adapter: "pokered-unique8-v6", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() }
.string(); .string();
assert!(matches!( assert!(matches!(
decide(&other_kernel, &new, &migrates, &accepted), decide(&other_kernel, &new, &migrates, &accepted),
@ -250,8 +250,8 @@ mod tests {
assert!(accepted_adapters(None).is_empty()); assert!(accepted_adapters(None).is_empty());
assert!(accepted_adapters(Some(" ")).is_empty()); assert!(accepted_adapters(Some(" ")).is_empty());
assert_eq!( assert_eq!(
accepted_adapters(Some("pokered-unique8-v5, pokered-unique8-v4")), accepted_adapters(Some("pokered-unique8-v6, pokered-unique8-v5")),
vec!["pokered-unique8-v5".to_string(), "pokered-unique8-v4".to_string()] vec!["pokered-unique8-v6".to_string(), "pokered-unique8-v5".to_string()]
); );
} }

View file

@ -1,4 +1,4 @@
//! The `pokered-unique8-v6` reward catalog. //! The `pokered-unique8-v7` reward catalog.
//! //!
//! A direct port of the prototype's `src/reward/catalog.ts`, including the //! A direct port of the prototype's `src/reward/catalog.ts`, including the
//! declaration order, which is the order `counts` and `last` serialize in. //! declaration order, which is the order `counts` and `last` serialize in.
@ -21,6 +21,8 @@ pub mod kind {
pub const BADGE: &str = "badge"; pub const BADGE: &str = "badge";
pub const BOUNDARY: &str = "boundary"; pub const BOUNDARY: &str = "boundary";
pub const CATCH: &str = "catch"; pub const CATCH: &str = "catch";
pub const TALK: &str = "talk";
pub const ITEM: &str = "item";
} }
/// What a `catch` of a species this run has already caught pays. /// What a `catch` of a species this run has already caught pays.
@ -44,7 +46,7 @@ pub struct RewardRule {
pub stimulation_ms: u32, pub stimulation_ms: u32,
} }
pub const REWARDS: [RewardRule; 9] = [ pub const REWARDS: [RewardRule; 11] = [
RewardRule { RewardRule {
kind: kind::MILESTONE, kind: kind::MILESTONE,
label: "Story", label: "Story",
@ -125,6 +127,30 @@ pub const REWARDS: [RewardRule; 9] = [
value: 0.30, value: 0.30,
stimulation_ms: 150, stimulation_ms: 150,
}, },
// The operator's decision of 2026-09-23: pay the fly for engaging with what is *inside* a
// building rather than for leaving it (`boundary` pays nothing on an indoor map from v7).
// Both appended, for the reason every rule since `boundary` was: the declaration order is
// the key order `counts` serializes in, and every checkpoint already written carries the
// first nine in this order.
//
// `talk` is one payout per person or sign per map for the lifetime of the ledger, and only
// indoors: the conversation the fly opened by pressing A at it, paid when the box closes.
RewardRule {
kind: kind::TALK,
label: "Talk",
trigger: "Conversation the fly opened indoors; once per map and person or sign",
value: 0.10,
stimulation_ms: 100,
},
// `item` is one payout per item ball or hidden item for the lifetime of the ledger, on any
// map: the cartridge's own "this one has been taken" bit rising.
RewardRule {
kind: kind::ITEM,
label: "Item",
trigger: "Item ball or hidden item picked up; once per item",
value: 0.15,
stimulation_ms: 120,
},
]; ];
/// Position of `kind` in [`REWARDS`], or `None` for an unknown kind. This is /// Position of `kind` in [`REWARDS`], or `None` for an unknown kind. This is
@ -229,12 +255,17 @@ mod tests {
assert_eq!(rule(kind::CATCH).unwrap().value, 0.30); assert_eq!(rule(kind::CATCH).unwrap().value, 0.30);
assert_eq!(CATCH_REPEAT_VALUE, 0.10); assert_eq!(CATCH_REPEAT_VALUE, 0.10);
assert_eq!(rule(kind::CATCH).unwrap().stimulation_ms, 150); assert_eq!(rule(kind::CATCH).unwrap().stimulation_ms, 150);
// Nor these: the operator's engagement rules, `pokered-unique8-v7`.
assert_eq!(rule(kind::TALK).unwrap().value, 0.10);
assert_eq!(rule(kind::TALK).unwrap().stimulation_ms, 100);
assert_eq!(rule(kind::ITEM).unwrap().value, 0.15);
assert_eq!(rule(kind::ITEM).unwrap().stimulation_ms, 120);
assert!(rule("blackout").is_none(), "the catalog has no penalties"); assert!(rule("blackout").is_none(), "the catalog has no penalties");
assert!(REWARDS.iter().all(|rule| rule.value > 0.0)); assert!(REWARDS.iter().all(|rule| rule.value > 0.0));
} }
#[test] #[test]
fn the_catch_rule_is_last_so_the_older_key_order_does_not_move() { fn new_rules_are_appended_so_the_older_key_order_does_not_move() {
let order: Vec<&str> = REWARDS.iter().map(|rule| rule.kind).collect(); let order: Vec<&str> = REWARDS.iter().map(|rule| rule.kind).collect();
assert_eq!( assert_eq!(
order, order,
@ -248,9 +279,11 @@ mod tests {
kind::BADGE, kind::BADGE,
kind::BOUNDARY, kind::BOUNDARY,
kind::CATCH, kind::CATCH,
kind::TALK,
kind::ITEM,
] ]
); );
assert_eq!(index(kind::CATCH), Some(REWARDS.len() - 1)); assert_eq!(index(kind::ITEM), Some(REWARDS.len() - 1));
} }
#[test] #[test]

View file

@ -0,0 +1,423 @@
//! What the engagement rules of `pokered-unique8-v7` read: indoors, a conversation the fly
//! opened, and an item picked up.
//!
//! The operator's decision of 2026-09-23 (`docs/rewards-learning.md`, "Engagement rewards"):
//! pay the fly for engaging with what is inside a building -- `talk` and `item` -- and stop
//! paying `boundary` for walking back out of one. Everything here is a read of game memory
//! after a frame; nothing chooses, biases or presses a button, and nothing is checkpointed.
//! The lifetime ledgers the payouts are keyed into are the adapter's own `seen` set, in
//! [`super::PokemonRedReward`].
use crate::adapter::MemoryReader;
use super::macros::state::{Facing, Player};
use super::state::{self, poke};
use super::symbols::ram;
/// Tileset ids, `constants/tileset_constants.asm` at [`super::symbols::POKERED_COMMIT`]. Only the
/// ones the indoor rule names.
pub mod tileset {
pub const OVERWORLD: u8 = 0;
pub const FOREST: u8 = 3;
pub const UNDERGROUND: u8 = 11;
pub const SHIP_PORT: u8 = 14;
pub const CAVERN: u8 = 17;
pub const PLATEAU: u8 = 23;
/// `DEF NUM_TILESETS EQU const_value`: 24 tilesets, ids 0 to 23.
pub const COUNT: u8 = 24;
}
/// Whether a map with this tileset is **inside a building**, by the cartridge's own two tables.
///
/// - `CheckIfInOutsideMap` (`home/overworld.asm`) is the game's own outdoor test: tileset
/// `OVERWORLD` or `PLATEAU` is "a town or route", and `WarpFound2` labels the other branch
/// `.indoorMaps`. On its own that also calls Viridian Forest and every cave indoor.
/// - `BikeRidingTilesets` (`data/tilesets/bike_riding_tilesets.asm`) is the game's list of places
/// a bicycle may be ridden -- `OVERWORLD`, `FOREST`, `UNDERGROUND`, `SHIP_PORT`, `CAVERN` -- and
/// the bike is the one thing the cartridge refuses *inside a building* by rule.
///
/// Indoor is neither: not outside, and not somewhere the bike is allowed. That is every house,
/// mart, Pokémon Center, gym, gate, lab, museum, the S.S. Anne, Silph Co., the Pokémon Tower, the
/// Mansion, the Rocket Hideout and the Indigo Plateau's rooms -- and *not* Viridian Forest, a
/// cave, the Underground Path or Vermilion's dock, whose exits are how the fly gets anywhere.
/// A tileset id past the table is not indoor: an unreadable map is never a reason to withhold
/// `boundary`.
pub fn indoor(tileset: u8) -> bool {
use tileset::*;
tileset < COUNT
&& !matches!(
tileset,
OVERWORLD | PLATEAU | FOREST | UNDERGROUND | SHIP_PORT | CAVERN
)
}
/// What a conversation was with, as `DisplayTextID` names it: a sprite slot, or a sign's text id.
///
/// The same split the macros' session `talked` ledger uses, but this is not that ledger: the
/// payout is keyed into the adapter's lifetime `seen` set, which is checkpointed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Thing {
Sprite(u8),
Sign(u8),
}
/// A conversation the fly opened and the cartridge has now closed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Conversation {
pub map: u8,
pub thing: Thing,
}
impl Conversation {
/// The `seen` ledger key: one payout per `(map, object)` for the lifetime of the ledger.
pub fn key(&self) -> String {
match self.thing {
Thing::Sprite(slot) => format!("talk:{}:sprite:{slot}", self.map),
Thing::Sign(id) => format!("talk:{}:sign:{id}", self.map),
}
}
pub fn label(&self) -> String {
match self.thing {
Thing::Sprite(slot) => format!("TALKED TO #{slot} IN AREA {}", self.map),
Thing::Sign(id) => format!("READ SIGN #{id} IN AREA {}", self.map),
}
}
}
/// Samples after the box opens by which `DisplayTextID` has certainly written its argument.
///
/// `DisplayTextIDInit` sets the font bit and then loads the font's tiles into VRAM, which takes
/// frames; `DisplayTextID` copies its argument into `wSpriteIndex` only after that. Measured on
/// the cartridge (`tests/rom_engage.rs`, the Viridian Forest north gate): the bit rose on one
/// frame and the argument arrived **twenty frames** later. Until then the byte still holds
/// whatever the *last* text was about -- which may well be the person in front of the fly, from
/// a conversation that did not pay -- so it is not read as this conversation's argument until it
/// has changed, or until this many samples have gone by, after which an unchanged byte means the
/// new text is about the same thing as the last one. More than twice the measured delay.
const ARGUMENT_SETTLED: u8 = 45;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Armed {
map: u8,
x: u32,
y: u32,
/// The fly had the joypad and was standing still: [`state::controllable`] and a zero
/// `wWalkCounter`. The overworld only reads an A press in that state.
ready: bool,
/// `wSpriteIndex` before the box opened: the previous text's argument.
stale: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Opening {
map: u8,
samples: u8,
stale: u8,
/// The bottom dialogue box has been on screen during this opening.
dialogue: bool,
}
/// The `talk` rule's per-frame watch. Transient: a restore or a rollback clears it, so a
/// conversation in flight at a checkpoint pays nothing, which is the conservative answer.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TalkWatch {
armed: Option<Armed>,
opening: Option<Opening>,
pending: Option<Conversation>,
}
impl TalkWatch {
pub fn clear(&mut self) {
*self = Self::default();
}
/// A sample that is not the overworld -- a battle. Whatever the fly was doing before it is
/// not what opens the next text box, so the arming is dropped; a conversation already open
/// (a trainer the fly spoke to) stays pending and pays when its box is finally closed.
pub fn interrupt(&mut self) {
self.armed = None;
self.opening = None;
}
/// One overworld sample (`wIsInBattle` zero, every playability gate passed).
///
/// A conversation pays when all of this holds, each read out of WRAM:
///
/// 1. **The fly started it.** On the last sample before the text box opened
/// (`wFontLoaded` bit 0 rising) the fly had the joypad -- no ignored buttons, no simulated
/// input, no scripted movement ([`state::controllable`]) -- was standing still
/// (`wWalkCounter` zero, which is the only state the overworld reads A in) and stood on the
/// tile it is on now. Most script text opens with the joypad already taken, and is not
/// `ready`; a map script that runs the frame after a step ends can open text while the fly
/// is still `ready`, so it pays only if rule 2 names the thing in front (none in the early
/// game; the Fighting Dojo master and the Elite Four once each).
/// 2. **It is with the thing in front of the fly.** `DisplayTextID` copies its argument into
/// `wSpriteIndex` once the font is loaded ([`ARGUMENT_SETTLED`] has the timing, and why
/// the byte is read only once it has changed or settled), in the bottom dialogue box --
/// the start menu is drawn elsewhere. A value up to `wNumSprites` is a sprite slot, and that sprite must stand
/// on the tile the player faces -- or one further, across a counter, on a tileset that has
/// counter tiles (`IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter`). A larger
/// value is a text id, and it must be the text id of the sign on the tile the player faces.
/// An item ball is a sprite but not a person: it pays `item`, not `talk`.
/// 3. **Indoors**, by [`indoor`], on the map the box opened on.
/// 4. **It finished.** The box closed again on the same map. A conversation that ends in a
/// warp, a restore or a blackout pays nothing.
///
/// Returns the conversation on the sample the box closes; the caller pays it once per key.
pub fn observe(
&mut self,
memory: &mut dyn MemoryReader,
map: u8,
x: u32,
y: u32,
) -> Option<Conversation> {
let open = memory.read8(ram::wFontLoaded) & poke::BIT_FONT_LOADED != 0;
if !open {
// A box that closes before its argument was ever seen to change: a short text about
// the same thing as the last one. The argument was written before a letter printed,
// so it is this text's; the dialogue box must have been drawn for it.
let argument = memory.read8(ram::wSpriteIndex);
if let Some(opening) = self.opening.take()
&& opening.dialogue
&& opening.map == map
&& let Some(thing) = thing_named(memory, argument)
{
self.pending = Some(Conversation { map, thing });
}
let finished = self
.pending
.take()
.filter(|conversation| conversation.map == map);
let ready = state::controllable(memory) && memory.read8(ram::wWalkCounter) == 0;
let stale = argument;
self.armed = Some(Armed {
map,
x,
y,
ready,
stale,
});
return finished;
}
if let Some(armed) = self.armed.take()
&& armed.ready
&& armed.map == map
&& armed.x == x
&& armed.y == y
&& self.pending.is_none()
&& indoor(memory.read8(ram::wCurMapTileset))
{
self.opening = Some(Opening {
map,
samples: 0,
stale: armed.stale,
dialogue: false,
});
}
if let Some(opening) = self.opening.as_mut() {
opening.samples += 1;
opening.dialogue |= state::text_box(memory).waiting;
let map = opening.map;
let argument = memory.read8(ram::wSpriteIndex);
if argument != opening.stale || opening.samples >= ARGUMENT_SETTLED {
// One reading, whichever way it goes: the argument this text was opened with,
// and only while the dialogue box is what is drawn (not the start menu's).
let dialogue = state::text_box(memory).waiting;
self.opening = None;
if dialogue && let Some(thing) = thing_named(memory, argument) {
self.pending = Some(Conversation { map, thing });
}
}
}
None
}
}
/// The step `facing` points at from `(x, y)`, or `None` off the top or left of the map.
fn ahead(x: u8, y: u8, facing: Facing) -> Option<(u8, u8)> {
let (dx, dy) = facing.delta();
let x = u8::try_from(i16::from(x) + dx).ok()?;
let y = u8::try_from(i16::from(y) + dy).ok()?;
Some((x, y))
}
/// `wMapSpriteExtraData`'s two bytes for a sprite slot (1-based, as `hSpriteIndex` is).
fn extra_data(memory: &mut dyn MemoryReader, slot: u8) -> (u8, u8) {
let entry = ram::wMapSpriteExtraData + (u16::from(slot) - 1) * 2;
(memory.read8(entry), memory.read8(entry + 1))
}
/// Whether sprite `slot` is an item ball: `LoadMapHeader` writes `(item id, 0)` into its extra
/// data for an `ITEM`-flagged `object_event`, `(trainer class, trainer number)` for a `TRAINER`
/// one -- trainer numbers start at 1 -- and two zeroes for everything else.
fn is_item_ball(memory: &mut dyn MemoryReader, slot: u8) -> bool {
let (item, second) = extra_data(memory, slot);
item != 0 && second == 0
}
/// `DisplayTextID`'s argument, if it names something the player is facing.
///
/// The caller also asks for the bottom dialogue box ([`state::text_box`]'s `waiting`):
/// `DisplayTextIDInit` draws it for every text id but the start menu's, which it draws at the top
/// right instead.
fn thing_named(memory: &mut dyn MemoryReader, argument: u8) -> Option<Thing> {
if argument == 0 {
// TEXT_START_MENU.
return None;
}
let player: Player = state::player(memory)?;
let one = ahead(player.x, player.y, player.facing)?;
let sprites = memory.read8(ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
if argument <= sprites {
let npc = state::npcs(memory)
.into_iter()
.find(|npc| npc.slot == argument)?;
let at = (npc.x, npc.y);
let reached = at == one
|| (ahead(one.0, one.1, player.facing) == Some(at)
&& state::counter_tiles(memory)
.iter()
.any(|tile| *tile != poke::NO_COUNTER_TILE));
if reached && !is_item_ball(memory, argument) {
return Some(Thing::Sprite(argument));
}
return None;
}
state::signs(memory)
.into_iter()
.any(|sign| sign.text_id == argument && (sign.x, sign.y) == one)
.then_some(Thing::Sign(argument))
}
/// `wToggleableObjectFlags` is `flag_array $100`.
const TOGGLE_BYTES: usize = 32;
/// `wObtainedHiddenItemsFlags` is `flag_array MAX_HIDDEN_ITEMS`, and `MAX_HIDDEN_ITEMS` is 112
/// (`constants/item_constants.asm`).
const HIDDEN_ITEM_BYTES: usize = 14;
/// `wToggleableObjectList` is `ds 16 * 2 + 1`: sixteen `(sprite slot, global index)` pairs and a
/// `$ff` terminator.
const TOGGLE_LIST_ENTRIES: u16 = 16;
/// The two item balls a script *reveals*: `TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4` (`$87`, the Silph
/// Scope) and `TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5` (`$88`, the Lift Key), the only `ITEM`
/// `object_event`s `data/maps/toggleable_objects.asm` starts `OFF`, and the only item entries
/// `constants/toggle_constants.asm` does not mark "X, never toggled by a script".
///
/// Every other item ball's bit is clear from a new game until `PickUpItem` sets it, so a set bit
/// is a pickup. These two are set from the start and cleared when Giovanni's defeat shows them,
/// so seeding them as "already taken" would withhold two payouts for ever. They are the only
/// bits the seed leaves out.
pub const SCRIPT_SHOWN_ITEM_BALLS: [u8; 2] = [0x87, 0x88];
/// The cartridge's two "this item has been taken" bitsets, as of one sample.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemFlags {
toggles: [u8; TOGGLE_BYTES],
hidden: [u8; HIDDEN_ITEM_BYTES],
}
fn bit(bytes: &[u8], index: usize) -> bool {
bytes
.get(index / 8)
.is_some_and(|byte| byte & (1 << (index % 8)) != 0)
}
impl ItemFlags {
pub fn read(memory: &mut dyn MemoryReader) -> Self {
let mut toggles = [0; TOGGLE_BYTES];
for (offset, byte) in toggles.iter_mut().enumerate() {
*byte = memory.read8(ram::wToggleableObjectFlags + offset as u16);
}
let mut hidden = [0; HIDDEN_ITEM_BYTES];
for (offset, byte) in hidden.iter_mut().enumerate() {
*byte = memory.read8(ram::wObtainedHiddenItemsFlags + offset as u16);
}
Self { toggles, hidden }
}
/// Ledger keys for every item this state already shows as taken: the seed that stops a
/// pickup made before the rule existed from paying after a rollback un-takes it.
///
/// Every set toggle bit but [`SCRIPT_SHOWN_ITEM_BALLS`] -- which includes the bits of people
/// a script has hidden, harmlessly, because only an item ball's key is ever looked up -- and
/// every set hidden-item bit.
pub fn seed(&self) -> Vec<String> {
let mut keys = Vec::new();
for index in 0..TOGGLE_BYTES * 8 {
if bit(&self.toggles, index) && !SCRIPT_SHOWN_ITEM_BALLS.contains(&(index as u8)) {
keys.push(ball_key(index as u8));
}
}
for index in 0..HIDDEN_ITEM_BYTES * 8 {
if bit(&self.hidden, index) {
keys.push(hidden_key(index as u8));
}
}
keys
}
}
/// One item the fly has just picked up.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pickup {
pub key: String,
pub label: String,
}
pub fn ball_key(global: u8) -> String {
format!("item:{global}")
}
pub fn hidden_key(index: u8) -> String {
format!("hidden:{index}")
}
/// Items whose "taken" bit rose between `before` and `now`.
///
/// - **An item ball** is one of this map's toggleable sprites (`wToggleableObjectList`) whose
/// extra data says item ([`is_item_ball`]). `PickUpItem` sets its global bit in
/// `wToggleableObjectFlags` through `HideObject`, and only after `GiveItem` succeeded, so a full
/// bag pays nothing. A toggleable that is not an item -- a person a script hides, a legendary
/// after its battle -- is never looked at.
/// - **A hidden item** is a bit of `wObtainedHiddenItemsFlags`, which `FoundHiddenItemText` sets
/// after `GiveItem` succeeded and nothing else in the game writes. Hidden coins have a bitset of
/// their own and are not items.
///
/// A bit that was already set on the previous sample is not a pickup, which is what keeps a
/// restored or seeded state from paying for anything it already holds.
pub fn pickups(memory: &mut dyn MemoryReader, before: &ItemFlags, now: &ItemFlags) -> Vec<Pickup> {
let mut out = Vec::new();
if now == before {
// The overwhelmingly common frame: nothing was taken, and nothing more need be read.
return out;
}
let sprites = memory.read8(ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
for entry in 0..TOGGLE_LIST_ENTRIES {
let slot = memory.read8(ram::wToggleableObjectList + entry * 2);
if slot == 0xff {
break;
}
let global = memory.read8(ram::wToggleableObjectList + entry * 2 + 1);
let index = usize::from(global);
if slot == 0 || slot > sprites || !bit(&now.toggles, index) || bit(&before.toggles, index) {
continue;
}
let (item, second) = extra_data(memory, slot);
if item != 0 && second == 0 {
out.push(Pickup {
key: ball_key(global),
label: format!("FOUND ITEM #{item}"),
});
}
}
for index in 0..HIDDEN_ITEM_BYTES * 8 {
if bit(&now.hidden, index) && !bit(&before.hidden, index) {
out.push(Pickup {
key: hidden_key(index as u8),
label: "FOUND A HIDDEN ITEM".to_string(),
});
}
}
out
}

View file

@ -416,6 +416,38 @@ impl Wram {
pub const BLOCKSET_BANK: u8 = 0x11; pub const BLOCKSET_BANK: u8 = 0x11;
pub const BLOCKSET_BASE: u16 = 0x4000; pub const BLOCKSET_BASE: u16 = 0x4000;
/// Rows of the cartridge's move table, where `data/moves/moves.asm` puts it: `$0E:$4000`,
/// six bytes a row in move-id order, each opening with its own id. `(id, effect, power,
/// type)`; accuracy 100 and PP 35 stand in for the two bytes nothing here reads.
pub fn move_table(&mut self, rows: &[(u8, u8, u8, u8)]) -> &mut Self {
use super::state::poke::moves::{ROW_BYTES, TABLE_ADDRESS, TABLE_BANK};
for (id, effect, power, kind) in rows {
let base = TABLE_ADDRESS + u16::from(id - 1) * ROW_BYTES;
for (offset, byte) in [*id, *effect, *power, *kind, 0xff, 35].into_iter().enumerate() {
self.rom.insert((TABLE_BANK, base + offset as u16), byte);
}
}
self
}
/// One byte of a fake cartridge bank.
pub fn rom_byte(&mut self, bank: u8, address: u16, byte: u8) -> &mut Self {
self.rom.insert((bank, address), byte);
self
}
/// Every stage of both battlers at normal (7), as `InitBattleVariables`-era code leaves them.
pub fn normal_stages(&mut self) -> &mut Self {
for stat in 0..6 {
self.set(ram::wPlayerMonStatMods + stat, 7).set(ram::wEnemyMonStatMods + stat, 7);
}
for stat in 0..4 {
self.set_word_be(ram::wBattleMonAttack + 2 * stat, 12)
.set_word_be(ram::wEnemyMonAttack + 2 * stat, 9);
}
self
}
/// Which tileset the loaded map uses, for the tile-pair collision lists. /// Which tileset the loaded map uses, for the tile-pair collision lists.
pub fn tileset(&mut self, id: u8) -> &mut Self { pub fn tileset(&mut self, id: u8) -> &mut Self {
self.set(ram::wCurMapTileset, id) self.set(ram::wCurMapTileset, id)

View file

@ -377,6 +377,17 @@ pub trait MacroState: GameState {
false false
} }
/// Whether the battle engine will answer the fly's move `id` with nothing on this frame: a
/// stat stage already at its limit, a status move against a target it cannot affect.
///
/// Row 60 (`docs/design/macros.md` 12.23), read from the cartridge's own move table and the
/// bytes its effect routines test ([`crate::pokemon_red::state::move_without_effect`]). The
/// default is `false`: a state that cannot read the table has proved nothing, so the `MOVE n`
/// buttons stay where they were.
fn move_without_effect(&mut self, _id: u8) -> bool {
false
}
/// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable. /// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable.
/// ///
/// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state /// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state

View file

@ -349,10 +349,9 @@ impl MacroPalette for PokemonPalette {
// How far the objective is, over the same map graph `GO OBJECTIVE` walks (section // How far the objective is, over the same map graph `GO OBJECTIVE` walks (section
// 12.15). Read from the same frame and the same state everything else is, and only // 12.15). Read from the same frame and the same state everything else is, and only
// where the fly is its own master, for the same reason the ground is. // where the fly is its own master, for the same reason the ground is.
let approach = standing.and_then(|player| { let approach = standing.and_then(|_| {
let objective = palette::objective_place(&mut state)?; let objective = palette::objective_place(&mut state)?;
let hops = let hops = geography::hops(palette::region_here(&mut state)?, objective.map)?;
geography::hops(geography::region_at(player.map, player.y), objective.map)?;
Some((objective.map, hops)) Some((objective.map, hops))
}); });
*cached = Some(palette); *cached = Some(palette);
@ -617,6 +616,28 @@ mod tests {
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Unknown); assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Unknown);
} }
#[test]
fn the_frames_between_a_trainers_text_and_its_battle_deal_no_pad_and_record_no_ground() {
// Row 61, Viridian Forest. A trainer who saw the fly: its "!" bubble and the five frames
// after its challenge text read as an ordinary overworld -- no box, no script bit,
// `wJoyIgnore` and `wCurOpponent` zero. The pad was dealt there and the push-back the
// fly's walk had earned when the trainer took the joypad was written there, walling the
// one free tile of the corridor to the north gate for the session.
let mut wram = Wram::overworld();
wram.set(crate::pokemon_red::symbols::ram::wStatusFlags7, 1 << 3);
let mut palette = PokemonPalette::new(7);
let engaged = palette.observe(&mut wram, &NoLedger);
assert_eq!(engaged.scene, SceneId::Unknown, "the cartridge's, inside the challenge");
assert!(engaged.bindings.is_empty(), "nothing to press: {:?}", engaged.bindings);
assert_eq!(palette.stood(), 0, "and no ground recorded from it");
// `.battleOccurred` clears the bit and the overworld is the fly's again.
wram.set(crate::pokemon_red::symbols::ram::wStatusFlags7, 0);
let own = palette.observe(&mut wram, &NoLedger);
assert_eq!(own.scene, SceneId::Overworld);
assert_eq!(palette.stood(), 1);
}
#[test] #[test]
fn a_teleport_pad_is_not_a_tear() { fn a_teleport_pad_is_not_a_tear() {
// Saffron Gym and two Silph Co. floors warp to themselves. Standing on a pad whose // Saffron Gym and two Silph Co. floors warp to themselves. Standing on a pad whose

View file

@ -149,6 +149,12 @@ const ANSWER_REOPEN_FRAMES: u32 = 24;
/// animation is about three. /// animation is about three.
const HEAL_WAIT_FRAMES: u32 = 360; const HEAL_WAIT_FRAMES: u32 = 360;
/// Frames running the overworld has to be the fly's before a push-back is written as a refusal
/// ([`MacroMachine::observe_push`], row 59). A trainer's challenge text closes onto five frames of
/// overworld before the battle is decided; six times that is still half a second, and a refusal
/// the cartridge really made loses nothing by being written half a second late.
pub const PUSH_SETTLE_FRAMES: u32 = 30;
/// How a macro ended, i.e. the `outcome` field of the `macro` feed event (section 5: "outcome = /// How a macro ended, i.e. the `outcome` field of the `macro` feed event (section 5: "outcome =
/// done/blocked/timeout/refused"). /// done/blocked/timeout/refused").
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -662,6 +668,9 @@ pub struct MacroMachine {
/// the overworld is a refusal and is written as one; a battle is a battle, and nothing about /// the overworld is a refusal and is written as one; a battle is a battle, and nothing about
/// the target or the ground is learned from it. /// the target or the ground is learned from it.
pending_push: Vec<PendingPush>, pending_push: Vec<PendingPush>,
/// How many frames running the overworld has been the fly's while [`Self::pending_push`]
/// waits: the push-back is decided at [`PUSH_SETTLE_FRAMES`] (row 59).
pending_push_calm: u32,
/// A finished `TALK`'s target, waiting to be taken into the session's talked ledger. /// A finished `TALK`'s target, waiting to be taken into the session's talked ledger.
/// ///
/// The machine records rather than keeps: the ledger is the driver's /// The machine records rather than keeps: the ledger is the driver's
@ -696,6 +705,7 @@ impl MacroMachine {
pending_talk: None, pending_talk: None,
pending_answer: None, pending_answer: None,
pending_push: Vec::new(), pending_push: Vec::new(),
pending_push_calm: 0,
talked: None, talked: None,
rng: if seed == 0 { 1 } else { seed }, rng: if seed == 0 { 1 } else { seed },
} }
@ -979,6 +989,7 @@ impl MacroMachine {
self.pending_answer = None; self.pending_answer = None;
// Nor the cartridge refusing a step: the frames it happened in are being thrown away too. // Nor the cartridge refusing a step: the frames it happened in are being thrown away too.
self.pending_push.clear(); self.pending_push.clear();
self.pending_push_calm = 0;
} }
/// Whether the fly is standing somewhere other than where the running macro began. /// Whether the fly is standing somewhere other than where the running macro began.
@ -1036,17 +1047,32 @@ impl MacroMachine {
/// One frame after the cartridge took the joypad from a macro: decide what it was (row 58). /// One frame after the cartridge took the joypad from a macro: decide what it was (row 58).
/// ///
/// Back in the overworld with the buttons the fly's again: a refusal, written exactly as /// Back in the overworld with the buttons the fly's again, for [`PUSH_SETTLE_FRAMES`] running:
/// section 12.4 and row 37 always wrote it. A battle: a trainer's challenge, and it teaches the /// a refusal, written exactly as section 12.4 and row 37 always wrote it. A battle: a
/// ledgers nothing. Anything else -- the text, the walk, the frames between -- is still the /// trainer's challenge, and it teaches the ledgers nothing. Anything else -- the text, the
/// cartridge's, and the decision waits. /// walk, the frames between -- is still the cartridge's, and the decision waits.
///
/// The window is row 59's. A trainer's challenge text closes onto five frames of an ordinary
/// overworld -- no text, no script, no joypad bit, `wCurOpponent` still clear -- before
/// `StartTrainerBattle` runs (`home/trainers.asm`: it follows `DisplayTextID`, whose
/// close-down redraws the map first). Decided on the first of them, Route 3's first trainer
/// walled (11, 6), the one gap between the road's west end and the rest of it, for the
/// session, and the fly walked between Pewter City and that end for hours.
fn observe_push(&mut self, state: &mut dyn MacroState) { fn observe_push(&mut self, state: &mut dyn MacroState) {
if self.pending_push.is_empty() { if self.pending_push.is_empty() {
self.pending_push_calm = 0;
return; return;
} }
match class(state.scene()) { match class(state.scene()) {
Class::Battle | Class::ForcedSwitch => self.pending_push.clear(), Class::Battle | Class::ForcedSwitch => {
self.pending_push.clear();
self.pending_push_calm = 0;
}
Class::Overworld if self.pending_push_calm + 1 < PUSH_SETTLE_FRAMES => {
self.pending_push_calm += 1;
}
Class::Overworld => { Class::Overworld => {
self.pending_push_calm = 0;
// Every macro the script ended while it held the joypad -- the walk it interrupted // Every macro the script ended while it held the joypad -- the walk it interrupted
// and any press made into its text -- in the order they ended. // and any press made into its text -- in the order they ended.
for pending in std::mem::take(&mut self.pending_push) { for pending in std::mem::take(&mut self.pending_push) {
@ -1058,7 +1084,7 @@ impl MacroMachine {
} }
} }
} }
_ => {} _ => self.pending_push_calm = 0,
} }
} }

View file

@ -28,6 +28,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
use super::super::maps; use super::super::maps;
use super::cartridge::Edge; use super::cartridge::Edge;
use super::state::MapGrid;
/// A column with no connection on it. /// A column with no connection on it.
const NONE: u8 = 0xff; const NONE: u8 = 0xff;
@ -44,10 +45,13 @@ const EAST: usize = 3;
/// `wCurMapConnections`' four bits are loaded from. Kanto's overworld is one grid, so the table is /// `wCurMapConnections`' four bits are loaded from. Kanto's overworld is one grid, so the table is
/// symmetric by construction and [`neighbours`] does not rely on that — it reads both directions. /// symmetric by construction and [`neighbours`] does not rely on that — it reads both directions.
/// ///
/// Two rows are worth a note. `ROUTE_3` and `ROUTE_4` are connected along the east-west axis even /// Every row is the header's own, checked line by line against the disassembly at the pinned
/// though Mt. Moon stands between them, so the walkable path is the cave and not the edge; that /// commit (row 59). Four pairs had the right neighbour in the wrong column, and a wrong column is
/// costs nothing, because an edge whose tiles are not walkable produces no exit at all /// a wrong map on the other side of an edge: `ROUTE_3` / `ROUTE_4` (Route 4 is north of Route 3,
/// ([`super::path::exits`] filters on the walkable predicate) and the cave is in [`LINKS`]. /// not east, and Route 3's top edge is the road to Mt. Moon's Pokécenter), `ROUTE_14` /
/// `ROUTE_15` and `ROUTE_24` / `ROUTE_25` (west and east, not south and north), and `ROUTE_22` /
/// `ROUTE_23`, which the table had left out. A connection nobody can walk across is still the
/// header's, and [`NO_CROSSING`] says which.
const CONNECTIONS: &[(u8, [u8; 4])] = &[ const CONNECTIONS: &[(u8, [u8; 4])] = &[
(maps::PALLET_TOWN, [maps::ROUTE_1, maps::ROUTE_21, NONE, NONE]), (maps::PALLET_TOWN, [maps::ROUTE_1, maps::ROUTE_21, NONE, NONE]),
(maps::VIRIDIAN_CITY, [maps::ROUTE_2, maps::ROUTE_1, maps::ROUTE_22, NONE]), (maps::VIRIDIAN_CITY, [maps::ROUTE_2, maps::ROUTE_1, maps::ROUTE_22, NONE]),
@ -62,8 +66,8 @@ const CONNECTIONS: &[(u8, [u8; 4])] = &[
(maps::SAFFRON_CITY, [maps::ROUTE_5, maps::ROUTE_6, maps::ROUTE_7, maps::ROUTE_8]), (maps::SAFFRON_CITY, [maps::ROUTE_5, maps::ROUTE_6, maps::ROUTE_7, maps::ROUTE_8]),
(maps::ROUTE_1, [maps::VIRIDIAN_CITY, maps::PALLET_TOWN, NONE, NONE]), (maps::ROUTE_1, [maps::VIRIDIAN_CITY, maps::PALLET_TOWN, NONE, NONE]),
(maps::ROUTE_2, [maps::PEWTER_CITY, maps::VIRIDIAN_CITY, NONE, NONE]), (maps::ROUTE_2, [maps::PEWTER_CITY, maps::VIRIDIAN_CITY, NONE, NONE]),
(maps::ROUTE_3, [NONE, NONE, maps::PEWTER_CITY, maps::ROUTE_4]), (maps::ROUTE_3, [maps::ROUTE_4, NONE, maps::PEWTER_CITY, NONE]),
(maps::ROUTE_4, [NONE, NONE, maps::ROUTE_3, maps::CERULEAN_CITY]), (maps::ROUTE_4, [NONE, maps::ROUTE_3, NONE, maps::CERULEAN_CITY]),
(maps::ROUTE_5, [maps::CERULEAN_CITY, maps::SAFFRON_CITY, NONE, NONE]), (maps::ROUTE_5, [maps::CERULEAN_CITY, maps::SAFFRON_CITY, NONE, NONE]),
(maps::ROUTE_6, [maps::SAFFRON_CITY, maps::VERMILION_CITY, NONE, NONE]), (maps::ROUTE_6, [maps::SAFFRON_CITY, maps::VERMILION_CITY, NONE, NONE]),
(maps::ROUTE_7, [NONE, NONE, maps::CELADON_CITY, maps::SAFFRON_CITY]), (maps::ROUTE_7, [NONE, NONE, maps::CELADON_CITY, maps::SAFFRON_CITY]),
@ -73,28 +77,54 @@ const CONNECTIONS: &[(u8, [u8; 4])] = &[
(maps::ROUTE_11, [NONE, NONE, maps::VERMILION_CITY, maps::ROUTE_12]), (maps::ROUTE_11, [NONE, NONE, maps::VERMILION_CITY, maps::ROUTE_12]),
(maps::ROUTE_12, [maps::LAVENDER_TOWN, maps::ROUTE_13, maps::ROUTE_11, NONE]), (maps::ROUTE_12, [maps::LAVENDER_TOWN, maps::ROUTE_13, maps::ROUTE_11, NONE]),
(maps::ROUTE_13, [maps::ROUTE_12, NONE, maps::ROUTE_14, NONE]), (maps::ROUTE_13, [maps::ROUTE_12, NONE, maps::ROUTE_14, NONE]),
(maps::ROUTE_14, [NONE, maps::ROUTE_15, NONE, maps::ROUTE_13]), (maps::ROUTE_14, [NONE, NONE, maps::ROUTE_15, maps::ROUTE_13]),
(maps::ROUTE_15, [maps::ROUTE_14, NONE, maps::FUCHSIA_CITY, NONE]), (maps::ROUTE_15, [NONE, NONE, maps::FUCHSIA_CITY, maps::ROUTE_14]),
(maps::ROUTE_16, [NONE, maps::ROUTE_17, NONE, maps::CELADON_CITY]), (maps::ROUTE_16, [NONE, maps::ROUTE_17, NONE, maps::CELADON_CITY]),
(maps::ROUTE_17, [maps::ROUTE_16, maps::ROUTE_18, NONE, NONE]), (maps::ROUTE_17, [maps::ROUTE_16, maps::ROUTE_18, NONE, NONE]),
(maps::ROUTE_18, [maps::ROUTE_17, NONE, NONE, maps::FUCHSIA_CITY]), (maps::ROUTE_18, [maps::ROUTE_17, NONE, NONE, maps::FUCHSIA_CITY]),
(maps::ROUTE_19, [maps::FUCHSIA_CITY, NONE, maps::ROUTE_20, NONE]), (maps::ROUTE_19, [maps::FUCHSIA_CITY, NONE, maps::ROUTE_20, NONE]),
(maps::ROUTE_20, [NONE, NONE, maps::CINNABAR_ISLAND, maps::ROUTE_19]), (maps::ROUTE_20, [NONE, NONE, maps::CINNABAR_ISLAND, maps::ROUTE_19]),
(maps::ROUTE_21, [maps::PALLET_TOWN, maps::CINNABAR_ISLAND, NONE, NONE]), (maps::ROUTE_21, [maps::PALLET_TOWN, maps::CINNABAR_ISLAND, NONE, NONE]),
// Route 22 ends at the League gate, which is a building rather than an edge, and this (maps::ROUTE_22, [maps::ROUTE_23, NONE, NONE, maps::VIRIDIAN_CITY]),
// table has no id for it: Indigo Plateau is on the graph but not reachable from the south. (maps::ROUTE_23, [maps::INDIGO_PLATEAU, maps::ROUTE_22, NONE, NONE]),
(maps::ROUTE_22, [NONE, NONE, NONE, maps::VIRIDIAN_CITY]), (maps::ROUTE_24, [NONE, maps::CERULEAN_CITY, NONE, maps::ROUTE_25]),
(maps::ROUTE_23, [maps::INDIGO_PLATEAU, NONE, NONE, NONE]), (maps::ROUTE_25, [NONE, NONE, maps::ROUTE_24, NONE]),
(maps::ROUTE_24, [maps::ROUTE_25, maps::CERULEAN_CITY, NONE, NONE]),
(maps::ROUTE_25, [NONE, maps::ROUTE_24, NONE, NONE]),
]; ];
/// Connections in the headers that no step on foot crosses, from both sides.
///
/// Row 59, measured from the disassembly: for every connection, the tiles of this map's edge that
/// are walkable *and* land on a walkable tile of the other map's strip (the header's offset, the
/// other map's blocks and collision list). These eight have none. Pallet Town's south edge has
/// two walkable tiles and Route 21 is water under both; Cinnabar's east edge and Route 20's two
/// ends are sea; Route 22's north edge is the League's fence, and the road is its gate, a building
/// the graph has no row for. The map on the other side is still named ([`connected`]), and a
/// surfer's road is for a later row; on foot none of them is a way out
/// ([`super::path::exits`] offers no exit on them) or a road ([`neighbours`] leaves them out).
/// Without this the road from Pallet Town to Cerulean was by sea, and a fly that whited out in
/// Mt. Moon walked into Pallet's shore once every two seconds.
const NO_CROSSING: &[(u8, Edge)] = &[
(maps::PALLET_TOWN, Edge::South),
(maps::ROUTE_21, Edge::North),
(maps::CINNABAR_ISLAND, Edge::East),
(maps::ROUTE_20, Edge::West),
(maps::ROUTE_20, Edge::East),
(maps::ROUTE_19, Edge::West),
(maps::ROUTE_22, Edge::North),
(maps::ROUTE_23, Edge::South),
];
/// Whether a step off `map`'s `edge` can land on the other map on foot ([`NO_CROSSING`]).
pub fn crossable(map: u8, edge: Edge) -> bool {
!NO_CROSSING.contains(&(map, edge))
}
/// Doors and floor changes, as undirected pairs of maps. /// Doors and floor changes, as undirected pairs of maps.
/// ///
/// Only the ones a rung place needs a route through, because that is all [`next_hop`] is for: an /// Only the ones a rung place needs a route through, because that is all [`next_hop`] is for: an
/// unlisted building is simply not on the graph, which makes it a place `GO OBJECTIVE` cannot aim /// unlisted building is simply not on the graph, which makes it a place `GO OBJECTIVE` cannot aim
/// at from another map and changes nothing else. A cave with two mouths appears twice, which is /// at from another map and changes nothing else. Every pair is two warp tables that name each
/// what makes Mt. Moon a way from Route 3 to Route 4. /// other (a `LAST_MAP` door resolved to the one outdoor map whose warps lead in).
const LINKS: &[(u8, u8)] = &[ const LINKS: &[(u8, u8)] = &[
(maps::REDS_HOUSE_1F, maps::PALLET_TOWN), (maps::REDS_HOUSE_1F, maps::PALLET_TOWN),
(maps::REDS_HOUSE_2F, maps::REDS_HOUSE_1F), (maps::REDS_HOUSE_2F, maps::REDS_HOUSE_1F),
@ -122,10 +152,14 @@ const LINKS: &[(u8, u8)] = &[
(maps::PEWTER_MUSEUM_2F, maps::PEWTER_MUSEUM_1F), (maps::PEWTER_MUSEUM_2F, maps::PEWTER_MUSEUM_1F),
(maps::PEWTER_MART, maps::PEWTER_CITY), (maps::PEWTER_MART, maps::PEWTER_CITY),
(maps::PEWTER_POKECENTER, maps::PEWTER_CITY), (maps::PEWTER_POKECENTER, maps::PEWTER_CITY),
(maps::MT_MOON_1F, maps::ROUTE_3), // Mt. Moon has two mouths, and both are on Route 4 (`data/maps/objects/Route4.asm`): (18, 5)
// into the first floor, and (24, 5) into B1F, whose (27, 3) is the way back out on the far
// side of the mountain. Route 3 has no warps at all. Which chamber of B1F and B2F each ladder
// opens onto is [`SPLIT`]'s business.
(maps::MT_MOON_1F, maps::ROUTE_4), (maps::MT_MOON_1F, maps::ROUTE_4),
(maps::MT_MOON_1F, maps::MT_MOON_B1F), (maps::MT_MOON_1F, maps::MT_MOON_B1F),
(maps::MT_MOON_B1F, maps::MT_MOON_B2F), (maps::MT_MOON_B1F, maps::MT_MOON_B2F),
(maps::MT_MOON_B1F, maps::ROUTE_4),
(maps::CERULEAN_GYM, maps::CERULEAN_CITY), (maps::CERULEAN_GYM, maps::CERULEAN_CITY),
(maps::CERULEAN_MART, maps::CERULEAN_CITY), (maps::CERULEAN_MART, maps::CERULEAN_CITY),
(maps::CERULEAN_POKECENTER, maps::CERULEAN_CITY), (maps::CERULEAN_POKECENTER, maps::CERULEAN_CITY),
@ -141,11 +175,12 @@ const LINKS: &[(u8, u8)] = &[
/// the forest's south gate; the north half touches Pewter City and the forest's north gate; the /// the forest's south gate; the north half touches Pewter City and the forest's north gate; the
/// belt of trees between them needs CUT. A graph with one node for it answered "Pewter is two /// belt of trees between them needs CUT. A graph with one node for it answered "Pewter is two
/// hops from the south gate, south" — which is a road that does not exist — and sent the fly back /// hops from the south gate, south" — which is a road that does not exist — and sent the fly back
/// out of the gate it had just walked into, once per hold, for four hours. /// out of the gate it had just walked into, once per hold, for four hours. Row 59 found three more
/// on the road to Cerulean: Route 4, and Mt. Moon's two lower floors ([`SPLIT`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Region { pub struct Region {
pub map: u8, pub map: u8,
/// Which piece, for a map [`SPLIT`] has a row for; 0 everywhere else. /// Which piece, for a map [`SPLIT`] has a row for: its index in that row. 0 everywhere else.
pub part: u8, pub part: u8,
} }
@ -154,108 +189,268 @@ impl Region {
pub const fn whole(map: u8) -> Self { pub const fn whole(map: u8) -> Self {
Self { map, part: 0 } Self { map, part: 0 }
} }
/// Piece `part` of a map [`SPLIT`] has a row for.
pub const fn piece(map: u8, part: u8) -> Self {
Self { map, part }
}
} }
/// [`SPLIT`]'s two piece numbers. /// One piece of a split map.
const NORTH_PIECE: u8 = 0; struct Piece {
const SOUTH_PIECE: u8 = 1; /// The map's own warps that stand on this piece's ground: the index into its warp table (as
/// `wWarpEntries` holds it, and as a warp elsewhere names it for its destination, 0-based) and
/// the tile. A warp that lands on one of these lands in this piece; the tiles are what the
/// fly's own piece is told apart by ([`region_on`]).
doors: &'static [(u8, u8, u8)],
/// Everything one step from this piece: a whole map, or a piece of another split map. Their
/// maps together are exactly [`neighbours`]'s answer for the map, and every step is listed
/// back from the other side; [`tests::a_split_maps_pieces_add_up_and_answer_each_other`] pins
/// both.
next: &'static [Region],
}
/// A map whose walkable ground is in two pieces, and which of its neighbours each piece touches. /// A map whose walkable ground is in pieces the player cannot walk between.
struct Split { struct Split {
map: u8, map: u8,
/// The tile rows each piece's own doorway is on, measured from the cartridge: a tile belongs pieces: &'static [Piece],
/// to the piece whose row it is nearer to. Anchoring on the doorways rather than on a row in
/// the middle means the number comes from the warp table rather than from a claim about where
/// the trees are, and a tile in the impassable belt between them — ground the fly cannot
/// stand on — is the only place the answer could be wrong.
north_door: u8,
south_door: u8,
/// The neighbours reachable from each piece. Together they are exactly [`neighbours`]'s answer
/// for the map, which [`tests::a_split_maps_pieces_divide_its_neighbours_between_them`] pins.
north: &'static [u8],
south: &'static [u8],
} }
/// Every map whose ground is in two pieces. One row, and it took four hours of stream to find. /// Route 2's two halves, in [`SPLIT`]'s order.
#[cfg(test)]
const NORTH_PIECE: u8 = 0;
#[cfg(test)]
const SOUTH_PIECE: u8 = 1;
/// Route 4's two sides of the mountain.
#[cfg(test)]
const WEST_SIDE: u8 = 0;
const EAST_SIDE: u8 = 1;
/// Mt. Moon B1F's four chambers, named by what they hold.
const B1F_EXIT: u8 = 0;
const B1F_WEST: u8 = 1;
const B1F_MIDDLE: u8 = 2;
const B1F_SOUTH: u8 = 3;
/// Mt. Moon B2F's three pieces.
const B2F_MAIN: u8 = 0;
const B2F_NORTH: u8 = 1;
const B2F_SOUTH: u8 = 2;
/// Every map whose ground is in pieces, with each piece's doors and neighbours.
/// ///
/// `ROUTE_2`, surveyed from the cartridge on 2026-09-17 (`docs/design/macros-wram.md`'s method, /// Every row is measured from the disassembly at the pinned commit: the map's blocks, its
/// the run recorded in `infra/docs/macros-traps.md` row 33). The map is 20 by 72 and its warp /// tileset's blockset and collision list, the tile-pair walls and the ledges, flooded tile by tile
/// table reads: /// (`infra/docs/macros-traps.md` row 59 has the method). A map is listed here only when two of its
/// ways out are on different pieces, and the audit ran over every map on the graph.
/// ///
/// | warp | tile | into | /// - **`ROUTE_2`** (row 33): the forest's north gate at (3, 11) and Pewter's edge; the south gate
/// | ---: | --- | --- | /// at (3, 43) and Viridian's. Maps 46, 48 and 49 stay off the graph, the module's standing rule
/// | 0 | (12, 9) | `DIGLETTS_CAVE_ROUTE_2` (46) | /// for a building no rung place needs a route through: 49's two doors are both Route 2's own.
/// | 1 | (3, 11) | `VIRIDIAN_FOREST_NORTH_GATE` (47) | /// - **`ROUTE_4`** (row 59): Mt. Moon stands across it. The west side holds the Pokécenter at
/// | 2 | (15, 19) | `ROUTE_2_TRADE_HOUSE` (48) | /// (11, 5), the cave mouth at (18, 5) and the road down to Route 3; the east side holds B1F's
/// | 3 | (16, 35) | `ROUTE_2_GATE` (49) | /// exit at (24, 5) and the ledges down to Cerulean. From the Pewter side the only way east is
/// | 4 | (15, 39) | `ROUTE_2_GATE` (49) | /// through the mountain.
/// | 5 | (3, 43) | `VIRIDIAN_FOREST_SOUTH_GATE` (50) | /// - **`MT_MOON_B1F`** (row 59): four chambers, each two ladders and nothing between them. The
/// /// one road through is 1F (5, 5) to the west chamber, (21, 17) down to B2F, B2F (5, 7) up to the
/// with `north: true` and `south: true` in `wCurMapConnections` — Pewter off the top row, Viridian /// exit chamber, (27, 3) out onto Route 4's east side. The middle and south chambers are ladders
/// off the bottom one. The two forest gates at rows 11 and 43 are the doorways this splits on. /// to dead ends on B2F.
/// /// - **`MT_MOON_B2F`** (row 59): the fossil floor, one large piece with the two ladders the road
/// Maps 46, 48 and 49 are deliberately *not* on the graph, which is this module's standing rule /// uses, and two small pieces under the dead-end ladders.
/// for a building no rung place needs a route through: 49's two doors are both warps of `ROUTE_2` const SPLIT: &[Split] = &[
/// itself, so it is a shortcut within one map rather than a way between two, and 46 and 48 are Split {
/// ends of the line. A route the table does not carry is simply not offered; nothing is guessed. map: maps::ROUTE_2,
const SPLIT: &[Split] = &[Split { pieces: &[
map: maps::ROUTE_2, Piece {
north_door: 11, doors: &[(1, 3, 11)],
south_door: 43, next: &[
north: &[maps::PEWTER_CITY, maps::VIRIDIAN_FOREST_NORTH_GATE], Region::whole(maps::PEWTER_CITY),
south: &[maps::VIRIDIAN_CITY, maps::VIRIDIAN_FOREST_SOUTH_GATE], Region::whole(maps::VIRIDIAN_FOREST_NORTH_GATE),
}]; ],
},
Piece {
doors: &[(5, 3, 43)],
next: &[
Region::whole(maps::VIRIDIAN_CITY),
Region::whole(maps::VIRIDIAN_FOREST_SOUTH_GATE),
],
},
],
},
Split {
map: maps::ROUTE_4,
pieces: &[
Piece {
doors: &[(0, 11, 5), (1, 18, 5)],
next: &[Region::whole(maps::ROUTE_3), Region::whole(maps::MT_MOON_1F)],
},
Piece {
doors: &[(2, 24, 5)],
next: &[
Region::piece(maps::MT_MOON_B1F, B1F_EXIT),
Region::whole(maps::CERULEAN_CITY),
],
},
],
},
Split {
map: maps::MT_MOON_B1F,
pieces: &[
Piece {
doors: &[(6, 23, 3), (7, 27, 3)],
next: &[
Region::piece(maps::MT_MOON_B2F, B2F_MAIN),
Region::piece(maps::ROUTE_4, EAST_SIDE),
],
},
Piece {
doors: &[(0, 5, 5), (4, 21, 17)],
next: &[
Region::whole(maps::MT_MOON_1F),
Region::piece(maps::MT_MOON_B2F, B2F_MAIN),
],
},
Piece {
doors: &[(1, 17, 11), (2, 25, 9)],
next: &[
Region::whole(maps::MT_MOON_1F),
Region::piece(maps::MT_MOON_B2F, B2F_NORTH),
],
},
Piece {
doors: &[(3, 25, 15), (5, 13, 27)],
next: &[
Region::whole(maps::MT_MOON_1F),
Region::piece(maps::MT_MOON_B2F, B2F_SOUTH),
],
},
],
},
Split {
map: maps::MT_MOON_B2F,
pieces: &[
Piece {
doors: &[(1, 21, 17), (3, 5, 7)],
next: &[
Region::piece(maps::MT_MOON_B1F, B1F_EXIT),
Region::piece(maps::MT_MOON_B1F, B1F_WEST),
],
},
Piece {
doors: &[(0, 25, 9)],
next: &[Region::piece(maps::MT_MOON_B1F, B1F_MIDDLE)],
},
Piece {
doors: &[(2, 15, 27)],
next: &[Region::piece(maps::MT_MOON_B1F, B1F_SOUTH)],
},
],
},
];
fn split_of(map: u8) -> Option<&'static Split> { fn split_of(map: u8) -> Option<&'static Split> {
SPLIT.iter().find(|split| split.map == map) SPLIT.iter().find(|split| split.map == map)
} }
/// The piece of `map` a tile on row `y` is in. /// The pieces of a split map with their numbers, which are their [`Region::part`]s.
/// fn pieces(split: &'static Split) -> impl Iterator<Item = (u8, &'static Piece)> {
/// For every map but [`SPLIT`]'s rows this is [`Region::whole`]. Callers pass the player's own split.pieces.iter().enumerate().filter_map(|(part, piece)| Some((u8::try_from(part).ok()?, piece)))
/// row, which is the only thing that can tell the two halves of `ROUTE_2` apart.
pub fn region_at(map: u8, y: u8) -> Region {
match split_of(map) {
None => Region::whole(map),
Some(split) => {
let north = y.abs_diff(split.north_door);
let south = y.abs_diff(split.south_door);
Region { map, part: if north <= south { NORTH_PIECE } else { SOUTH_PIECE } }
}
}
} }
/// The piece of `map` that `from` opens onto, or `None` when no piece of it touches `from`. /// The piece of `map` the tile `(x, y)` is in, by the doors alone.
/// ///
/// This is the reverse of [`region_at`] and it needs no tile: a door or an edge is listed under /// For every map but [`SPLIT`]'s rows this is [`Region::whole`]. On a split map it is the piece
/// exactly one piece, so "which half of Route 2 does the north gate open onto" is a table lookup. /// with the nearest door, counting tiles across and down, the first piece on a tie. That is exact
/// `None` is an edge the graph does not have -- the south half of Route 2 is not reachable from /// for every tile of Route 2's and Route 4's ground, which is where the grid cannot answer
/// Pewter City, whatever the map ids alone would suggest. /// ([`region_on`]: a ledge is a one-way step the grid does not model), and it is only the fallback
fn region_toward(map: u8, from: u8) -> Option<Region> { /// on Mt. Moon's floors, whose chambers wrap round each other.
match split_of(map) { pub fn region_at(map: u8, x: u8, y: u8) -> Region {
None => Some(Region::whole(map)), let Some(split) = split_of(map) else { return Region::whole(map) };
Some(split) => { let distance = |piece: &Piece| {
if split.north.contains(&from) { piece
Some(Region { map, part: NORTH_PIECE }) .doors
} else if split.south.contains(&from) { .iter()
Some(Region { map, part: SOUTH_PIECE }) .map(|(_, dx, dy)| u16::from(x.abs_diff(*dx)) + u16::from(y.abs_diff(*dy)))
} else { .min()
None .unwrap_or(u16::MAX)
} };
let part = pieces(split).min_by_key(|(part, piece)| (distance(piece), *part)).map_or(0, |(part, _)| part);
Region { map, part }
}
/// The piece of `map` the fly standing on `(x, y)` is in.
///
/// The ground decides: with the decoded map grid (section 15) the piece is the one whose doors a
/// walk from here can reach, when exactly one piece's can. The grid has no ledges -- a ledge is a
/// step one way only, and the grid reads it as a wall -- so on the part of Route 4 below the
/// ledges no door is reachable and [`region_at`] answers from the doors. Row 59 flooded every tile
/// of every piece's ground in the disassembly under this rule and it names the right piece for
/// all of them.
pub fn region_on(map: u8, x: u8, y: u8, grid: Option<&MapGrid>) -> Region {
let Some(split) = split_of(map) else { return Region::whole(map) };
if let Some(grid) = grid.filter(|grid| grid.map() == map) {
let walk = grid.reachable(x, y);
// A door tile the collision list refuses is still stepped onto from beside it.
let reached = |dx: u8, dy: u8| {
walk.contains(dx, dy)
|| [(0i16, 1i16), (0, -1), (1, 0), (-1, 0)].iter().any(|(ox, oy)| {
match (u8::try_from(i16::from(dx) + ox), u8::try_from(i16::from(dy) + oy)) {
(Ok(nx), Ok(ny)) => walk.contains(nx, ny),
_ => false,
}
})
};
let mut hit =
pieces(split).filter(|(_, piece)| piece.doors.iter().any(|(_, dx, dy)| reached(*dx, *dy)));
if let (Some((part, _)), None) = (hit.next(), hit.next()) {
return Region { map, part };
} }
} }
region_at(map, x, y)
}
/// The piece of `map` a warp lands in when it names `map`'s warp `index` as its destination.
///
/// `None` only for a split map whose table does not list that warp, which is not guessed at.
pub fn arrival_by_warp(map: u8, index: u8) -> Option<Region> {
let Some(split) = split_of(map) else { return Some(Region::whole(map)) };
pieces(split)
.find(|(_, piece)| piece.doors.iter().any(|(door, _, _)| *door == index))
.map(|(part, _)| Region { map, part })
}
/// The piece of `map` that stepping off an edge of `from` lands in.
///
/// An edge is listed under exactly one piece of a split map: Pewter's south edge opens onto Route
/// 2's north half, Route 3's north edge onto Route 4's west side and Cerulean's west edge onto its
/// east side. `None` is an edge the graph does not have.
pub fn arrival_by_edge(map: u8, from: u8) -> Option<Region> {
let Some(split) = split_of(map) else { return Some(Region::whole(map)) };
pieces(split)
.find(|(_, piece)| piece.next.iter().any(|next| next.map == from))
.map(|(part, _)| Region { map, part })
} }
/// Every piece of ground one step from `region`. /// Every piece of ground one step from `region`.
fn region_neighbours(region: Region) -> Vec<Region> { fn region_neighbours(region: Region) -> Vec<Region> {
let of = |map: u8| region_toward(map, region.map); if let Some(split) = split_of(region.map) {
match split_of(region.map) { return split.pieces.get(usize::from(region.part)).map_or_else(Vec::new, |piece| piece.next.to_vec());
None => neighbours(region.map).into_iter().filter_map(of).collect(), }
Some(split) => { // A whole map steps onto every piece of a split neighbour that lists it back: Mt. Moon's
let own = if region.part == NORTH_PIECE { split.north } else { split.south }; // first floor has a ladder into three of B1F's four chambers.
own.iter().copied().filter_map(of).collect() let mut out = Vec::new();
for map in neighbours(region.map) {
match split_of(map) {
None => out.push(Region::whole(map)),
Some(split) => out.extend(
pieces(split)
.filter(|(_, piece)| piece.next.contains(&region))
.map(|(part, _)| Region { map, part }),
),
} }
} }
out
} }
/// The map on the other side of `map`'s `edge`, or `None` where the table does not know. /// The map on the other side of `map`'s `edge`, or `None` where the table does not know.
@ -280,11 +475,20 @@ pub fn outdoor_of(interior: u8) -> Option<u8> {
neighbours(interior).into_iter().find(|map| super::cartridge::outdoors(*map)) neighbours(interior).into_iter().find(|map| super::cartridge::outdoors(*map))
} }
/// Every map one step from `map`, doors and edges together, deduplicated and in id order. /// Every map one step on foot from `map`, doors and edges together, deduplicated and in id order.
///
/// A connection with no crossing ([`NO_CROSSING`]) is not a step on foot and is left out.
pub fn neighbours(map: u8) -> Vec<u8> { pub fn neighbours(map: u8) -> Vec<u8> {
const EDGES: [Edge; 4] = [Edge::North, Edge::South, Edge::West, Edge::East];
let mut out: Vec<u8> = Vec::new(); let mut out: Vec<u8> = Vec::new();
if let Some(row) = CONNECTIONS.iter().find(|(id, _)| *id == map) { if let Some(row) = CONNECTIONS.iter().find(|(id, _)| *id == map) {
out.extend(row.1.iter().copied().filter(|id| *id != NONE)); out.extend(
row.1
.iter()
.zip(EDGES)
.filter(|(id, edge)| **id != NONE && crossable(map, *edge))
.map(|(id, _)| *id),
);
} }
for (a, b) in LINKS { for (a, b) in LINKS {
if *a == map { if *a == map {
@ -296,7 +500,7 @@ pub fn neighbours(map: u8) -> Vec<u8> {
} }
// Symmetric closure: a row that names a neighbour is a connection whichever side lists it. // Symmetric closure: a row that names a neighbour is a connection whichever side lists it.
for (id, row) in CONNECTIONS { for (id, row) in CONNECTIONS {
if row.contains(&map) { if row.iter().zip(EDGES).any(|(other, edge)| *other == map && crossable(*id, edge)) {
out.push(*id); out.push(*id);
} }
} }
@ -313,16 +517,23 @@ pub fn neighbours(map: u8) -> Vec<u8> {
/// an unreachable one, and for `from == to` -- there is no hop to take when the fly is already /// an unreachable one, and for `from == to` -- there is no hop to take when the fly is already
/// there, and `GO OBJECTIVE` has its own answer for that case. /// there, and `GO OBJECTIVE` has its own answer for that case.
pub fn next_hop(from: Region, to: u8) -> Option<u8> { pub fn next_hop(from: Region, to: u8) -> Option<u8> {
next_step(from, to).map(|hop| hop.map)
}
/// [`next_hop`] with the piece it lands in, which is what an exit has to match on a map with two
/// doors into one split map: three of Mt. Moon's first-floor ladders go down to B1F, and only
/// one of them reaches the way out ([`arrival_by_warp`] names where each one lands).
pub fn next_step(from: Region, to: u8) -> Option<Region> {
if from.map == to { if from.map == to {
return None; return None;
} }
let mut seen: HashSet<Region> = HashSet::from([from]); let mut seen: HashSet<Region> = HashSet::from([from]);
// piece -> the first hop out of `from` that reaches it // piece -> the first hop out of `from` that reaches it
let mut first: HashMap<Region, u8> = HashMap::new(); let mut first: HashMap<Region, Region> = HashMap::new();
let mut queue: VecDeque<Region> = VecDeque::new(); let mut queue: VecDeque<Region> = VecDeque::new();
for hop in region_neighbours(from) { for hop in region_neighbours(from) {
if seen.insert(hop) { if seen.insert(hop) {
first.insert(hop, hop.map); first.insert(hop, hop);
queue.push_back(hop); queue.push_back(hop);
} }
} }
@ -495,6 +706,62 @@ mod tests {
} }
} }
#[test]
fn the_rows_the_disassembly_corrected_say_what_its_headers_say() {
// Row 59, `data/maps/headers/*.asm` at the pinned commit. Route 4 is north of Route 3:
// Route 3's top edge is the road to Mt. Moon's Pokécenter, and Route 3 has no east exit.
assert_eq!(connected(maps::ROUTE_3, Edge::North), Some(maps::ROUTE_4));
assert_eq!(connected(maps::ROUTE_3, Edge::East), None);
assert_eq!(connected(maps::ROUTE_4, Edge::South), Some(maps::ROUTE_3));
assert_eq!(connected(maps::ROUTE_4, Edge::West), None);
assert_eq!(connected(maps::ROUTE_4, Edge::East), Some(maps::CERULEAN_CITY));
// Nugget Bridge's far end is Route 24's east edge, not its north one.
assert_eq!(connected(maps::ROUTE_24, Edge::East), Some(maps::ROUTE_25));
assert_eq!(connected(maps::ROUTE_24, Edge::North), None);
assert_eq!(connected(maps::ROUTE_25, Edge::West), Some(maps::ROUTE_24));
assert_eq!(connected(maps::ROUTE_25, Edge::South), None);
assert_eq!(connected(maps::ROUTE_14, Edge::West), Some(maps::ROUTE_15));
assert_eq!(connected(maps::ROUTE_15, Edge::East), Some(maps::ROUTE_14));
// Mt. Moon's two mouths are both on Route 4, and Route 3 has no warps: a cave door that
// is not there is a road the fly walks up and down for ever (row 59's Pewter ring).
assert!(!neighbours(maps::ROUTE_3).contains(&maps::MT_MOON_1F));
assert_eq!(outdoor_of(maps::MT_MOON_1F), Some(maps::ROUTE_4));
assert_eq!(outdoor_of(maps::MT_MOON_B1F), Some(maps::ROUTE_4));
assert_eq!(
neighbours(maps::ROUTE_3),
vec![maps::PEWTER_CITY, maps::ROUTE_4],
"Route 3 is a road between two maps and nothing else"
);
}
#[test]
fn a_connection_nobody_can_walk_across_is_named_and_is_not_a_road() {
// Row 59: Pallet Town's shore. The header connects it to Route 21, which is water.
assert_eq!(connected(maps::PALLET_TOWN, Edge::South), Some(maps::ROUTE_21));
assert!(!crossable(maps::PALLET_TOWN, Edge::South));
assert!(!neighbours(maps::PALLET_TOWN).contains(&maps::ROUTE_21));
assert!(!neighbours(maps::ROUTE_21).contains(&maps::PALLET_TOWN));
// So the road from Pallet Town to Cerulean is the long one on land: north, through the
// forest, Pewter and Mt. Moon, and not by sea through Cinnabar and Fuchsia.
assert_eq!(next_hop(Region::whole(maps::PALLET_TOWN), maps::CERULEAN_CITY), Some(maps::ROUTE_1));
assert_eq!(hops(Region::whole(maps::PALLET_TOWN), maps::CERULEAN_CITY), Some(16));
// Every entry is one of the header's own connections, and both sides are listed.
for (map, edge) in NO_CROSSING {
let other = connected(*map, *edge).expect("a no-crossing entry is a header connection");
let back = match edge {
Edge::North => Edge::South,
Edge::South => Edge::North,
Edge::West => Edge::East,
Edge::East => Edge::West,
};
assert!(!crossable(other, back), "{other:#04x} lists {map:#04x} as crossable");
}
// Indigo Plateau is on the graph and, until a row gives the League gate, not on foot
// from the south.
assert_eq!(connected(maps::ROUTE_22, Edge::North), Some(maps::ROUTE_23));
assert_eq!(next_hop(Region::whole(maps::VIRIDIAN_CITY), maps::INDIGO_PLATEAU), None);
}
#[test] #[test]
fn a_front_door_resolves_to_the_town_outside_it() { fn a_front_door_resolves_to_the_town_outside_it() {
assert_eq!(outdoor_of(maps::OAKS_LAB), Some(maps::PALLET_TOWN)); assert_eq!(outdoor_of(maps::OAKS_LAB), Some(maps::PALLET_TOWN));
@ -520,7 +787,7 @@ mod tests {
assert_eq!(next_hop(at(maps::OAKS_LAB), maps::VIRIDIAN_MART), Some(maps::PALLET_TOWN)); assert_eq!(next_hop(at(maps::OAKS_LAB), maps::VIRIDIAN_MART), Some(maps::PALLET_TOWN));
// Upstairs is two hops from the town, through the ground floor. // Upstairs is two hops from the town, through the ground floor.
assert_eq!(next_hop(at(maps::PALLET_TOWN), maps::REDS_HOUSE_2F), Some(maps::REDS_HOUSE_1F)); assert_eq!(next_hop(at(maps::PALLET_TOWN), maps::REDS_HOUSE_2F), Some(maps::REDS_HOUSE_1F));
// Through the cave, because Route 3 and Route 4 are the same two maps either way round. // Out along Route 3, whose only other end is the road up to Mt. Moon.
assert_eq!(next_hop(at(maps::PEWTER_CITY), maps::CERULEAN_GYM), Some(maps::ROUTE_3)); assert_eq!(next_hop(at(maps::PEWTER_CITY), maps::CERULEAN_GYM), Some(maps::ROUTE_3));
// Nowhere to go, and nowhere known. // Nowhere to go, and nowhere known.
assert_eq!(next_hop(at(maps::PALLET_TOWN), maps::PALLET_TOWN), None); assert_eq!(next_hop(at(maps::PALLET_TOWN), maps::PALLET_TOWN), None);
@ -528,37 +795,169 @@ mod tests {
} }
#[test] #[test]
fn a_split_maps_pieces_divide_its_neighbours_between_them() { fn a_split_maps_pieces_add_up_and_answer_each_other() {
// The invariant that keeps [`SPLIT`] honest: a piece's own list is a real subset of the // The invariants that keep [`SPLIT`] honest. A typo in any of them is a road that does
// map's neighbours, the two pieces together are all of them, and neither claims the same // not exist, or a door that leads nowhere.
// neighbour twice. A typo here is a road that does not exist.
for split in SPLIT { for split in SPLIT {
let mut both: Vec<u8> = // The pieces' neighbours together are exactly the map's.
split.north.iter().chain(split.south.iter()).copied().collect(); let mut maps_of: Vec<u8> =
both.sort_unstable(); split.pieces.iter().flat_map(|piece| piece.next.iter().map(|r| r.map)).collect();
let mut once = both.clone(); maps_of.sort_unstable();
once.dedup(); maps_of.dedup();
assert_eq!(both, once, "{:#04x} lists a neighbour under both pieces", split.map);
assert_eq!( assert_eq!(
both, maps_of,
neighbours(split.map), neighbours(split.map),
"{:#04x}'s pieces do not add up to its neighbours", "{:#04x}'s pieces do not add up to its neighbours",
split.map split.map
); );
assert_ne!(split.north_door, split.south_door); let mut doors: Vec<u8> =
split.pieces.iter().flat_map(|piece| piece.doors.iter().map(|d| d.0)).collect();
doors.sort_unstable();
let count = doors.len();
doors.dedup();
assert_eq!(doors.len(), count, "{:#04x} lists one warp under two pieces", split.map);
for (part, piece) in pieces(split) {
let here = Region { map: split.map, part };
assert!(!piece.doors.is_empty(), "{here:?} has no door to be told apart by");
// A door's own tile is in its own piece.
for (_, x, y) in piece.doors {
assert_eq!(region_at(split.map, *x, *y), here, "door ({x}, {y})");
}
// Every step is listed back from the other side, so a route is reversible.
for next in piece.next {
assert!(
region_neighbours(*next).contains(&here),
"{next:?} does not step back onto {here:?}"
);
if let Some(other) = split_of(next.map) {
assert!(usize::from(next.part) < other.pieces.len(), "{next:?}");
}
}
}
} }
} }
#[test]
fn the_road_from_pewter_to_cerulean_is_through_mt_moon_one_chamber_at_a_time() {
// Row 59. Every step of the road, as the pieces the fly stands in, measured from the
// disassembly: Route 3's top edge, Route 4's west side, the cave mouth at (18, 5), 1F's
// ladder at (5, 5), B1F's west chamber, its ladder at (21, 17), B2F, its ladder at (5, 7),
// B1F's exit chamber, (27, 3), Route 4's east side, Cerulean.
let road = [
Region::whole(maps::PEWTER_CITY),
Region::whole(maps::ROUTE_3),
Region::piece(maps::ROUTE_4, WEST_SIDE),
Region::whole(maps::MT_MOON_1F),
Region::piece(maps::MT_MOON_B1F, B1F_WEST),
Region::piece(maps::MT_MOON_B2F, B2F_MAIN),
Region::piece(maps::MT_MOON_B1F, B1F_EXIT),
Region::piece(maps::ROUTE_4, EAST_SIDE),
Region::whole(maps::CERULEAN_CITY),
];
for pair in road.windows(2) {
assert_eq!(next_step(pair[0], maps::CERULEAN_CITY), Some(pair[1]), "from {:?}", pair[0]);
}
assert_eq!(hops(road[0], maps::CERULEAN_CITY), Some(8));
// Mt. Moon's rung is the first floor, one hop from the cave mouth's side of Route 4 --
// which is where the Pewter ring said the fly could never get to.
assert_eq!(next_hop(Region::whole(maps::PEWTER_CITY), maps::MT_MOON_1F), Some(maps::ROUTE_3));
assert_eq!(next_hop(Region::whole(maps::ROUTE_3), maps::MT_MOON_1F), Some(maps::ROUTE_4));
assert_eq!(
next_hop(Region::piece(maps::ROUTE_4, WEST_SIDE), maps::MT_MOON_1F),
Some(maps::MT_MOON_1F)
);
// The dead ends lead back the way they came.
assert_eq!(
next_step(Region::piece(maps::MT_MOON_B1F, B1F_MIDDLE), maps::CERULEAN_CITY),
Some(Region::whole(maps::MT_MOON_1F))
);
assert_eq!(
next_step(Region::piece(maps::MT_MOON_B2F, B2F_SOUTH), maps::CERULEAN_CITY),
Some(Region::piece(maps::MT_MOON_B1F, B1F_SOUTH))
);
// From Cerulean's side of the mountain the way back to Pewter is the cave, not Route 4's
// south edge, which is on the other side.
assert_eq!(
next_step(Region::piece(maps::ROUTE_4, EAST_SIDE), maps::PEWTER_CITY),
Some(Region::piece(maps::MT_MOON_B1F, B1F_EXIT))
);
assert_eq!(
next_step(Region::whole(maps::CERULEAN_CITY), maps::ROUTE_24),
Some(Region::whole(maps::ROUTE_24))
);
assert_eq!(next_hop(Region::whole(maps::ROUTE_24), maps::ROUTE_25), Some(maps::ROUTE_25));
}
#[test]
fn a_door_or_an_edge_lands_in_the_piece_it_opens_onto() {
// Which warp of the destination a warp names is the cartridge's own answer (`wWarpEntries`
// byte 2, 0-based): 1F's ladders are B1F's warps 0, 2 and 3.
assert_eq!(arrival_by_warp(maps::MT_MOON_B1F, 0), Some(Region::piece(maps::MT_MOON_B1F, B1F_WEST)));
assert_eq!(arrival_by_warp(maps::MT_MOON_B1F, 2), Some(Region::piece(maps::MT_MOON_B1F, B1F_MIDDLE)));
assert_eq!(arrival_by_warp(maps::MT_MOON_B1F, 3), Some(Region::piece(maps::MT_MOON_B1F, B1F_SOUTH)));
// B1F's exit is `LAST_MAP` warp 2: Route 4's (24, 5), the far side of the mountain.
assert_eq!(arrival_by_warp(maps::ROUTE_4, 2), Some(Region::piece(maps::ROUTE_4, EAST_SIDE)));
// 1F's doormat is `LAST_MAP` warp 1: the cave mouth, the Pewter side.
assert_eq!(arrival_by_warp(maps::ROUTE_4, 1), Some(Region::piece(maps::ROUTE_4, WEST_SIDE)));
// The forest gates' doormats onto Route 2 are its warps 1 and 5.
assert_eq!(arrival_by_warp(maps::ROUTE_2, 1), Some(Region::piece(maps::ROUTE_2, NORTH_PIECE)));
assert_eq!(arrival_by_warp(maps::ROUTE_2, 5), Some(Region::piece(maps::ROUTE_2, SOUTH_PIECE)));
// A warp the table does not list is not guessed at; a whole map is always whole.
assert_eq!(arrival_by_warp(maps::ROUTE_2, 0), None);
assert_eq!(arrival_by_warp(maps::PEWTER_GYM, 0), Some(Region::whole(maps::PEWTER_GYM)));
// Edges.
assert_eq!(arrival_by_edge(maps::ROUTE_4, maps::ROUTE_3), Some(Region::piece(maps::ROUTE_4, WEST_SIDE)));
assert_eq!(
arrival_by_edge(maps::ROUTE_4, maps::CERULEAN_CITY),
Some(Region::piece(maps::ROUTE_4, EAST_SIDE))
);
assert_eq!(arrival_by_edge(maps::ROUTE_2, maps::PEWTER_CITY), Some(Region::piece(maps::ROUTE_2, NORTH_PIECE)));
assert_eq!(arrival_by_edge(maps::ROUTE_3, maps::ROUTE_4), Some(Region::whole(maps::ROUTE_3)));
}
#[test]
fn route_4s_sides_are_told_apart_by_the_doors_where_the_ground_cannot() {
// With no grid, the nearest door: the cave mouth's side reaches down to Route 3's road at
// (7..11, 17), and Cerulean's side is everything east of the mountain.
assert_eq!(region_at(maps::ROUTE_4, 9, 17), Region::piece(maps::ROUTE_4, WEST_SIDE));
assert_eq!(region_at(maps::ROUTE_4, 18, 6), Region::piece(maps::ROUTE_4, WEST_SIDE));
assert_eq!(region_at(maps::ROUTE_4, 24, 6), Region::piece(maps::ROUTE_4, EAST_SIDE));
assert_eq!(region_at(maps::ROUTE_4, 89, 10), Region::piece(maps::ROUTE_4, EAST_SIDE));
assert_eq!(region_at(maps::ROUTE_3, 60, 0), Region::whole(maps::ROUTE_3));
// B2F's chambers wrap round each other, so there the grid decides.
let mut grid = MapGrid::new(maps::MT_MOON_B2F, 40, 36);
// A corridor from B2F's (5, 7) ladder east along row 7 and down column 33 to (33, 31):
// nearer the (15, 27) ladder than either of its own, and on the main piece.
for x in 4..=33 {
grid.set(x, 7, 0, super::super::state::Walkable::Yes);
}
for y in 7..=31 {
grid.set(33, y, 0, super::super::state::Walkable::Yes);
}
assert_eq!(region_at(maps::MT_MOON_B2F, 33, 31), Region::piece(maps::MT_MOON_B2F, B2F_SOUTH));
assert_eq!(
region_on(maps::MT_MOON_B2F, 33, 31, Some(&grid)),
Region::piece(maps::MT_MOON_B2F, B2F_MAIN)
);
// A grid of another map says nothing, and neither does none.
let other = MapGrid::new(maps::ROUTE_4, 90, 18);
assert_eq!(
region_on(maps::MT_MOON_B2F, 33, 31, Some(&other)),
Region::piece(maps::MT_MOON_B2F, B2F_SOUTH)
);
assert_eq!(region_on(maps::MT_MOON_B2F, 33, 31, None), Region::piece(maps::MT_MOON_B2F, B2F_SOUTH));
}
#[test] #[test]
fn route_2s_halves_are_told_apart_by_the_row_the_fly_is_standing_on() { fn route_2s_halves_are_told_apart_by_the_row_the_fly_is_standing_on() {
// The two doorways are rows 11 and 43, measured from the cartridge's own warp table. // The two doorways are rows 11 and 43, measured from the cartridge's own warp table.
assert_eq!(region_at(maps::ROUTE_2, 11).part, NORTH_PIECE); assert_eq!(region_at(maps::ROUTE_2, 3, 11).part, NORTH_PIECE);
assert_eq!(region_at(maps::ROUTE_2, 43).part, SOUTH_PIECE); assert_eq!(region_at(maps::ROUTE_2, 3, 43).part, SOUTH_PIECE);
assert_eq!(region_at(maps::ROUTE_2, 0).part, NORTH_PIECE, "Pewter's end"); assert_eq!(region_at(maps::ROUTE_2, 8, 0).part, NORTH_PIECE, "Pewter's end");
assert_eq!(region_at(maps::ROUTE_2, 71).part, SOUTH_PIECE, "Viridian's end"); assert_eq!(region_at(maps::ROUTE_2, 8, 71).part, SOUTH_PIECE, "Viridian's end");
// Every other map is one piece, whatever row is asked about. // Every other map is one piece, whatever row is asked about.
assert_eq!(region_at(maps::ROUTE_1, 30), Region::whole(maps::ROUTE_1)); assert_eq!(region_at(maps::ROUTE_1, 10, 30), Region::whole(maps::ROUTE_1));
assert_eq!(region_at(maps::VIRIDIAN_FOREST_SOUTH_GATE, 7).part, 0); assert_eq!(region_at(maps::VIRIDIAN_FOREST_SOUTH_GATE, 4, 7).part, 0);
} }
#[test] #[test]
@ -578,16 +977,16 @@ mod tests {
Some(maps::ROUTE_2) Some(maps::ROUTE_2)
); );
// Route 2's north half steps off its own top edge; its south half walks to the gate. // Route 2's north half steps off its own top edge; its south half walks to the gate.
assert_eq!(next_hop(region_at(maps::ROUTE_2, 11), maps::PEWTER_CITY), Some(maps::PEWTER_CITY)); assert_eq!(next_hop(region_at(maps::ROUTE_2, 3, 11), maps::PEWTER_CITY), Some(maps::PEWTER_CITY));
assert_eq!( assert_eq!(
next_hop(region_at(maps::ROUTE_2, 43), maps::PEWTER_CITY), next_hop(region_at(maps::ROUTE_2, 3, 43), maps::PEWTER_CITY),
Some(maps::VIRIDIAN_FOREST_SOUTH_GATE) Some(maps::VIRIDIAN_FOREST_SOUTH_GATE)
); );
// From Viridian City the first hop is still Route 2, which is the road the fly takes north. // From Viridian City the first hop is still Route 2, which is the road the fly takes north.
assert_eq!(next_hop(Region::whole(maps::VIRIDIAN_CITY), maps::PEWTER_CITY), Some(maps::ROUTE_2)); assert_eq!(next_hop(Region::whole(maps::VIRIDIAN_CITY), maps::PEWTER_CITY), Some(maps::ROUTE_2));
// And the way back south from the north half is the forest, not Route 2's own bottom edge. // And the way back south from the north half is the forest, not Route 2's own bottom edge.
assert_eq!( assert_eq!(
next_hop(region_at(maps::ROUTE_2, 11), maps::VIRIDIAN_CITY), next_hop(region_at(maps::ROUTE_2, 3, 11), maps::VIRIDIAN_CITY),
Some(maps::VIRIDIAN_FOREST_NORTH_GATE) Some(maps::VIRIDIAN_FOREST_NORTH_GATE)
); );
} }
@ -645,11 +1044,11 @@ mod tests {
assert_eq!(here.map, maps::PEWTER_GYM); assert_eq!(here.map, maps::PEWTER_GYM);
assert_eq!(steps, 3); assert_eq!(steps, 3);
// A split map is measured from the piece the fly is standing in, exactly as `next_hop` is. // A split map is measured from the piece the fly is standing in, exactly as `next_hop` is.
assert_eq!(hops(region_at(maps::ROUTE_2, 11), maps::PEWTER_CITY), Some(1)); assert_eq!(hops(region_at(maps::ROUTE_2, 3, 11), maps::PEWTER_CITY), Some(1));
// Five from the south half, because the belt of trees between the halves needs CUT and // Five from the south half, because the belt of trees between the halves needs CUT and
// the road is the forest: the south gate, the forest, the north gate, Route 2's north // the road is the forest: the south gate, the forest, the north gate, Route 2's north
// half, Pewter. // half, Pewter.
assert_eq!(hops(region_at(maps::ROUTE_2, 43), maps::PEWTER_CITY), Some(5)); assert_eq!(hops(region_at(maps::ROUTE_2, 3, 43), maps::PEWTER_CITY), Some(5));
// And nothing is guessed. // And nothing is guessed.
assert_eq!(hops(at(maps::PALLET_TOWN), 0xf0), None); assert_eq!(hops(at(maps::PALLET_TOWN), 0xf0), None);
} }

View file

@ -1422,14 +1422,47 @@ fn exit_tiers(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
/// was in. Empty when there is no objective and when it is on this map. /// was in. Empty when there is no objective and when it is on this map.
pub fn toward_objective(state: &mut dyn MacroState, candidates: &[Exit]) -> Vec<Exit> { pub fn toward_objective(state: &mut dyn MacroState, candidates: &[Exit]) -> Vec<Exit> {
let Some(objective) = objective_place(state) else { return Vec::new() }; let Some(objective) = objective_place(state) else { return Vec::new() };
let Some(player) = state.player() else { return Vec::new() }; let Some(from) = region_here(state) else { return Vec::new() };
let here = player.map; let here = from.map;
if here == objective.map { if here == objective.map {
return Vec::new(); return Vec::new();
} }
let hop = geography::next_hop(geography::region_at(here, player.y), objective.map); match geography::next_step(from, objective.map) {
let aim = hop.unwrap_or(objective.map); Some(hop) => {
candidates.iter().copied().filter(|exit| exit.destination(here) == Some(aim)).collect() candidates.iter().copied().filter(|exit| leads_to(state, exit, here, hop)).collect()
}
None => candidates
.iter()
.copied()
.filter(|exit| exit.destination(here) == Some(objective.map))
.collect(),
}
}
/// The piece of ground the fly is standing in: its map, and on a map whose ground is in pieces
/// the piece its walk can reach the doors of (`docs/design/macros.md` sections 12.7 and 12.24).
pub fn region_here(state: &mut dyn MacroState) -> Option<geography::Region> {
let player = state.player()?;
let grid = state.map_grid();
Some(geography::region_on(player.map, player.x, player.y, grid.as_deref()))
}
/// Whether `exit` takes the fly onto `hop`: the map on the other side, and on a map whose ground
/// is in pieces, the piece it lands in. A warp names the destination's warp it arrives at, which
/// is what tells Mt. Moon's three ladders down to B1F apart (section 12.24); an edge lands in the
/// piece that lists the map it is stepped off. A landing the table cannot name is not a match.
fn leads_to(state: &mut dyn MacroState, exit: &Exit, here: u8, hop: geography::Region) -> bool {
if exit.destination(here) != Some(hop.map) {
return false;
}
let landing = match exit.id {
ExitId::Warp(index) => state
.warps()
.get(usize::from(index))
.and_then(|warp| geography::arrival_by_warp(hop.map, warp.destination_warp)),
ExitId::Edge(_) => geography::arrival_by_edge(hop.map, here),
};
landing == Some(hop)
} }
/// The people on this map still worth walking to, each with the key the ledgers name it by. /// The people on this map still worth walking to, each with the key the ledgers name it by.
@ -1768,9 +1801,10 @@ pub fn goals_toward(state: &mut dyn MacroState, target: u8) -> Vec<Aim> {
}) })
.collect() .collect()
}; };
if let Some(hop) = geography::next_hop(geography::region_at(here, player.y), target) { let hop = region_here(state).and_then(|from| geography::next_step(from, target));
if let Some(hop) = hop {
let toward: Vec<Exit> = let toward: Vec<Exit> =
exits.iter().copied().filter(|exit| exit.destination(here) == Some(hop)).collect(); exits.iter().copied().filter(|exit| leads_to(state, exit, here, hop)).collect();
if !toward.is_empty() { if !toward.is_empty() {
return of(toward); return of(toward);
} }
@ -1893,7 +1927,8 @@ pub const fn move_index(kind: MacroKind) -> Option<u8> {
} }
} }
/// Whether `kind`'s move slot holds a move with PP: the four buttons' precondition (section 14). /// Whether `kind`'s move slot holds a move with PP that the battle engine will not answer with
/// nothing: the four buttons' precondition (section 14, row 60).
/// ///
/// Three things it is *not*, each of them a bug this palette has had: /// Three things it is *not*, each of them a bug this palette has had:
/// ///
@ -1921,23 +1956,41 @@ pub fn move_slot_bound(state: &mut dyn MacroState, kind: MacroKind) -> bool {
// all, so the button is bound there whatever the seam can make of the battler. That is the // all, so the button is bound there whatever the seam can make of the battler. That is the
// backstop `NEXT` used to be on this row (section 12.10): the own turn's main menu always has // backstop `NEXT` used to be on this row (section 12.10): the own turn's main menu always has
// a button that ends the turn, and it is never one that merely reopens a list. // a button that ends the turn, and it is never one that merely reopens a list.
if index == 0 && matches!(battle.menu, BattleMenu::Main { .. }) { let main = matches!(battle.menu, BattleMenu::Main { .. });
return true;
}
// And the same backstop over an **open move list** whose battler the seam cannot read // And the same backstop over an **open move list** whose battler the seam cannot read
// (section 12.11). That frame used to deal `BACK` alone -- the only button on it closed the // (section 12.11). That frame used to deal `BACK` alone -- the only button on it closed the
// list `MOVE 1` on the menu underneath had just opened, which is 12.10's pair with `MOVE 1` in // list `MOVE 1` on the menu underneath had just opened, which is 12.10's pair with `MOVE 1` in
// `NEXT`'s place. `MOVE 1`'s script over an open list confirms wherever the cursor stands, so // `NEXT`'s place. `MOVE 1`'s script over an open list confirms wherever the cursor stands, so
// it reads no move either, and confirming a move is what ends a turn. // it reads no move either, and confirming a move is what ends a turn.
let Some(own) = battle.own else { let Some(own) = battle.own else {
return index == 0 && matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. }); return index == 0
&& (main || matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. }));
}; };
let holds = |slot: usize| -> Option<&Move> { let holds = |slot: usize| -> Option<&Move> {
own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0) own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0)
}; };
let Some(entry) = holds(usize::from(index)) else { return false }; // **A move the cartridge will answer with nothing is not dealt beside one it will not**
// (row 60, section 12.23). Live on Route 1: Squirtle's TAIL WHIP against a Pidgey whose
// DEFENSE was already at -6 was `MOVE 2` 183 times, "Nothing happened!" every time, and no
// battle ended by the fly's hand. What the move does is the move table's and the effect
// routine's answer ([`MacroState::move_without_effect`]), read the same way for every move,
// and it is PP's rule over again: a spent move is not offered beside a usable one, and when
// nothing is usable what was dealt stays dealt -- taking the last moves away would leave a list
// whose only button is `BACK`, which is 12.11's pair.
let mut useful = [false; 4];
for (slot, flag) in useful.iter_mut().enumerate() {
if let Some(entry) = holds(slot).copied() {
*flag = entry.pp > 0 && !state.move_without_effect(entry.id);
}
}
let any_useful = useful.iter().any(|flag| *flag);
let entry = holds(usize::from(index)).copied();
if index == 0 && main {
return !any_useful || useful[0] || entry.is_none_or(|entry| entry.pp == 0);
}
let Some(entry) = entry else { return false };
if entry.pp > 0 { if entry.pp > 0 {
return true; return useful[usize::from(index)] || !any_useful;
} }
// Out of PP. Only `MOVE 1` stays, and only when nothing else has any either -- otherwise the // Out of PP. Only `MOVE 1` stays, and only when nothing else has any either -- otherwise the
// fly would be offered a spent move beside a usable one. // fly would be offered a spent move beside a usable one.

View file

@ -430,7 +430,9 @@ pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
Edge::East => connections.east, Edge::East => connections.east,
Edge::West => connections.west, Edge::West => connections.west,
}; };
if !connected { // A connection the headers name and no step on foot crosses -- Pallet Town's shore --
// is not a way out (`geography::NO_CROSSING`, row 59).
if !connected || !geography::crossable(player.map, edge) {
continue; continue;
} }
// A step off the edge of an outdoor map is the next area; off an interior one -- which // A step off the edge of an outdoor map is the next area; off an interior one -- which

View file

@ -536,18 +536,22 @@ impl MapGrid {
/// reachable ones is fenced in, and no amount of re-planning is going to help it /// reachable ones is fenced in, and no amount of re-planning is going to help it
/// (`docs/design/macros.md` section 15, `examples/scene_probe.rs`). /// (`docs/design/macros.md` section 15, `examples/scene_probe.rs`).
pub fn reachable_from(&self, x: u8, y: u8) -> usize { pub fn reachable_from(&self, x: u8, y: u8) -> usize {
if self.index(x, y).is_none() { self.reachable(x, y).iter().filter(|seen| **seen).count()
return 0; }
}
/// Whether `(tx, ty)` is among the tiles [`MapGrid::reachable_from`] counts from `(x, y)`.
///
/// The whole flood at once, row-major like the grid itself, so a caller asking about several
/// tiles pays for one walk. Off the map is never reachable.
pub fn reachable(&self, x: u8, y: u8) -> Reachable {
let mut seen = vec![false; self.tiles.len()]; let mut seen = vec![false; self.tiles.len()];
let Some(start) = self.index(x, y) else {
return Reachable { width: self.width, seen };
};
seen[start] = true;
let mut queue = std::collections::VecDeque::new(); let mut queue = std::collections::VecDeque::new();
if let Some(index) = self.index(x, y) {
seen[index] = true;
}
queue.push_back((x, y)); queue.push_back((x, y));
let mut count = 0;
while let Some((tx, ty)) = queue.pop_front() { while let Some((tx, ty)) = queue.pop_front() {
count += 1;
for facing in [Facing::Up, Facing::Down, Facing::Left, Facing::Right] { for facing in [Facing::Up, Facing::Down, Facing::Left, Facing::Right] {
if self.walled(tx, ty, facing) { if self.walled(tx, ty, facing) {
continue; continue;
@ -563,7 +567,30 @@ impl MapGrid {
queue.push_back((nx, ny)); queue.push_back((nx, ny));
} }
} }
count Reachable { width: self.width, seen }
}
}
/// The tiles a walk from one tile of a [`MapGrid`] could reach ([`MapGrid::reachable`]).
#[derive(Debug, Clone)]
pub struct Reachable {
width: u8,
seen: Vec<bool>,
}
impl Reachable {
/// Whether the walk reaches `(x, y)`.
pub fn contains(&self, x: u8, y: u8) -> bool {
x < self.width
&& self
.seen
.get(usize::from(y) * usize::from(self.width) + usize::from(x))
.copied()
.unwrap_or(false)
}
fn iter(&self) -> impl Iterator<Item = &bool> {
self.seen.iter()
} }
} }

View file

@ -111,6 +111,8 @@ struct World {
money: u32, money: u32,
bag: Vec<(u8, u8)>, bag: Vec<(u8, u8)>,
stock: Vec<u8>, stock: Vec<u8>,
/// Move ids the battle engine would answer with "Nothing happened!" on this frame (row 60).
no_effect: BTreeSet<u8>,
/// Tiles the game lets the player talk *over*: a mart's or a centre's counter. /// Tiles the game lets the player talk *over*: a mart's or a centre's counter.
counters: BTreeSet<Tile>, counters: BTreeSet<Tile>,
/// Errands this run has discharged (`docs/design/macros.md` section 13). /// Errands this run has discharged (`docs/design/macros.md` section 13).
@ -242,6 +244,7 @@ impl World {
pushes: BTreeSet::new(), pushes: BTreeSet::new(),
exhausted: BTreeSet::new(), exhausted: BTreeSet::new(),
stock: Vec::new(), stock: Vec::new(),
no_effect: BTreeSet::new(),
visited: BTreeSet::new(), visited: BTreeSet::new(),
stood: BTreeSet::new(), stood: BTreeSet::new(),
seen_maps: BTreeSet::new(), seen_maps: BTreeSet::new(),
@ -696,6 +699,10 @@ impl MacroState for World {
self.prompt && self.scene == Scene::Dialog self.prompt && self.scene == Scene::Dialog
} }
fn move_without_effect(&mut self, id: u8) -> bool {
self.no_effect.contains(&id)
}
fn shop_stock(&mut self) -> Vec<u8> { fn shop_stock(&mut self) -> Vec<u8> {
self.stock.clone() self.stock.clone()
} }
@ -847,14 +854,16 @@ fn settle(machine: &mut MacroMachine, world: &mut World) {
} }
} }
/// The cartridge gives the joypad back in the overworld: one frame of it, observed, and whatever /// The cartridge gives the joypad back in the overworld: the settle window of it, observed, and
/// it decided taken into the ledgers (row 58). /// whatever it decided taken into the ledgers (rows 58 and 59).
fn hand_back(machine: &mut MacroMachine, world: &mut World) { fn hand_back(machine: &mut MacroMachine, world: &mut World) {
world.scene = Scene::Overworld; world.scene = Scene::Overworld;
world.scripted = false; world.scripted = false;
world.scripted_at = None; world.scripted_at = None;
world.switch = None; world.switch = None;
machine.observe_frame(world); for _ in 0..super::executor::PUSH_SETTLE_FRAMES {
machine.observe_frame(world);
}
settle(machine, world); settle(machine, world);
} }
@ -3939,6 +3948,95 @@ fn a_turn_with_nothing_to_attack_switch_or_flee_with_still_has_a_button() {
assert!(pad_of(&mut world).contains(&"MOVE 1")); assert!(pad_of(&mut world).contains(&"MOVE 1"));
} }
/// `constants/move_constants.asm`: the two moves a level-5 Squirtle knows.
const TACKLE: u8 = 0x21;
const TAIL_WHIP: u8 = 0x27;
#[test]
fn a_move_the_cartridge_answers_with_nothing_is_off_the_pad_beside_one_it_does_not() {
// Row 60, live on Route 1: Squirtle L5 with TACKLE and TAIL WHIP, a Pidgey whose DEFENSE is
// already at -6, and `MOVE 2` chosen 183 times to "Nothing happened!". Over the menu and over
// the open list, TAIL WHIP leaves the pad and TACKLE stays.
let mut world = World::battle();
world.mons = vec![mon(0, 8, 20, &[(TACKLE, 35), (TAIL_WHIP, 30)])];
world.active = Some(0);
world.list = List::BattleMain;
assert!(on_the_pad(&mut world, MacroKind::Move2), "before the stage is at its limit");
world.no_effect.insert(TAIL_WHIP);
for list in [List::BattleMain, List::Moves(2)] {
world.list = list;
world.grid = list == List::BattleMain;
let pad = pad_of(&mut world);
assert!(!pad.contains(&"MOVE 2"), "{list:?} deals {pad:?}");
assert!(pad.contains(&"MOVE 1"), "{list:?} deals {pad:?}");
}
// Nothing presses for the fly: the button is gone, and nothing is chosen in its place.
world.list = List::BattleMain;
world.grid = true;
assert!(!move_slot_bound(&mut world, MacroKind::Move2));
// Slot one is read the same way: FIGHT's backstop over the menu is not a way to deal a move
// that does nothing beside one that does.
let mut swapped = World::battle();
swapped.mons = vec![mon(0, 8, 20, &[(TAIL_WHIP, 30), (TACKLE, 35)])];
swapped.active = Some(0);
swapped.list = List::BattleMain;
swapped.no_effect.insert(TAIL_WHIP);
assert_eq!(
pad_of(&mut swapped).iter().filter(|name| name.starts_with("MOVE")).collect::<Vec<_>>(),
[&"MOVE 2"]
);
}
#[test]
fn with_no_move_that_does_anything_the_moves_stay_as_pp_deals_them() {
// PP's own rule (section 12.8, row 30a): with nothing usable, what ends the turn stays on the
// pad. Taking every move away over an open list would leave `BACK` alone, which closes what
// `MOVE 1` on the menu underneath opened -- 12.11's pair.
let mut world = World::battle();
world.mons = vec![mon(0, 8, 20, &[(TACKLE, 0), (TAIL_WHIP, 30)])];
world.active = Some(0);
world.no_effect.insert(TAIL_WHIP);
world.list = List::Moves(2);
world.grid = false;
world.cursor_max = 1;
let pad = pad_of(&mut world);
assert!(pad.contains(&"MOVE 2"), "the one move with PP still ends the turn: {pad:?}");
assert_ne!(pad, ["BACK"]);
world.list = List::BattleMain;
world.grid = true;
let pad = pad_of(&mut world);
assert!(pad.contains(&"MOVE 1") && pad.contains(&"MOVE 2"), "{pad:?}");
// And both moves without effect: nothing changes from what PP alone deals.
let mut both = World::battle();
both.mons = vec![mon(0, 8, 20, &[(TACKLE, 35), (TAIL_WHIP, 30)])];
both.active = Some(0);
both.list = List::BattleMain;
let before = pad_of(&mut both);
both.no_effect.extend([TACKLE, TAIL_WHIP]);
assert_eq!(pad_of(&mut both), before);
}
#[test]
fn a_spent_move_and_a_move_without_effect_leave_the_one_that_works() {
// The two readings together: slot one spent, slot two refused, slot three usable. Only
// `MOVE 3` is a move; `MOVE 1` over the menu is FIGHT's backstop only while slot one is the
// thing that can end the turn, and it is spent -- row 34's behaviour, unchanged.
let mut world = World::battle();
world.mons = vec![mon(0, 8, 20, &[(TACKLE, 0), (TAIL_WHIP, 30), (0x2d, 40)])];
world.active = Some(0);
world.no_effect.insert(TAIL_WHIP);
world.list = List::Moves(3);
world.grid = false;
world.cursor_max = 2;
let moves: Vec<&str> =
pad_of(&mut world).into_iter().filter(|name| name.starts_with("MOVE")).collect();
assert_eq!(moves, ["MOVE 3"]);
}
/// The bound buttons of the macros-mode pad for the scene the world is in, unbound slots dropped. /// The bound buttons of the macros-mode pad for the scene the world is in, unbound slots dropped.
fn pad_of(world: &mut World) -> Vec<&'static str> { fn pad_of(world: &mut World) -> Vec<&'static str> {
let scene = world.scene(); let scene = world.scene();
@ -4064,18 +4162,20 @@ fn go_shop_and_go_heal_are_on_the_pad_while_their_errand_stands() {
#[test] #[test]
fn an_edge_the_table_cannot_name_stops_being_somewhere_new_once_it_is_stood_on() { fn an_edge_the_table_cannot_name_stops_being_somewhere_new_once_it_is_stood_on() {
// The rung-11 reading of row 54 (`infra/docs/macros-traps.md`). The cartridge reports Route // The rung-11 reading of row 54 (`infra/docs/macros-traps.md`). The cartridge reported Route
// 3's connections as north and west; `geography`'s row carries west and east, so the north // 3's connections as north and west while `geography`'s row carried west and east, so the
// edge's destination is unnameable -- and an unnameable destination counted as *unvisited*, // north edge's destination was unnameable -- and an unnameable destination counted as
// which made those tiles first-tier for `GO ROUTE` on every hold for ever, with // *unvisited*, which made those tiles first-tier for `GO ROUTE` on every hold for ever. Row 59
// `GO OBJECTIVE` off the pad beside them because nothing on this map leads to the objective. // corrected the row itself, and since then every header connection has a row; the rule is
// pinned on `$0B`, the one outdoor id with no header of its own (`UNUSED_MAP_0B`), whose
// edges the table cannot name.
let mut world = World::room(); let mut world = World::room();
world.map = maps::ROUTE_3; world.map = 0x0b;
world.size = MapSize { width: 8, height: 8 }; world.size = MapSize { width: 8, height: 8 };
world.player = Tile::new(4, 4); world.player = Tile::new(4, 4);
world.connections = Connections { north: true, south: false, east: false, west: true }; world.connections = Connections { north: true, south: false, east: false, west: true };
// West is Pewter City, which the table does name and the run has stood on. // West the run has already stood on.
world.seen_maps.insert(maps::PEWTER_CITY); world.visited.insert(ExitId::Edge(Edge::West));
let north: Vec<ExitId> = ways(&mut world, Way::Route).iter().map(|exit| exit.id).collect(); let north: Vec<ExitId> = ways(&mut world, Way::Route).iter().map(|exit| exit.id).collect();
assert!( assert!(
@ -5331,6 +5431,37 @@ fn facing_one_of_the_rungs_people_is_the_arrival() {
assert!(on_the_pad(&mut world, MacroKind::GoObjective), "turned away, the walk is back"); assert!(on_the_pad(&mut world, MacroKind::GoObjective), "turned away, the walk is back");
} }
#[test]
fn a_challenge_closing_onto_a_few_frames_of_overworld_is_still_a_challenge() {
// Row 59, Route 3's first trainer. The challenge text closes, and for five frames the screen
// is an ordinary overworld with nothing set -- no text, no script, no joypad bit, the battle
// not yet decided -- before `StartTrainerBattle` runs. Decided on the first of them, the walk
// the trainer interrupted walled (11, 6) for the session: the one gap east on Route 3.
let mut world = World::room().at(3, 3);
world.map = 0x00;
world.connections = Connections { north: true, south: false, east: false, west: false };
world.switch = Some((4, Scene::Dialog));
world.scripted_at = Some(4);
let north = TargetKey::Exit(ExitId::Edge(Edge::North));
let mut machine = MacroMachine::new(0x1234_5678);
assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoRoute), Ok(MacroAbort::Done));
// The text closes onto five frames of overworld, and then the battle is decided.
world.scene = Scene::Overworld;
world.scripted = false;
world.scripted_at = None;
world.switch = None;
for _ in 0..5 {
machine.observe_frame(&mut world);
}
world.scene = Scene::Battle { own_turn: false, forced_switch: false };
machine.observe_frame(&mut world);
settle(&mut machine, &mut world);
hand_back(&mut machine, &mut world);
assert!(!world.targets.blocked(world.map, north), "a challenge is not the road refusing");
assert!(world.pushes.is_empty(), "and the gap it was walking through is still ground");
}
#[test] #[test]
fn a_trainer_walking_up_teaches_the_ledgers_nothing() { fn a_trainer_walking_up_teaches_the_ledgers_nothing() {
// The other half of the gym. A walk toward the leader crossed the Jr. Trainer's line of sight; // The other half of the gym. A walk toward the leader crossed the Jr. Trainer's line of sight;

View file

@ -1,4 +1,4 @@
//! The Pokémon Red reward adapter, `pokered-unique8-v6`. //! The Pokémon Red reward adapter, `pokered-unique8-v7`.
//! //!
//! A port of the prototype's `src/reward/pokemon-red.ts`. The gates and budgets //! A port of the prototype's `src/reward/pokemon-red.ts`. The gates and budgets
//! are unchanged; `docs/rewards-learning.md` holds the live rule table and the //! are unchanged; `docs/rewards-learning.md` holds the live rule table and the
@ -6,9 +6,12 @@
//! ladder with the 38 rungs of `docs/design/ladder.md`; v5 adds one reward rule, //! ladder with the 38 rungs of `docs/design/ladder.md`; v5 adds one reward rule,
//! `boundary` (`docs/design/room-escape.md` section 2), which pays the first step //! `boundary` (`docs/design/room-escape.md` section 2), which pays the first step
//! next to and the first step onto each of a map's exits; v6 adds `catch`, the //! next to and the first step onto each of a map's exits; v6 adds `catch`, the
//! operator's decision of 2026-09-22, which pays for keeping a wild Pokémon. //! operator's decision of 2026-09-22, which pays for keeping a wild Pokémon; v7 adds
//! `talk` and `item` and stops `boundary` paying indoors, the operator's decision of
//! 2026-09-23 to pay for engaging with a building rather than for leaving it.
pub mod catalog; pub mod catalog;
pub mod engage;
#[cfg(test)] #[cfg(test)]
pub(crate) mod fake_wram; pub(crate) mod fake_wram;
pub mod macros; pub mod macros;
@ -33,33 +36,39 @@ use symbols::ram;
/// Adapter version, pinned into the checkpoint compatibility string. /// Adapter version, pinned into the checkpoint compatibility string.
/// ///
/// `v6` is the `catch` rule. Bumping it is what makes a `v5` checkpoint a decision /// `v7` is the engagement rules: `talk` and `item` pay, and `boundary` stops paying on an
/// rather than an accident: the compatibility string is compared whole before a /// indoor map (the operator, 2026-09-23). Bumping it is what makes a `v6` checkpoint a
/// restore is attempted, so a `v5` run is refused by default and resumed only when /// decision rather than an accident: the compatibility string is compared whole before a
/// the operator names it in `FLY_ACCEPT_ADAPTERS` /// restore is attempted, so a `v6` run is refused by default and resumed only when the
/// ([`crate::compatibility::RestoreDecision`], `docs/design/flysim.md`). That /// operator names it in `FLY_ACCEPT_ADAPTERS` ([`crate::compatibility::RestoreDecision`],
/// migration is safe in one direction only, and only for this pair: `v5`'s ledger is /// `docs/design/flysim.md`). That migration is safe in one direction only, and only for this
/// a `v6` ledger with the catch counter absent, and an absent counter reads as zero. /// pair: `v6`'s ledger is a `v7` ledger holding no `talk:`, `item:` or `hidden:` keys, and the
/// first sample after the restore seeds the item keys from the cartridge's own bits, so
/// nothing already picked up pays ([`engage::ItemFlags::seed`]).
/// ///
/// (`v5` was the `boundary` rule, and rejected `v4` because a ledger that had never /// (`v6` was the `catch` rule and migrated `v5` the same way: an absent counter reads as
/// zero. `v5` was the `boundary` rule, and rejected `v4` because a ledger that had never
/// recorded a `boundary:` key could not be resumed as though its exits were already /// recorded a `boundary:` key could not be resumed as though its exits were already
/// collected. `v4` was the 38-rung ladder, and rejected `v3` because a stored rank /// collected. `v4` was the 38-rung ladder, and rejected `v3` because a stored rank
/// that meant "4 badges" on the old ladder is not a rung on the new one. Neither of /// that meant "4 badges" on the old ladder is not a rung on the new one. Neither of
/// those is a migration: this one is, because nothing a `v5` ledger holds means /// those is a migration; the last two are, because nothing an older ledger holds means
/// something different under `v6`.) /// something different under the newer rules.)
pub const REWARD_ADAPTER: &str = "pokered-unique8-v6"; pub const REWARD_ADAPTER: &str = "pokered-unique8-v7";
/// Adapter ids whose checkpoints `v6` can read. /// Adapter ids whose checkpoints `v7` can read.
/// ///
/// Exactly one, and it is one because the `catch` rule adds a counter and changes nothing else: /// Exactly one, and it is one because the engagement rules add ledger keys and change nothing
/// a `v5` ledger restores as a `v6` ledger with `catchCounts` empty, and every other byte of the /// else a `v6` state holds: every field keeps its name, shape and meaning, the `talk:` keys start
/// state means what it meant. `v4` is not here -- its `seen` ledger holds no `boundary:` keys, so /// empty (no conversation was ever paid), and the `item:`/`hidden:` keys are seeded from the
/// resuming it would pay a second time for every exit the run had already found -- and neither is /// game's own flags on the first sample, so no pickup made under `v6` pays when a rollback
/// `v3`, whose stored rank is a rung on a different ladder. /// un-takes it. The `boundary:` keys a `v6` run earned indoors stay in the ledger and mean what
/// they meant -- `exit_visited` still reads them. `v5` is not here: the live run is `v6`, and
/// the one migration the operator asked for is the one this adapter tests. `v4` and `v3` stay
/// refused for the reasons [`REWARD_ADAPTER`] gives.
/// ///
/// Listing an id here is necessary but not sufficient: `FLY_ACCEPT_ADAPTERS` must name it too /// Listing an id here is necessary but not sufficient: `FLY_ACCEPT_ADAPTERS` must name it too
/// (`crate::compatibility::decide`, `docs/design/flysim.md`). /// (`crate::compatibility::decide`, `docs/design/flysim.md`).
pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v5"]; pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v6"];
/// The only cartridge semantic rewards are enabled for. Even the canonical /// The only cartridge semantic rewards are enabled for. Even the canonical
/// pret build stays disabled until reviewed; see `docs/rewards-learning.md`. /// pret build stays disabled until reviewed; see `docs/rewards-learning.md`.
@ -89,8 +98,19 @@ pub const SUPPORTED_ROM: &str =
/// empty, which is the truth about a run that was never paid for a catch. That is the /// empty, which is the truth about a run that was never paid for a catch. That is the
/// whole of the documented `v5` -> `v6` migration; see /// whole of the documented `v5` -> `v6` migration; see
/// [`crate::compatibility::RestoreDecision`]. /// [`crate::compatibility::RestoreDecision`].
///
/// *Not* bumped for the engagement rules either. `talk` and `item` key their payouts into the
/// existing `seen` array, the way `boundary` did, and the item seed is marked there too
/// ([`ITEMS_SEEDED`]); a `v6` state is structurally a `v7` state with none of those keys.
pub const STATE_VERSION: u64 = 4; pub const STATE_VERSION: u64 = 4;
/// The `seen` key that says the item keys have been seeded from the cartridge's flags.
///
/// Absent from every `v6` state and from a fresh adapter; the first playable sample that finds
/// it absent writes one `item:`/`hidden:` key per item the game already shows as taken, pays
/// nothing for any of them, and writes this.
const ITEMS_SEEDED: &str = "items:seeded";
/// Catch payouts one species may earn in the lifetime of a run's ledger. /// Catch payouts one species may earn in the lifetime of a run's ledger.
/// ///
/// The same cap and the same reason as the wild-KO rule's three: a species the fly can /// The same cap and the same reason as the wild-KO rule's three: a species the fly can
@ -441,6 +461,13 @@ pub struct PokemonRedReward {
battle: Option<Battle>, battle: Option<Battle>,
mode: String, mode: String,
/// The `talk` rule's frame-to-frame watch. Transient: never checkpointed, cleared by a
/// rollback and by a restore.
talk: engage::TalkWatch,
/// The item bitsets as of the last playable sample, so a pickup is a bit that *rose*.
/// Transient for the same reason.
item_flags: Option<engage::ItemFlags>,
/// Transient, recomputed every sample and never checkpointed. /// Transient, recomputed every sample and never checkpointed.
safe: bool, safe: bool,
progress: u32, progress: u32,
@ -491,6 +518,8 @@ impl PokemonRedReward {
stable: 0, stable: 0,
battle: None, battle: None,
mode: "BOOT".to_string(), mode: "BOOT".to_string(),
talk: engage::TalkWatch::default(),
item_flags: None,
safe: false, safe: false,
progress: 0, progress: 0,
badges: 0, badges: 0,
@ -635,6 +664,10 @@ impl PokemonRedReward {
self.stable = 0; self.stable = 0;
self.battle = None; self.battle = None;
self.safe = false; self.safe = false;
// A conversation or a pickup in flight across a rollback is not paid: the game the
// fly returns to has not had it. What *was* paid stays in `seen` and blocks a replay.
self.talk.clear();
self.item_flags = None;
let keys: Vec<String> = self.wild_wins.keys().cloned().collect(); let keys: Vec<String> = self.wild_wins.keys().cloned().collect();
for key in keys { for key in keys {
self.replay_blocked.insert(&key); self.replay_blocked.insert(&key);
@ -750,11 +783,13 @@ impl PokemonRedReward {
self.boundary(&mut emitted, memory, map, x, y, width, height, true, brain_ms); self.boundary(&mut emitted, memory, map, x, y, width, height, true, brain_ms);
self.initialized = true; self.initialized = true;
} }
self.items(&mut emitted, memory, brain_ms);
let in_battle = memory.read8(ram::wIsInBattle); let in_battle = memory.read8(ram::wIsInBattle);
if in_battle == 1 || in_battle == 2 || in_battle == 255 { if in_battle == 1 || in_battle == 2 || in_battle == 255 {
self.mode = "BATTLE".to_string(); self.mode = "BATTLE".to_string();
self.stable = 0; self.stable = 0;
self.talk.interrupt();
let species_paid = self.counts.get(kind::SPECIES); let species_paid = self.counts.get(kind::SPECIES);
if self.battle.is_none() && in_battle != 255 { if self.battle.is_none() && in_battle != 255 {
self.battle = Some(Battle { self.battle = Some(Battle {
@ -849,6 +884,18 @@ impl PokemonRedReward {
self.catch_counts.insert(key, (paid + 1).min(MAX_CATCH_PAYOUTS)); self.catch_counts.insert(key, (paid + 1).min(MAX_CATCH_PAYOUTS));
} }
} }
// The talk rule (`docs/rewards-learning.md`, the operator 2026-09-23): a conversation
// the fly opened indoors, paid once per (map, object) when its box closes.
if let Some(conversation) = self.talk.observe(memory, map, x, y) {
self.once(
&mut emitted,
&conversation.key(),
kind::TALK,
conversation.label(),
false,
brain_ms,
);
}
let location = format!("{map}:{x}:{y}"); let location = format!("{map}:{x}:{y}");
self.stable = if self.location == location { self.stable + 1 } else { 1 }; self.stable = if self.location == location { self.stable + 1 } else { 1 };
self.location = location.clone(); self.location = location.clone();
@ -1056,6 +1103,11 @@ impl PokemonRedReward {
/// What this is not: a path. No button is chosen here, nothing is planned, and no map /// What this is not: a path. No button is chosen here, nothing is planned, and no map
/// knowledge reaches the readout. It is a reward the fly may or may not find, like every other /// knowledge reaches the readout. It is a reward the fly may or may not find, like every other
/// rule in the catalog. /// rule in the catalog.
///
/// **Indoors it pays nothing** (the operator, 2026-09-23): on a map [`engage::indoor`] calls a
/// building, every key is still written to the ledger -- so [`GameAdapter::exit_visited`]
/// answers exactly what it did, and a door found indoors is found -- but no payout is emitted.
/// The exits of a town, a route, a forest or a cave pay as they always have.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn boundary( fn boundary(
&mut self, &mut self,
@ -1072,6 +1124,7 @@ impl PokemonRedReward {
// Collected first, paid second: the ledger writes need `&mut self` and the table walk // Collected first, paid second: the ledger writes need `&mut self` and the table walk
// needs the sample cache, and the order of the collection is the order of the payouts. // needs the sample cache, and the order of the collection is the order of the payouts.
let mut hits: Vec<(String, bool)> = Vec::new(); let mut hits: Vec<(String, bool)> = Vec::new();
let pays = !engage::indoor(memory.read8(ram::wCurMapTileset));
let warps = memory.read8(ram::wNumberOfWarps).min(MAX_WARP_EVENTS); let warps = memory.read8(ram::wNumberOfWarps).min(MAX_WARP_EVENTS);
for index in 0..u16::from(warps) { for index in 0..u16::from(warps) {
@ -1112,6 +1165,10 @@ impl PokemonRedReward {
continue; continue;
} }
let key = format!("{key}:{}", if on_exit { "on" } else { "near" }); let key = format!("{key}:{}", if on_exit { "on" } else { "near" });
if !pays {
self.seen.insert(&key);
continue;
}
self.once_scaled( self.once_scaled(
emitted, emitted,
&key, &key,
@ -1124,6 +1181,34 @@ impl PokemonRedReward {
} }
} }
/// Pay each item picked up since the last playable sample, once per item for the lifetime of
/// the ledger (`docs/rewards-learning.md`, the operator 2026-09-23).
///
/// The first sample that finds [`ITEMS_SEEDED`] absent -- a fresh adapter, or a `v6` state
/// restored under `v7` -- keys every item the cartridge already shows as taken and pays for
/// none of them. After that a pickup is a bit that rose between two playable samples
/// ([`engage::pickups`]) and pays unless its key is already in `seen`, which is what stops a
/// rollback that un-takes an item from paying for it twice.
fn items(
&mut self,
emitted: &mut Vec<RewardEvent>,
memory: &mut impl MemoryReader,
brain_ms: f64,
) {
let now = engage::ItemFlags::read(memory);
if !self.seen.contains(ITEMS_SEEDED) {
for key in now.seed() {
self.seen.insert(&key);
}
self.seen.insert(ITEMS_SEEDED);
} else if let Some(before) = &self.item_flags {
for pickup in engage::pickups(memory, before, &now) {
self.once(emitted, &pickup.key, kind::ITEM, pickup.label, false, brain_ms);
}
}
self.item_flags = Some(now);
}
pub fn export_state(&self) -> Value { pub fn export_state(&self) -> Value {
json!({ json!({
"version": STATE_VERSION, "version": STATE_VERSION,
@ -1287,6 +1372,8 @@ impl PokemonRedReward {
self.mode = mode.to_string(); self.mode = mode.to_string();
self.progress = progress; self.progress = progress;
self.badges = badges; self.badges = badges;
self.talk.clear();
self.item_flags = None;
Ok(()) Ok(())
} }
} }

View file

@ -145,6 +145,11 @@ pub mod poke {
/// zero in the overworld, non-zero from the frame a trainer's challenge closes to the end of /// zero in the overworld, non-zero from the frame a trainer's challenge closes to the end of
/// the battle, including the 219 frames of the battle transition in between. /// the battle, including the 219 frames of the battle transition in between.
pub const CUR_OPPONENT: u16 = super::ram::wBattleType - 1; pub const CUR_OPPONENT: u16 = super::ram::wBattleType - 1;
/// `constants/ram_constants.asm`: `wStatusFlags7` bit 3, `BIT_TRAINER_BATTLE` (row 61). Set by
/// `CheckFightingMapTrainers` (`home/trainers.asm`) on the frame a trainer sees the player,
/// cleared at `.battleOccurred` (`home/overworld.asm`) once the battle is over -- before the
/// blackout check, so a lost battle clears it too. Nothing else writes it.
pub const TRAINER_BATTLE_STATUS7: u8 = 1 << 3;
/// `constants/battle_constants.asm`: the non-volatile status byte. /// `constants/battle_constants.asm`: the non-volatile status byte.
pub const SLP_MASK: u8 = 0b111; pub const SLP_MASK: u8 = 0b111;
@ -232,6 +237,54 @@ pub mod poke {
/// this fixed point. /// this fixed point.
pub const PLAYER_SCREEN_X: i32 = 8; pub const PLAYER_SCREEN_X: i32 = 8;
pub const PLAYER_SCREEN_Y: i32 = 9; pub const PLAYER_SCREEN_Y: i32 = 9;
/// The cartridge's move table and the battle engine's answers to it (row 60,
/// `docs/design/macros.md` 12.23).
pub mod moves {
/// `data/moves/moves.asm`: `Moves` opens `SECTION "Battle Engine 7"`, which
/// `layout.link` places first in ROM bank `$0E`, so the table starts at `$0E:$4000`.
/// Six bytes a row (`MOVE_LENGTH`): animation (the move id itself), effect, power,
/// type, accuracy, PP, rows in move-id order from `POUND` (1).
pub const TABLE_BANK: u8 = 0x0e;
pub const TABLE_ADDRESS: u16 = 0x4000;
pub const ROW_BYTES: u16 = 6;
/// `constants/move_constants.asm`: `NUM_ATTACKS`, `STRUGGLE` (`$a5`) the last.
pub const LAST_MOVE: u8 = 0xa5;
/// `constants/move_effect_constants.asm`: the stat-stage effects, each run in stage
/// order ATTACK, DEFENSE, SPEED, SPECIAL, ACCURACY, EVASION.
pub const ATTACK_UP1: u8 = 0x0a;
pub const EVASION_UP1: u8 = 0x0f;
pub const ATTACK_DOWN1: u8 = 0x12;
pub const EVASION_DOWN1: u8 = 0x17;
pub const SLEEP: u8 = 0x20;
pub const ATTACK_UP2: u8 = 0x32;
pub const EVASION_UP2: u8 = 0x37;
pub const ATTACK_DOWN2: u8 = 0x3a;
pub const EVASION_DOWN2: u8 = 0x3f;
pub const POISON: u8 = 0x42;
pub const PARALYZE: u8 = 0x43;
/// `constants/battle_constants.asm`: a stage byte is 1 (-6) to `MAX_STAT_LEVEL` 13 (+6),
/// 7 normal; `MAX_STAT_VALUE` 999. The first four stages have a stat behind them
/// (`wBattleMonAttack` onwards, big-endian words); accuracy and evasion do not.
pub const MIN_STAGE: u8 = 1;
pub const MAX_STAGE: u8 = 13;
pub const STATS_WITH_VALUES: u8 = 4;
pub const MIN_STAT: u16 = 1;
pub const MAX_STAT: u16 = 999;
/// `wEnemyBattleStatus2` bits: `PROTECTED_BY_MIST` 1, `HAS_SUBSTITUTE_UP` 4,
/// `NEEDS_TO_RECHARGE` 5.
pub const MIST: u8 = 1 << 1;
pub const SUBSTITUTE: u8 = 1 << 4;
pub const RECHARGE: u8 = 1 << 5;
/// `constants/type_constants.asm`.
pub const TYPE_POISON: u8 = 0x03;
pub const TYPE_GROUND: u8 = 0x04;
pub const TYPE_ELECTRIC: u8 = 0x17;
}
} }
fn read(memory: &mut dyn MemoryReader, address: u16) -> u8 { fn read(memory: &mut dyn MemoryReader, address: u16) -> u8 {
@ -625,6 +678,114 @@ fn enemy_mon(memory: &mut dyn MemoryReader) -> Option<EnemyMon> {
}) })
} }
/// One row of the cartridge's move table (`data/moves/moves.asm`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MoveData {
pub id: u8,
pub effect: u8,
pub power: u8,
pub kind: u8,
pub accuracy: u8,
pub pp: u8,
}
/// Move `id`'s row of the move table, read from the cartridge image.
///
/// `None` when the seam has no cartridge behind it, when `id` is not a move, or when the row does
/// not open with its own id -- every row of `Moves` does (`move`'s first byte is the animation,
/// "interchangeable with move id"), so a table that is not where the disassembly puts it answers
/// nothing rather than a neighbour's effect.
pub fn move_data(memory: &mut dyn MemoryReader, id: u8) -> Option<MoveData> {
use poke::moves::{LAST_MOVE, ROW_BYTES, TABLE_ADDRESS, TABLE_BANK};
if id == 0 || id > LAST_MOVE {
return None;
}
let base = TABLE_ADDRESS + u16::from(id - 1) * ROW_BYTES;
let mut row = [0u8; 6];
for (offset, byte) in row.iter_mut().enumerate() {
*byte = memory.read_rom(TABLE_BANK, base + offset as u16)?;
}
if row[0] != id {
return None;
}
Some(MoveData {
id,
effect: row[1],
power: row[2],
kind: row[3],
accuracy: row[4],
pp: row[5],
})
}
/// Whether the battle engine will answer the fly's move `id` with nothing at all, on this frame.
///
/// Row 60 (`docs/design/macros.md` 12.23): Squirtle's TAIL WHIP against a Pidgey whose DEFENSE
/// was already at -6 printed "Nothing happened!" 183 times on Route 1. These are the refusals the
/// effect routines in `engine/battle/effects.asm` make on bytes that are already in WRAM when the
/// move is chosen, for a move that deals no damage (a move with power is always attempted (type immunity, e.g. TACKLE against GASTLY, is not answered here)):
///
/// - a stat-raising effect (`StatModifierUpEffect`): the user's stage is already +6, or the stat
/// itself is already 999;
/// - a stat-lowering effect (`StatModifierDownEffect`, `MoveHitTest`): the target has a
/// substitute or Mist, its stage is already -6, or the stat itself is already 1;
/// - `SleepEffect`: the target already has a status and is not recharging;
/// - `PoisonEffect`: a substitute, a status, or a Poison type;
/// - `ParalyzeEffect`: a status, or an Electric move against a Ground type.
///
/// `Some(false)` for every other move, which the cartridge may still miss -- a miss is a roll,
/// and this answers only what is already decided. `None` outside a battle this module
/// understands, when the move table cannot be read, or when a stage byte is out of its range:
/// a refusal this module cannot read is not one it reports.
pub fn move_without_effect(memory: &mut dyn MemoryReader, id: u8) -> Option<bool> {
use poke::moves::*;
in_battle(memory)?;
let data = move_data(memory, id)?;
if data.power != 0 {
return Some(false);
}
let stage = |memory: &mut dyn MemoryReader, base: u16, stat: u8| -> Option<u8> {
Some(read(memory, base + u16::from(stat))).filter(|stage| (MIN_STAGE..=MAX_STAGE).contains(stage))
};
let value = |memory: &mut dyn MemoryReader, base: u16, stat: u8| -> Option<u16> {
(stat < STATS_WITH_VALUES).then(|| word_be(memory, base + 2 * u16::from(stat)))
};
let effect = data.effect;
let raised = match effect {
ATTACK_UP1..=EVASION_UP1 => Some(effect - ATTACK_UP1),
ATTACK_UP2..=EVASION_UP2 => Some(effect - ATTACK_UP2),
_ => None,
};
if let Some(stat) = raised {
let at = stage(memory, ram::wPlayerMonStatMods, stat)?;
return Some(at >= MAX_STAGE || value(memory, ram::wBattleMonAttack, stat) == Some(MAX_STAT));
}
let target = read(memory, ram::wEnemyBattleStatus2);
let lowered = match effect {
ATTACK_DOWN1..=EVASION_DOWN1 => Some(effect - ATTACK_DOWN1),
ATTACK_DOWN2..=EVASION_DOWN2 => Some(effect - ATTACK_DOWN2),
_ => None,
};
if let Some(stat) = lowered {
let at = stage(memory, ram::wEnemyMonStatMods, stat)?;
return Some(
target & (SUBSTITUTE | MIST) != 0
|| at <= MIN_STAGE
|| value(memory, ram::wEnemyMonAttack, stat) == Some(MIN_STAT),
);
}
let status = read(memory, ram::wEnemyMonStatus);
let types = [read(memory, ram::wEnemyMonType1), read(memory, ram::wEnemyMonType1 + 1)];
Some(match effect {
SLEEP => status != 0 && target & RECHARGE == 0,
POISON => target & SUBSTITUTE != 0 || status != 0 || types.contains(&TYPE_POISON),
PARALYZE => {
status != 0 || (data.kind == TYPE_ELECTRIC && types.contains(&TYPE_GROUND))
}
_ => false,
})
}
/// Whether a text box is open, and whether the bottom-of-screen dialogue box is the one drawn. /// Whether a text box is open, and whether the bottom-of-screen dialogue box is the one drawn.
/// ///
/// `open` is `wFontLoaded`'s bit 0, which `DisplayTextIDInit` sets for every text display — the /// `open` is `wFontLoaded`'s bit 0, which `DisplayTextIDInit` sets for every text display — the
@ -1685,8 +1846,14 @@ impl<'a> PokeState<'a> {
} }
impl GameState for PokeState<'_> { impl GameState for PokeState<'_> {
/// [`super::scene::detect`], except that an overworld frame inside a trainer's challenge is
/// the cartridge's ([`trainer_engaged`], row 61): section 12.13's `Unknown` with no text box,
/// an empty pad the fly waits out, and no frame a held push-back is decided on.
fn scene(&mut self) -> Scene { fn scene(&mut self) -> Scene {
super::scene::detect(self.memory) match super::scene::detect(self.memory) {
Scene::Overworld if trainer_engaged(self.memory) => Scene::Unknown,
scene => scene,
}
} }
fn player(&mut self) -> Option<Player> { fn player(&mut self) -> Option<Player> {
@ -1754,6 +1921,27 @@ impl GameState for PokeState<'_> {
} }
} }
/// Whether a trainer who saw the player is between its "!" and the end of its battle (row 61).
///
/// Two stretches of that window read as an overworld the fly owned, both measured in Viridian
/// Forest: the "!" bubble, about sixty frames, drawn before `CheckFightingMapTrainers` sets
/// `wJoyIgnore`; and five frames after the challenge text, because
/// `DisplayEnemyTrainerTextAndStartBattle` (`home/trainers.asm`) clears `wJoyIgnore` before the
/// text and calls `StartTrainerBattle`, which writes `wCurOpponent`, only after the text's
/// close-down has redrawn the map. About sixty-six frames per engagement, every bit
/// [`controllable`] reads clear. The push-back a walk earned when the trainer took the joypad (row
/// 58's held entry) was written on the first frame after the text: the one free tile beside the
/// trainer, in the only corridor to the forest's north gate, walled for the session.
///
/// **The macros' reading only.** [`controllable`] and [`super::scene::detect`] are shared with
/// the reward adapter (the talk payout's "ready" test) and do not change; [`PokeState`]'s own
/// `scene` and `scripted` read this beside them. In macros mode the feed's `game.scene` is the
/// palette's, so it reads `unknown` on these frames, as the contract has it for a frame the
/// cartridge is driving.
pub fn trainer_engaged(memory: &mut dyn MemoryReader) -> bool {
read(memory, ram::wStatusFlags7) & poke::TRAINER_BATTLE_STATUS7 != 0
}
/// The cartridge tables on their defaults, and the exploration ledger wired through. /// The cartridge tables on their defaults, and the exploration ledger wired through.
/// ///
/// `pokemon_red/macros/cartridge.rs` defaults every [`MacroState`] method and every default /// `pokemon_red/macros/cartridge.rs` defaults every [`MacroState`] method and every default
@ -1767,7 +1955,7 @@ impl GameState for PokeState<'_> {
/// taken rather than at the nearest door (`docs/design/macros.md` section 3). /// taken rather than at the nearest door (`docs/design/macros.md` section 3).
impl MacroState for PokeState<'_> { impl MacroState for PokeState<'_> {
fn scripted(&mut self) -> bool { fn scripted(&mut self) -> bool {
!controllable(self.memory) !controllable(self.memory) || trainer_engaged(self.memory)
} }
fn text_open(&mut self) -> bool { fn text_open(&mut self) -> bool {
@ -1782,6 +1970,10 @@ impl MacroState for PokeState<'_> {
yes_no_prompt(self.memory) yes_no_prompt(self.memory)
} }
fn move_without_effect(&mut self, id: u8) -> bool {
move_without_effect(self.memory, id).unwrap_or(false)
}
/// The whole loaded map's walkability, from the cache when it is for this map /// The whole loaded map's walkability, from the cache when it is for this map
/// (`docs/design/macros.md` section 15). /// (`docs/design/macros.md` section 15).
/// ///

View file

@ -1067,3 +1067,189 @@ fn a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map() {
let off: Vec<u8> = offscreen_npcs(&mut wram).iter().map(|npc| npc.slot).collect(); let off: Vec<u8> = offscreen_npcs(&mut wram).iter().map(|npc| npc.slot).collect();
assert_eq!(off, vec![1], "only the leader is still off the screen from (4, 8)"); assert_eq!(off, vec![1], "only the leader is still off the screen from (4, 8)");
} }
/// Row 60's cartridge rows, as `data/moves/moves.asm` has them: `(id, effect, power, type)`.
const TACKLE: (u8, u8, u8, u8) = (0x21, 0x00, 35, 0x00);
const TAIL_WHIP: (u8, u8, u8, u8) = (0x27, 0x13, 0, 0x00);
const GROWL: (u8, u8, u8, u8) = (0x2d, 0x12, 0, 0x00);
const SCREECH: (u8, u8, u8, u8) = (0x67, 0x3b, 0, 0x00);
const WITHDRAW: (u8, u8, u8, u8) = (0x6e, 0x0b, 0, 0x15);
const SAND_ATTACK: (u8, u8, u8, u8) = (0x1c, 0x16, 0, 0x00);
const SLEEP_POWDER: (u8, u8, u8, u8) = (0x4f, 0x20, 0, 0x16);
const POISONPOWDER: (u8, u8, u8, u8) = (0x4d, 0x42, 0, 0x03);
const THUNDER_WAVE: (u8, u8, u8, u8) = (0x56, 0x43, 0, 0x17);
/// `AURORA_BEAM`: a damaging move with a stat side effect, which always does something.
const AURORA_BEAM: (u8, u8, u8, u8) = (0x3e, 0x44, 65, 0x19);
fn stage_battle() -> Wram {
let mut wram = Wram::new();
wram.battle_mon(0, 0xb1, 5, 8, 20, 0, &[(0x21, 35), (0x27, 30)])
.enemy_mon(0x24, 2, 13, 13)
.battle(1)
.normal_stages()
.move_table(&[
TACKLE,
TAIL_WHIP,
GROWL,
SCREECH,
WITHDRAW,
SAND_ATTACK,
SLEEP_POWDER,
POISONPOWDER,
THUNDER_WAVE,
AURORA_BEAM,
]);
wram
}
#[test]
fn the_move_table_is_read_by_id_and_checked_against_its_own_first_byte() {
let mut wram = stage_battle();
let tail_whip = move_data(&mut wram, TAIL_WHIP.0).expect("a row");
assert_eq!((tail_whip.effect, tail_whip.power), (0x13, 0));
assert_eq!(move_data(&mut wram, 0), None, "no move zero");
assert_eq!(move_data(&mut wram, 0xa6), None, "past STRUGGLE");
assert_eq!(move_data(&mut wram, 0x22), None, "a row the cartridge image does not answer");
// A table that is not where the disassembly says: a row that does not open with its own id
// is somebody else's row, and it is not read as this one.
let mut shifted = Wram::new();
shifted.move_table(&[(0x28, 0x13, 0, 0)]);
let base = poke::moves::TABLE_ADDRESS + 0x26 * poke::moves::ROW_BYTES;
for offset in 0..6 {
let byte = shifted.read_rom(poke::moves::TABLE_BANK, base + 6 + offset).unwrap();
shifted.rom_byte(poke::moves::TABLE_BANK, base + offset, byte);
}
assert_eq!(move_data(&mut shifted, 0x27), None);
assert!(move_data(&mut shifted, 0x28).is_some());
}
#[test]
fn tail_whip_does_nothing_at_minus_six_and_something_before() {
// Row 60: the Pidgey's DEFENSE stage walked down from 7 to 1 by TAIL WHIP after TAIL WHIP.
let mut wram = stage_battle();
for stage in 2..=7 {
wram.set(ram::wEnemyMonStatMods + 1, stage);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(false), "stage {stage}");
}
wram.set(ram::wEnemyMonStatMods + 1, 1);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
// The -2 variant reads the same stage.
assert_eq!(move_without_effect(&mut wram, SCREECH.0), Some(true));
// TACKLE deals damage whatever the stages say.
assert_eq!(move_without_effect(&mut wram, TACKLE.0), Some(false));
// And a damaging move with a stat side effect is never "nothing".
wram.set(ram::wEnemyMonStatMods + 3, 1);
assert_eq!(move_without_effect(&mut wram, AURORA_BEAM.0), Some(false));
// GROWL reads ATTACK's stage, not DEFENSE's.
assert_eq!(move_without_effect(&mut wram, GROWL.0), Some(false));
wram.set(ram::wEnemyMonStatMods, 1);
assert_eq!(move_without_effect(&mut wram, GROWL.0), Some(true));
// SAND-ATTACK reads ACCURACY's, which has no stat value behind it.
assert_eq!(move_without_effect(&mut wram, SAND_ATTACK.0), Some(false));
wram.set(ram::wEnemyMonStatMods + 4, 1);
assert_eq!(move_without_effect(&mut wram, SAND_ATTACK.0), Some(true));
}
#[test]
fn a_stat_already_at_one_or_nine_hundred_ninety_nine_refuses_before_the_stage_does() {
// `StatModifierDownEffect` restores the stage and prints "Nothing happened!" when the stat
// itself is already 1 -- a level-2 Pidgey's DEFENSE gets there before -6.
let mut wram = stage_battle();
wram.set(ram::wEnemyMonStatMods + 1, 4).set_word_be(ram::wEnemyMonAttack + 2, 1);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
wram.set_word_be(ram::wEnemyMonAttack + 2, 2);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(false));
// Raising: +6, or a stat of 999.
wram.set(ram::wPlayerMonStatMods + 1, 12);
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(false));
wram.set(ram::wPlayerMonStatMods + 1, 13);
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(true));
wram.set(ram::wPlayerMonStatMods + 1, 9).set_word_be(ram::wBattleMonAttack + 2, 999);
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(true));
}
#[test]
fn mist_and_a_substitute_turn_a_stat_lowering_move_away() {
let mut wram = stage_battle();
wram.set(ram::wEnemyBattleStatus2, poke::moves::MIST);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
wram.set(ram::wEnemyBattleStatus2, poke::moves::SUBSTITUTE);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
assert_eq!(move_without_effect(&mut wram, POISONPOWDER.0), Some(true));
// A raise is the user's own business.
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(false));
}
#[test]
fn a_status_move_against_a_target_it_cannot_affect_does_nothing() {
let mut wram = stage_battle();
for id in [SLEEP_POWDER.0, POISONPOWDER.0, THUNDER_WAVE.0] {
assert_eq!(move_without_effect(&mut wram, id), Some(false), "healthy target, {id:#04x}");
}
// Any status: already asleep, poisoned, paralysed.
wram.set(ram::wEnemyMonStatus, 1 << 6);
for id in [SLEEP_POWDER.0, POISONPOWDER.0, THUNDER_WAVE.0] {
assert_eq!(move_without_effect(&mut wram, id), Some(true), "statused target, {id:#04x}");
}
// A target that must recharge is put to sleep whatever its status (`SleepEffect`).
wram.set(ram::wEnemyBattleStatus2, poke::moves::RECHARGE);
assert_eq!(move_without_effect(&mut wram, SLEEP_POWDER.0), Some(false));
// Types: a Poison type is not poisoned; a Ground type is not paralysed by an Electric move.
let mut typed = stage_battle();
typed.set(ram::wEnemyMonType1 + 1, poke::moves::TYPE_POISON);
assert_eq!(move_without_effect(&mut typed, POISONPOWDER.0), Some(true));
typed.set(ram::wEnemyMonType1 + 1, 0).set(ram::wEnemyMonType1, poke::moves::TYPE_GROUND);
assert_eq!(move_without_effect(&mut typed, THUNDER_WAVE.0), Some(true));
assert_eq!(move_without_effect(&mut typed, POISONPOWDER.0), Some(false));
}
#[test]
fn a_refusal_that_cannot_be_read_is_not_reported() {
// Out of battle, no cartridge behind the seam, or a stage byte out of its 1..=13 range.
let mut wram = stage_battle();
wram.set(ram::wIsInBattle, 0);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), None);
let mut bare = Wram::new();
bare.battle(1).normal_stages().set(ram::wEnemyMonStatMods + 1, 1);
assert_eq!(move_without_effect(&mut bare, TAIL_WHIP.0), None, "no move table");
let mut wram = stage_battle();
wram.set(ram::wEnemyMonStatMods + 1, 0);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), None);
// And the seam's own answer is "leave the button where it was".
let mut unread = Wram::new();
assert!(!PokeState::new(&mut unread).move_without_effect(TAIL_WHIP.0));
let mut read = stage_battle();
read.set(ram::wEnemyMonStatMods + 1, 1);
assert!(PokeState::new(&mut read).move_without_effect(TAIL_WHIP.0));
}
#[test]
fn a_trainers_challenge_is_the_cartridges_until_its_battle_is_over() {
use crate::pokemon_red::macros::state::GameState;
// Row 61. A trainer who saw the fly: its "!" bubble (before `wJoyIgnore` is set) and the five
// frames after its text (before `wCurOpponent` is) have every bit `controllable` reads clear.
// The macros read them as the cartridge's; the shared readings the reward adapter uses do not
// move.
let mut wram = Wram::overworld();
assert_eq!(PokeState::new(&mut wram).scene(), Scene::Overworld);
assert!(!PokeState::new(&mut wram).scripted());
wram.set(ram::wStatusFlags7, poke::TRAINER_BATTLE_STATUS7);
assert!(trainer_engaged(&mut wram));
assert_eq!(PokeState::new(&mut wram).scene(), Scene::Unknown, "inside the challenge");
assert!(PokeState::new(&mut wram).scripted(), "and the fly is not its own master");
assert!(controllable(&mut wram), "the adapter's gate is unchanged");
assert_eq!(crate::pokemon_red::scene::detect(&mut wram), Scene::Overworld, "and the shared scene");
// The challenge's own text is still a conversation to advance.
wram.dialogue_box();
assert_eq!(PokeState::new(&mut wram).scene(), Scene::Dialog);
// `.battleOccurred` clears the bit; the other bits of the byte are not a challenge:
// `BIT_NO_MAP_MUSIC` after a rival, `BIT_USE_CUR_MAP_SCRIPT` from a trainer talked to.
let mut after = Wram::overworld();
after.set(ram::wStatusFlags7, (1 << 1) | (1 << 4) | (1 << 7));
assert!(!trainer_engaged(&mut after));
assert_eq!(PokeState::new(&mut after).scene(), Scene::Overworld);
assert!(!PokeState::new(&mut after).scripted());
}

View file

@ -23,29 +23,38 @@ pub mod ram {
pub const wMaxMenuItem: u16 = 0xcc28; // 52264 pub const wMaxMenuItem: u16 = 0xcc28; // 52264
pub const wMenuWatchedKeys: u16 = 0xcc29; // 52265 pub const wMenuWatchedKeys: u16 = 0xcc29; // 52265
pub const wPlayerMonNumber: u16 = 0xcc2f; // 52271 pub const wPlayerMonNumber: u16 = 0xcc2f; // 52271
pub const wPlayerMonStatMods: u16 = 0xcd1a; // 52506
pub const wEnemyMonStatMods: u16 = 0xcd2e; // 52526
pub const wSimulatedJoypadStatesIndex: u16 = 0xcd38; // 52536 pub const wSimulatedJoypadStatesIndex: u16 = 0xcd38; // 52536
pub const wMiscFlags: u16 = 0xcd60; // 52576 pub const wMiscFlags: u16 = 0xcd60; // 52576
pub const wJoyIgnore: u16 = 0xcd6b; // 52587 pub const wJoyIgnore: u16 = 0xcd6b; // 52587
pub const wNumMovesMinusOne: u16 = 0xcd6c; // 52588 pub const wNumMovesMinusOne: u16 = 0xcd6c; // 52588
pub const wBattleResult: u16 = 0xcf0b; // 53003 pub const wBattleResult: u16 = 0xcf0b; // 53003
pub const wSpriteIndex: u16 = 0xcf13; // 53011
pub const wItemList: u16 = 0xcf7b; // 53115 pub const wItemList: u16 = 0xcf7b; // 53115
pub const wListMenuID: u16 = 0xcf94; // 53140 pub const wListMenuID: u16 = 0xcf94; // 53140
pub const wFontLoaded: u16 = 0xcfc4; // 53188 pub const wFontLoaded: u16 = 0xcfc4; // 53188
pub const wWalkCounter: u16 = 0xcfc5; // 53189
pub const wEnemyMonSpecies: u16 = 0xcfe5; // 53221 pub const wEnemyMonSpecies: u16 = 0xcfe5; // 53221
pub const wEnemyMonHP: u16 = 0xcfe6; // 53222 pub const wEnemyMonHP: u16 = 0xcfe6; // 53222
pub const wEnemyMonStatus: u16 = 0xcfe9; // 53225
pub const wEnemyMonType1: u16 = 0xcfea; // 53226
pub const wEnemyMonLevel: u16 = 0xcff3; // 53235 pub const wEnemyMonLevel: u16 = 0xcff3; // 53235
pub const wEnemyMonMaxHP: u16 = 0xcff4; // 53236 pub const wEnemyMonMaxHP: u16 = 0xcff4; // 53236
pub const wEnemyMonAttack: u16 = 0xcff6; // 53238
pub const wBattleMonSpecies: u16 = 0xd014; // 53268 pub const wBattleMonSpecies: u16 = 0xd014; // 53268
pub const wBattleMonHP: u16 = 0xd015; // 53269 pub const wBattleMonHP: u16 = 0xd015; // 53269
pub const wBattleMonStatus: u16 = 0xd018; // 53272 pub const wBattleMonStatus: u16 = 0xd018; // 53272
pub const wBattleMonMoves: u16 = 0xd01c; // 53276 pub const wBattleMonMoves: u16 = 0xd01c; // 53276
pub const wBattleMonLevel: u16 = 0xd022; // 53282 pub const wBattleMonLevel: u16 = 0xd022; // 53282
pub const wBattleMonMaxHP: u16 = 0xd023; // 53283 pub const wBattleMonMaxHP: u16 = 0xd023; // 53283
pub const wBattleMonAttack: u16 = 0xd025; // 53285
pub const wBattleMonPP: u16 = 0xd02d; // 53293 pub const wBattleMonPP: u16 = 0xd02d; // 53293
pub const wTrainerClass: u16 = 0xd031; // 53297 pub const wTrainerClass: u16 = 0xd031; // 53297
pub const wIsInBattle: u16 = 0xd057; // 53335 pub const wIsInBattle: u16 = 0xd057; // 53335
pub const wBattleType: u16 = 0xd05a; // 53338 pub const wBattleType: u16 = 0xd05a; // 53338
pub const wTrainerNo: u16 = 0xd05d; // 53341 pub const wTrainerNo: u16 = 0xd05d; // 53341
pub const wEnemyBattleStatus2: u16 = 0xd068; // 53352
pub const wPartyMenuTypeOrMessageID: u16 = 0xd07d; // 53373 pub const wPartyMenuTypeOrMessageID: u16 = 0xd07d; // 53373
pub const wCapturedMonSpecies: u16 = 0xd11c; // 53532 pub const wCapturedMonSpecies: u16 = 0xd11c; // 53532
pub const wForcePlayerToChooseMon: u16 = 0xd11f; // 53535 pub const wForcePlayerToChooseMon: u16 = 0xd11f; // 53535
@ -71,11 +80,15 @@ pub mod ram {
pub const wSignCoords: u16 = 0xd4b1; // 54449 pub const wSignCoords: u16 = 0xd4b1; // 54449
pub const wSignTextIDs: u16 = 0xd4d1; // 54481 pub const wSignTextIDs: u16 = 0xd4d1; // 54481
pub const wNumSprites: u16 = 0xd4e1; // 54497 pub const wNumSprites: u16 = 0xd4e1; // 54497
pub const wMapSpriteExtraData: u16 = 0xd504; // 54532
pub const wTilesetBank: u16 = 0xd52b; // 54571 pub const wTilesetBank: u16 = 0xd52b; // 54571
pub const wTilesetBlocksPtr: u16 = 0xd52c; // 54572 pub const wTilesetBlocksPtr: u16 = 0xd52c; // 54572
pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576 pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576
pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578 pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578
pub const wNumHoFTeams: u16 = 0xd5a2; // 54690 pub const wNumHoFTeams: u16 = 0xd5a2; // 54690
pub const wToggleableObjectFlags: u16 = 0xd5a6; // 54694
pub const wToggleableObjectList: u16 = 0xd5ce; // 54734
pub const wObtainedHiddenItemsFlags: u16 = 0xd6f0; // 55024
pub const wStatusFlags5: u16 = 0xd730; // 55088 pub const wStatusFlags5: u16 = 0xd730; // 55088
pub const wStatusFlags6: u16 = 0xd732; // 55090 pub const wStatusFlags6: u16 = 0xd732; // 55090
pub const wStatusFlags7: u16 = 0xd733; // 55091 pub const wStatusFlags7: u16 = 0xd733; // 55091

View file

@ -865,8 +865,8 @@ fn the_recent_ticker_keeps_the_newest_eight_events_newest_first() {
#[test] #[test]
fn the_adapter_reports_its_identity_and_pinned_rom() { fn the_adapter_reports_its_identity_and_pinned_rom() {
let reward = PokemonRedReward::new(); let reward = PokemonRedReward::new();
assert_eq!(reward.id(), "pokered-unique8-v6"); assert_eq!(reward.id(), "pokered-unique8-v7");
assert_eq!(reward.migrates_from(), ["pokered-unique8-v5"]); assert_eq!(reward.migrates_from(), ["pokered-unique8-v6"]);
assert!(reward.rom_allowed(SUPPORTED_ROM)); assert!(reward.rom_allowed(SUPPORTED_ROM));
assert!(!reward.rom_allowed( assert!(!reward.rom_allowed(
"5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b" "5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b"
@ -1356,3 +1356,541 @@ fn a_rung_earned_out_of_order_does_not_skip_the_ones_under_it() {
f.visit(3, 3); f.visit(3, 3);
assert_eq!(f.reward.rank(), 9); assert_eq!(f.reward.rank(), 9);
} }
// --- Engagement rewards (the operator, 2026-09-23) -----------------------------------------------
/// `constants/tileset_constants.asm`: a gym, a house, a mart, the forest and a cave.
const GYM: u8 = 7;
const HOUSE: u8 = 8;
const MART: u8 = 2;
const FOREST: u8 = 3;
const CAVERN: u8 = 17;
/// `wSpriteStateData1`'s facing byte: `SPRITE_FACING_DOWN`, `_UP`, `_LEFT`, `_RIGHT`.
const FACING_DOWN: u8 = 0x00;
const FACING_UP: u8 = 0x04;
const FACING_LEFT: u8 = 0x08;
impl Fixture {
/// Stand on `map`, drawn with `tileset`, with no counter tiles in the tileset header.
fn on_map(&mut self, map: u8, tileset: u8) {
self.memory.set(ram::wCurMap, map);
self.memory.set(ram::wCurMapTileset, tileset);
for index in 0..3 {
self.memory.set(ram::wTilesetTalkingOverTiles + index, 0xff);
}
}
/// A visible sprite in `slot` at map tile `(x, y)`, stored plus four as `object_event` emits
/// it, with `extra` in its `wMapSpriteExtraData` entry.
fn sprite(&mut self, slot: u8, x: u8, y: u8, extra: (u8, u8)) {
let count = self.memory.bytes[ram::wNumSprites as usize].max(slot);
self.memory.set(ram::wNumSprites, count);
let data1 = ram::wSpriteStateData1 + u16::from(slot) * 16;
let data2 = ram::wSpriteStateData2 + u16::from(slot) * 16;
self.memory.set(data1, 1);
self.memory.set(data1 + 2, 0);
self.memory.set(data2 + 4, y + 4);
self.memory.set(data2 + 5, x + 4);
let entry = ram::wMapSpriteExtraData + (u16::from(slot) - 1) * 2;
self.memory.set(entry, extra.0);
self.memory.set(entry + 1, extra.1);
}
fn face(&mut self, facing: u8) {
self.memory.set(ram::wSpriteStateData1 + 9, facing);
}
/// `DisplayTextIDInit`'s `TextBoxBorder` at screen (0, 12)-(19, 17), the box every text id
/// but the start menu's is drawn in; or the map's own tiles again once it is gone.
fn dialogue_box(&mut self, drawn: bool) {
use super::state::poke::frame;
let at = |x: u16, y: u16| ram::wTileMap + y * 20 + x;
for y in 12..=17u16 {
for x in 0..20u16 {
let tile = if !drawn {
0
} else {
match (x, y) {
(0, 12) => frame::TOP_LEFT,
(19, 12) => frame::TOP_RIGHT,
(0, 17) => frame::BOTTOM_LEFT,
(19, 17) => frame::BOTTOM_RIGHT,
(_, 12) | (_, 17) => frame::HORIZONTAL,
(0, _) | (19, _) => frame::VERTICAL,
_ => 0x7f,
}
};
self.memory.set(at(x, y), tile);
}
}
}
/// The box opening: the font bit, the border, and -- `delay` samples later, the way the
/// cartridge loads the font's tiles first -- `DisplayTextID`'s argument.
fn open_box(&mut self, argument: u8, delay: usize) -> Vec<RewardEvent> {
self.memory.set(ram::wFontLoaded, 1);
self.dialogue_box(true);
let mut events = Vec::new();
for _ in 0..delay {
events.extend(self.sample());
}
self.memory.set(ram::wSpriteIndex, argument);
events.extend(self.sample());
events
}
fn close_box(&mut self) -> Vec<RewardEvent> {
self.memory.set(ram::wFontLoaded, 0);
self.dialogue_box(false);
self.sample()
}
/// One conversation as the cartridge draws it: a sample with the box closed (the frame the A
/// press was read on), `DisplayTextIDInit` setting the font bit and `DisplayTextID` copying
/// `argument` into `wSpriteIndex`, a few frames of text, and `CloseTextDisplay`.
fn talk(&mut self, argument: u8) -> Vec<RewardEvent> {
let mut events = self.sample();
events.extend(self.open_box(argument, 20));
events.extend(self.sample());
events.extend(self.close_box());
events
}
fn talk_events(&mut self, argument: u8) -> Vec<RewardEvent> {
self.talk(argument).into_iter().filter(|event| event.kind == kind::TALK).collect()
}
/// `wToggleableObjectList` for this map: `(sprite slot, global index)` pairs and `$ff`.
fn toggle_list(&mut self, entries: &[(u8, u8)]) {
for (index, (slot, global)) in entries.iter().enumerate() {
self.memory.set(ram::wToggleableObjectList + index as u16 * 2, *slot);
self.memory.set(ram::wToggleableObjectList + index as u16 * 2 + 1, *global);
}
self.memory.set(ram::wToggleableObjectList + entries.len() as u16 * 2, 0xff);
}
fn set_bit(&mut self, base: u16, index: u16, on: bool) {
let address = base + index / 8;
let mask = 1 << (index % 8);
let byte = self.memory.bytes[address as usize];
self.memory.set(address, if on { byte | mask } else { byte & !mask });
}
fn item_events(&mut self) -> Vec<RewardEvent> {
self.sample().into_iter().filter(|event| event.kind == kind::ITEM).collect()
}
}
#[test]
fn indoors_is_the_cartridges_building_tilesets_and_nothing_else() {
// CheckIfInOutsideMap's outside (OVERWORLD, PLATEAU) and BikeRidingTilesets (OVERWORLD,
// FOREST, UNDERGROUND, SHIP_PORT, CAVERN) are the two tables; indoor is neither.
let outdoor = [0, 3, 11, 14, 17, 23];
for tileset in 0..24u8 {
assert_eq!(engage::indoor(tileset), !outdoor.contains(&tileset), "tileset {tileset}");
}
assert!(!engage::indoor(24), "a tileset past the table is not a building");
assert!(!engage::indoor(0xff));
}
#[test]
fn a_conversation_the_fly_opens_indoors_pays_once_when_its_box_closes() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
// Nothing on the frames the box is open: the payout waits for it to close.
let mut events = f.sample();
events.extend(f.open_box(1, 20));
assert_eq!(count_of_kind(&events, kind::TALK), 0, "not while the box is open");
let events = f.close_box();
assert_eq!(kinds(&events), ["talk"]);
assert_eq!(events[0].value, 0.10);
assert_eq!(events[0].stimulation_ms, 100);
assert_eq!(labels(&events), [format!("TALKED TO #1 IN AREA {}", maps::PEWTER_GYM)]);
// Talking to the same person again, as often as the fly likes, is not a farm.
for _ in 0..5 {
assert!(f.talk_events(1).is_empty(), "one payout per (map, object) for the ledger's life");
}
// A second person on the same map is a second key.
f.sprite(2, 4, 5, (0, 0));
f.face(FACING_LEFT);
assert_eq!(kinds(&f.talk_events(2)), ["talk"]);
// A sign is a text id past the sprite slots, on the tile the player faces.
f.memory.set(ram::wNumSigns, 1);
f.memory.set(ram::wSignCoords, 6);
f.memory.set(ram::wSignCoords + 1, 5);
f.memory.set(ram::wSignTextIDs, 7);
f.face(FACING_DOWN);
let sign = f.talk_events(7);
assert_eq!(labels(&sign), [format!("READ SIGN #7 IN AREA {}", maps::PEWTER_GYM)]);
assert!(f.talk_events(7).is_empty());
assert_eq!(f.reward.statistics().counts[kind::TALK], 3);
}
#[test]
fn the_same_slot_on_another_indoor_map_is_another_conversation() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
f.on_map(maps::OAKS_LAB, HOUSE);
f.visit(5, 5);
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
}
#[test]
fn talking_outdoors_in_a_forest_or_in_a_cave_pays_nothing() {
for (map, tileset) in [
(maps::PEWTER_CITY, 0),
(maps::VIRIDIAN_FOREST, FOREST),
(maps::MT_MOON_1F, CAVERN),
] {
let mut f = Fixture::booted();
f.on_map(map, tileset);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
assert!(f.talk_events(1).is_empty(), "map {map}, tileset {tileset}");
}
}
#[test]
fn text_the_fly_did_not_open_pays_nothing() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
// A script that took the joypad before it drew the box: a trainer walking up, a guard.
f.memory.set(ram::wJoyIgnore, 0xff);
assert!(f.talk_events(1).is_empty(), "the joypad was the cartridge's");
f.memory.set(ram::wJoyIgnore, 0);
// Simulated input, and scripted movement.
f.memory.set(ram::wSimulatedJoypadStatesIndex, 3);
assert!(f.talk_events(1).is_empty(), "the buttons were simulated");
f.memory.set(ram::wSimulatedJoypadStatesIndex, 0);
f.memory.set(ram::wStatusFlags5, 0x80);
assert!(f.talk_events(1).is_empty(), "the movement was scripted");
f.memory.set(ram::wStatusFlags5, 0);
// A trigger tile: the box opens on the frame a step ends, with the walk counter still
// running on the sample before it -- the overworld never reads A mid-step.
f.memory.set(ram::wWalkCounter, 1);
assert!(f.talk_events(1).is_empty(), "a step onto a trigger tile is not a press");
f.memory.set(ram::wWalkCounter, 0);
// Text about someone the fly is not facing: a script naming a sprite across the room.
f.sprite(2, 9, 9, (0, 0));
assert!(f.talk_events(2).is_empty(), "not the thing in front");
// A text id that is no sign in front of the fly.
assert!(f.talk_events(9).is_empty(), "no sign there");
// The start menu is text id 0.
assert!(f.talk_events(0).is_empty(), "the start menu is not a conversation");
// A script that opens the box with the joypad taken and hands it back mid-conversation:
// the frames after it are the fly's, but the box did not open on one of them.
f.memory.set(ram::wJoyIgnore, 0xff);
f.sample();
f.open_box(1, 0);
f.memory.set(ram::wJoyIgnore, 0);
f.sample();
f.sample();
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0, "no open edge after a ready frame");
// START in front of someone: the menu's box is not the dialogue box, and `wSpriteIndex`
// still names the last person spoken to for the frames before the menu's own id arrives.
f.memory.set(ram::wSpriteIndex, 1);
f.sample();
f.memory.set(ram::wFontLoaded, 1);
for _ in 0..20 {
f.sample();
}
f.memory.set(ram::wSpriteIndex, 0);
f.sample();
f.memory.set(ram::wFontLoaded, 0);
assert_eq!(count_of_kind(&f.sample(), kind::TALK), 0, "the start menu is not a conversation");
// The argument never arrives inside the window: nothing is guessed.
f.memory.set(ram::wSpriteIndex, 0);
f.sample();
f.open_box(1, 60);
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0, "a second of frames, then no more");
// None of that was recorded: the person is still worth one conversation.
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
}
#[test]
fn a_conversation_that_ends_somewhere_else_pays_nothing() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
f.sample();
f.open_box(1, 20);
// The script warped the fly out while the box was up.
f.on_map(maps::PEWTER_CITY, 0);
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0);
// A rollback mid-conversation, likewise: the watch is transient.
f.on_map(maps::PEWTER_GYM, GYM);
f.sample();
f.open_box(1, 20);
f.reward.clear_transient();
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0);
}
#[test]
fn an_item_ball_is_an_item_not_a_conversation() {
let mut f = Fixture::booted();
f.on_map(maps::OAKS_LAB, HOUSE);
f.visit(5, 5);
f.sprite(1, 5, 4, (0x14, 0));
f.face(FACING_UP);
assert!(f.talk_events(1).is_empty());
// A trainer is a person: `(class, number)`, and numbers start at one.
f.sprite(2, 4, 5, (0xcb, 2));
f.face(FACING_LEFT);
assert_eq!(kinds(&f.talk_events(2)), ["talk"]);
}
#[test]
fn a_clerk_across_a_counter_is_in_reach_only_where_the_tileset_has_counters() {
let mut f = Fixture::booted();
f.on_map(maps::VIRIDIAN_MART, MART);
f.visit(5, 5);
f.sprite(1, 5, 3, (0, 0));
f.face(FACING_UP);
assert!(f.talk_events(1).is_empty(), "two tiles away with no counter tiles in the header");
f.memory.set(ram::wTilesetTalkingOverTiles, 0x18);
assert_eq!(kinds(&f.talk_events(1)), ["talk"], "IsSpriteOrSignInFrontOfPlayer's long range");
}
#[test]
fn a_rollback_or_a_restore_cannot_replay_a_conversation() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
f.reward.clear_transient();
let state = f.reward.export_state();
assert!(
state["seen"]
.as_array()
.unwrap()
.contains(&json!(format!("talk:{}:sprite:1", maps::PEWTER_GYM))),
"the ledger is the checkpointed `seen` set, not the macros' session ledger"
);
let mut restored = PokemonRedReward::new();
restored.import_state(&state).unwrap();
f.reward = restored;
f.visit(5, 5);
assert!(f.talk_events(1).is_empty());
assert_eq!(f.reward.statistics().counts[kind::TALK], 1);
}
#[test]
fn an_item_ball_pays_once_when_its_bit_rises_and_never_again() {
let mut f = Fixture::booted();
f.on_map(maps::VIRIDIAN_FOREST, FOREST);
f.sprite(3, 5, 4, (0x14, 0)); // a Potion
f.toggle_list(&[(3, 0x2a)]);
f.visit(5, 5);
// PickUpItem: GiveItem, then HideObject sets the ball's global bit.
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
let events = f.item_events();
assert_eq!(labels(&events), ["FOUND ITEM #20"]);
assert_eq!(events[0].value, 0.15);
assert_eq!(events[0].stimulation_ms, 120);
assert!(f.item_events().is_empty(), "a bit that stays set pays once");
// A rollback to a slot where the ball is still there, and the fly takes it again.
f.reward.clear_transient();
f.set_bit(ram::wToggleableObjectFlags, 0x2a, false);
f.sample();
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
assert!(f.item_events().is_empty(), "once per item for the run");
assert_eq!(f.reward.statistics().counts[kind::ITEM], 1);
}
#[test]
fn only_an_item_balls_bit_pays_and_only_after_a_pickup() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_CITY, 0);
f.sprite(1, 2, 2, (0, 0)); // a person a script hides
f.sprite(2, 3, 3, (0xcb, 1)); // a trainer
f.sprite(4, 6, 6, (0x14, 0)); // a ball
f.toggle_list(&[(1, 0x03), (2, 0x04), (4, 0x05)]);
f.visit(5, 5);
f.set_bit(ram::wToggleableObjectFlags, 0x03, true);
f.set_bit(ram::wToggleableObjectFlags, 0x04, true);
assert!(f.item_events().is_empty(), "a hidden person or trainer is not an item");
// A bag too full to take the ball leaves the bit clear, and nothing pays.
assert!(f.item_events().is_empty());
f.set_bit(ram::wToggleableObjectFlags, 0x05, true);
assert_eq!(kinds(&f.item_events()), ["item"]);
}
#[test]
fn a_hidden_item_pays_once_per_index() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, true);
assert_eq!(labels(&f.item_events()), ["FOUND A HIDDEN ITEM"]);
assert!(f.item_events().is_empty());
f.set_bit(ram::wObtainedHiddenItemsFlags, 18, true);
assert_eq!(kinds(&f.item_events()), ["item"]);
f.reward.clear_transient();
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, false);
f.sample();
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, true);
assert!(f.item_events().is_empty(), "a rollback cannot replay a hidden item");
}
#[test]
fn items_already_taken_are_seeded_and_the_two_script_shown_balls_are_not() {
let mut f = Fixture::new();
f.memory.set(ram::wCurMap, maps::REDS_HOUSE_2F);
f.set_bit(ram::wToggleableObjectFlags, 0x10, true);
f.set_bit(ram::wToggleableObjectFlags, 0x87, true);
f.set_bit(ram::wToggleableObjectFlags, 0x88, true);
f.set_bit(ram::wObtainedHiddenItemsFlags, 4, true);
assert!(f.item_events().is_empty(), "the seed pays nothing");
let seen = f.reward.export_state()["seen"].clone();
let seen: Vec<&str> = seen.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
for key in ["item:16", "hidden:4", "items:seeded"] {
assert!(seen.contains(&key), "{key}");
}
assert!(!seen.contains(&"item:135") && !seen.contains(&"item:136"));
// Giovanni beaten: the script shows the Silph Scope's ball (its bit clears), and the fly
// takes it.
f.on_map(0x8a, 22);
f.sprite(9, 25, 2, (0x48, 0));
f.toggle_list(&[(9, 0x87)]);
f.sample();
f.set_bit(ram::wToggleableObjectFlags, 0x87, false);
f.sample();
f.set_bit(ram::wToggleableObjectFlags, 0x87, true);
assert_eq!(kinds(&f.item_events()), ["item"]);
}
#[test]
fn boundary_pays_nothing_indoors_but_still_records_the_exit() {
let mut f = Fixture::booted();
f.on_map(maps::REDS_HOUSE_1F, 1);
f.warps(&[(4, 4)]);
assert!(boundary_values(&f.visit(3, 4)).is_empty(), "no payout beside an indoor door");
assert!(boundary_values(&f.visit(4, 4)).is_empty(), "nor on it");
assert!(
f.reward.exit_visited(MapExit::Warp { map: maps::REDS_HOUSE_1F, x: 4, y: 4 }),
"the ledger still knows the door, so the macros see what they always saw"
);
assert_eq!(f.reward.statistics().counts[kind::BOUNDARY], 0);
// Outdoors, in the forest and in a cave, exits pay exactly what they did.
let outdoors =
[(maps::PALLET_TOWN, 0), (maps::VIRIDIAN_FOREST, FOREST), (maps::MT_MOON_1F, CAVERN)];
for (map, tileset) in outdoors {
f.on_map(map, tileset);
assert_eq!(boundary_values(&f.visit(3, 4)), [0.05], "map {map}");
assert_eq!(boundary_values(&f.visit(4, 4)), [0.10], "map {map}");
}
}
#[test]
fn a_v6_state_restores_under_v7_with_empty_talk_and_seeded_item_ledgers() {
// A v6 run: some play, a pickup the v6 adapter did not pay for, and a v6 export -- which is
// a v7 export without any of the keys v7 writes.
let mut f = Fixture::booted();
f.on_map(maps::VIRIDIAN_FOREST, FOREST);
f.sprite(3, 5, 4, (0x14, 0));
f.toggle_list(&[(3, 0x2a)]);
f.visit(5, 5);
f.catch(0xb0, Some(3));
let mut v6 = f.reward.export_state();
let seen: Vec<Value> = v6["seen"]
.as_array()
.unwrap()
.iter()
.filter(|key| {
let key = key.as_str().unwrap();
!(key.starts_with("talk:") || key.starts_with("item") || key.starts_with("hidden:"))
})
.cloned()
.collect();
v6["seen"] = json!(seen);
v6["counts"].as_object_mut().unwrap().remove("talk");
v6["counts"].as_object_mut().unwrap().remove("item");
assert_eq!(v6["version"], json!(STATE_VERSION), "v6 and v7 share a schema version");
// Under v6 the fly took the ball.
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, true);
let mut restored = PokemonRedReward::new();
restored.import_state(&v6).unwrap();
assert_eq!(restored.statistics().counts[kind::TALK], 0);
assert_eq!(restored.statistics().counts[kind::ITEM], 0);
assert_eq!(restored.statistics().counts[kind::CATCH], 1, "nothing else moves");
f.reward = restored;
assert!(f.sample().is_empty(), "no retroactive payout for anything taken under v6");
// A rollback to a v6-era slot where the ball is still on the ground: taking it again is
// the same pickup, and it does not pay.
f.reward.clear_transient();
f.set_bit(ram::wToggleableObjectFlags, 0x2a, false);
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, false);
f.sample();
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, true);
assert!(f.item_events().is_empty());
// The talk ledger starts empty: the first conversation under v7 pays.
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
}
#[test]
fn a_warp_is_classified_by_the_header_still_loaded_while_the_map_id_has_moved_on() {
// `WarpFound2` writes the destination into `wCurMap` and then plays the map-change sound for
// thirty-odd frames before `EnterMap` loads the new header (`tests/rom_engage.rs` measured
// it at Route 2's gate door). On those frames the tileset and the warp table are still the
// map the fly is leaving, and the exit it is standing on is that map's.
let mut f = Fixture::booted();
f.on_map(maps::ROUTE_2, 0);
f.warps(&[(3, 11)]);
f.visit(3, 10);
// Town -> building: the id is the gate's, the header is still Route 2's, and Route 2's
// door is an outdoor exit, so its on-exit half pays as it always did.
f.memory.set(ram::wCurMap, 0x2f);
assert_eq!(boundary_values(&f.visit(3, 11)), [0.10]);
// Building -> town: the id is Pewter's, the header still the museum's, whose door is an
// indoor exit and pays nothing.
f.on_map(0x34, 10);
f.warps(&[(10, 7)]);
f.visit(10, 6);
f.memory.set(ram::wCurMap, maps::PEWTER_CITY);
assert!(boundary_values(&f.visit(10, 7)).is_empty());
}

View file

@ -15,10 +15,10 @@
use std::time::Instant; use std::time::Instant;
use flybrain_gb::adapter::MemoryReader; use flybrain_gb::adapter::{MapExit, MemoryReader};
use flybrain_gb::emulator::{AUDIO_SILENCE_LEVEL, CPU_TICKS_PER_SECOND}; use flybrain_gb::emulator::{AUDIO_SILENCE_LEVEL, CPU_TICKS_PER_SECOND};
use flybrain_gb::pokemon_red::symbols::ram; use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM}; use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM, engage};
use flybrain_gb::{ use flybrain_gb::{
DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, FRAMEBUFFER_LEN, GameAdapter, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, FRAMEBUFFER_LEN, GameAdapter,
buttons, buttons,
@ -444,7 +444,10 @@ fn read_u32(bytes: &[u8], offset: &mut usize) -> Option<u32> {
/// The boundary rule, against the cartridge rather than a synthetic trace. /// The boundary rule, against the cartridge rather than a synthetic trace.
/// ///
/// `docs/design/room-escape.md` section 2, Verification: "from the bedroom archive, the first /// `docs/design/room-escape.md` section 2, Verification: "from the bedroom archive, the first
/// payouts are boundary events near the stairs". Two things are checked here that no synthetic /// payouts are boundary events near the stairs". Since `pokered-unique8-v7` the bedroom is
/// *indoors* (`engage::indoor`: tileset `REDS_HOUSE_2`, neither outside nor bike-ridable), so
/// the same walk now proves the other half of the rule: the stairs enter the ledger where they
/// always did, and nothing is paid for them. Three things are checked here that no synthetic
/// WRAM trace can check: /// WRAM trace can check:
/// ///
/// 1. **the warp table layout.** `RedsHouse2F_Object` declares exactly one warp, /// 1. **the warp table layout.** `RedsHouse2F_Object` declares exactly one warp,
@ -453,9 +456,11 @@ fn read_u32(bytes: &[u8], offset: &mut usize) -> Option<u32> {
/// layout were X-then-Y, or the stride were not four, this assertion is what fails. /// layout were X-then-Y, or the stride were not four, this assertion is what fails.
/// 2. **that the rule fires at the right place.** The fly spawns at (3, 6) with the stairs at /// 2. **that the rule fires at the right place.** The fly spawns at (3, 6) with the stairs at
/// (7, 1), four tiles and five rows away, so the stairs are not baselined by the first /// (7, 1), four tiles and five rows away, so the stairs are not baselined by the first
/// playable sample and have to be walked to. /// playable sample and have to be walked to -- and the ledger records them from beside them.
/// 3. **that the cartridge calls the bedroom a building**: `wCurMapTileset` reads `REDS_HOUSE_2`
/// on the running game, so the rule pays nothing there.
#[test] #[test]
fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() { fn the_boundary_rule_records_the_bedroom_stairs_and_pays_nothing_indoors() {
let mut emulator = skip_without_rom!(boot()); let mut emulator = skip_without_rom!(boot());
assert_eq!(emulator.rom_sha256(), SUPPORTED_ROM, "FLY_ROM is not the pinned cartridge"); assert_eq!(emulator.rom_sha256(), SUPPORTED_ROM, "FLY_ROM is not the pinned cartridge");
let mut adapter = PokemonRedReward::new(); let mut adapter = PokemonRedReward::new();
@ -498,6 +503,8 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() {
0, 0,
"an indoor map has no connected edges" "an indoor map has no connected edges"
); );
assert_eq!(emulator.read_wram(ram::wCurMapTileset), 4, "REDS_HOUSE_2");
assert!(engage::indoor(4), "and the rule calls it a building");
let (spawn_x, spawn_y) = let (spawn_x, spawn_y) =
(emulator.read_wram(ram::wXCoord), emulator.read_wram(ram::wYCoord)); (emulator.read_wram(ram::wXCoord), emulator.read_wram(ram::wYCoord));
assert!( assert!(
@ -509,6 +516,7 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() {
let mut seed: u64 = 0x5eed_1234_5678_9abc; let mut seed: u64 = 0x5eed_1234_5678_9abc;
let mut boundary: Vec<(u8, u8, f64)> = Vec::new(); let mut boundary: Vec<(u8, u8, f64)> = Vec::new();
let mut kinds: Vec<&'static str> = Vec::new(); let mut kinds: Vec<&'static str> = Vec::new();
let mut found_at: Option<(u8, u8)> = None;
for frame in 0..24_000u32 { for frame in 0..24_000u32 {
seed = seed seed = seed
.wrapping_mul(6_364_136_223_846_793_005) .wrapping_mul(6_364_136_223_846_793_005)
@ -531,26 +539,19 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() {
boundary.push((x, y, event.value)); boundary.push((x, y, event.value));
} }
} }
if found_at.is_none() && adapter.exit_visited(MapExit::Warp { map: 0x26, x: 7, y: 1 }) {
found_at = Some((x, y));
}
if adapter.map_id() != Some(0x26) { if adapter.map_id() != Some(0x26) {
break; break;
} }
} }
eprintln!("boundary: payouts in the bedroom {boundary:?}, all kinds {kinds:?}"); eprintln!("boundary: payouts in the bedroom {boundary:?}, all kinds {kinds:?}");
let (x, y, value) = *boundary.first().expect("the stairs were never found"); let (x, y) = found_at.expect("the stairs were never found");
assert!( assert!(
x.abs_diff(7) + y.abs_diff(1) <= 1, x.abs_diff(7) + y.abs_diff(1) <= 1,
"the first boundary payout was at ({x}, {y}), not next to the stairs at (7, 1)" "the stairs entered the ledger at ({x}, {y}), not next to the stairs at (7, 1)"
); );
assert!( assert!(boundary.is_empty(), "an indoor exit pays nothing, got {boundary:?}");
value == 0.05 || value == 0.10,
"a boundary payout is the adjacent value or twice it, got {value}"
);
assert!(
boundary.len() <= 2,
"one warp can pay at most twice in a lifetime, got {boundary:?}"
);
for (x, y, _) in &boundary {
assert!(x.abs_diff(7) + y.abs_diff(1) <= 1, "payout away from the stairs at ({x}, {y})");
}
} }

View file

@ -37,6 +37,7 @@ axum = { version = "0.8", features = ["ws"] }
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2 = { workspace = true }
tokio = { version = "1", features = [ tokio = { version = "1", features = [
"rt-multi-thread", "rt-multi-thread",
"net", "net",

View file

@ -70,19 +70,20 @@ use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use flybrain_core::agent::{ use flybrain_core::agent::{
AgentConfig, NeuralAgent, RewardEvent as NeuralReward, TickOptions, AgentConfig, NeuralAgent,
}; };
use flybrain_core::dataset::load_brain_dataset_from_dir; use flybrain_core::dataset::load_brain_dataset_from_dir;
use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask}; use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
use flybrain_core::lif::SweepPlan; use flybrain_core::lif::SweepPlan;
use flybrain_gb::adapter::GameAdapter; use flybrain_gb::adapter::GameAdapter;
use flybrain_gb::pokemon_red::PokemonRedReward; use flybrain_gb::pokemon_red::PokemonRedReward;
use flybrain_gb::ratchet::Ratchet; use flybrain_gb::ratchet::Ratchet;
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons}; use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons};
use flysim::config::Config; use flysim::config::Config;
use flysim::frame::{Executed, FrameObserver, LegacyFrame, Parts};
use flysim::macros::{MacroLayer, OutcomeCounts, Silence, macro_layer}; use flysim::macros::{MacroLayer, OutcomeCounts, Silence, macro_layer};
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
use flysim::trace::FrameTrace;
/// `constants/map_constants.asm`: Red's bedroom, where a cold boot ends up. /// `constants/map_constants.asm`: Red's bedroom, where a cold boot ends up.
const REDS_HOUSE_2F: u32 = 0x26; const REDS_HOUSE_2F: u32 = 0x26;
@ -103,25 +104,12 @@ fn emulator(rom: &[u8]) -> Emulator {
.expect("binjgb should accept the cartridge") .expect("binjgb should accept the cartridge")
} }
/// The neural half of a ratchet recovery, exactly as `simloop.rs` wires it. /// Whether a macro owned the buttons of a frame: read as the executor hands the mask over.
struct AgentRecovery<'a> { struct MacroOwned(bool);
agent: &'a mut NeuralAgent,
}
impl NeuralRecovery for AgentRecovery<'_> { impl FrameObserver for MacroOwned {
fn clear_decoder_holds(&mut self) { fn executed(&mut self, _frame: &LegacyFrame, parts: &mut Parts<'_>, _executed: &Executed) {
let ms = self.agent.network.ms; self.0 = parts.macros.as_deref().is_some_and(|layer| layer.running().is_some());
self.agent.decoder.clear_holds(ms);
}
fn clear_eligibility(&mut self) {
let ms = self.agent.network.ms;
self.agent.network.plasticity.clear_eligibility(ms);
}
fn set_visual_frame(&mut self, frame: &[u8]) {
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
self.agent.network.set_visual_frame(frame, width, height);
} }
} }
@ -244,11 +232,9 @@ fn boot_to_bedroom(rom: &[u8]) -> Vec<u8> {
/// One arm: `hours` brain hours of the sim loop's frame order, unthrottled. /// One arm: `hours` brain hours of the sim loop's frame order, unthrottled.
/// ///
/// The order is `simloop.rs`'s (steps 2 to 10), as `NeuralAgent::tick` expresses it: the frame and /// The frame is `flysim::frame::LegacyFrame`, the one the stream runs, so the arm measures the
/// the payouts handed to a tick are the ones the previous tick's buttons produced. The macro layer /// wiring under test rather than a second implementation of it. (Before FND-01 the arms ticked the
/// is consulted at exactly the two points the loop consults it — after the decode, before the /// brain through `NeuralAgent::tick`, one frame behind the stream's order.)
/// buttons reach the emulator, and after the frame and its payouts — so the arm measures the
/// wiring under test rather than a second implementation of it.
fn run_arm( fn run_arm(
rom: &[u8], rom: &[u8],
data: &Arc<flybrain_core::dataset::BrainDataset>, data: &Arc<flybrain_core::dataset::BrainDataset>,
@ -270,48 +256,39 @@ fn run_arm(
.or(preset.exclusive.as_ref()) .or(preset.exclusive.as_ref())
.expect("the preset has a group") .expect("the preset has a group")
.hold_ms; .hold_ms;
let blocked_ms = preset.exclusive.as_ref().expect("the preset has an exclusive group").blocked_ms;
let mut agent_config = AgentConfig::with_decoder(preset); let mut agent_config = AgentConfig::with_decoder(preset);
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy()); let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
let mut arm = Arm { mode: mode.as_str(), ..Arm::default() }; let mut arm = Arm { mode: mode.as_str(), ..Arm::default() };
match start { if let Start::Fresh { warmup_ms, .. } = start {
Start::Fresh { state, warmup_ms } => { agent_config.warmup_ms = *warmup_ms;
emulator.import_state(state).expect("the booted state should import");
agent_config.warmup_ms = *warmup_ms;
}
Start::Live { checkpoint } => {
emulator
.import_state(&checkpoint.runtime.emulator)
.expect("the checkpoint's emulator state should import");
adapter
.import_state(&checkpoint.runtime.reward)
.expect("the checkpoint's reward ledger should import");
let snapshot = (!checkpoint.runtime.ratchet_game.is_empty()).then(|| {
flybrain_gb::ratchet::Snapshot {
game: checkpoint.runtime.ratchet_game.clone(),
frame: checkpoint.runtime.ratchet_frame.clone(),
}
});
ratchet
.import(Some(checkpoint.runtime.ratchet), snapshot, adapter.rank_ladder().len())
.expect("the checkpoint's ratchet state should import");
}
} }
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent"); let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
if threads > 1 { if threads > 1 {
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan")); agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
} }
let mut frame = LegacyFrame::new()
.with_trace(FrameTrace::from_env().expect("FLY_TRACE should name a writable file"));
match start { match start {
Start::Fresh { .. } => { Start::Fresh { state, warmup_ms: _ } => {
agent.warmup(Some(emulator.framebuffer())).expect("warm-up"); emulator.import_state(state).expect("the booted state should import");
} frame.frame_buffer.copy_from_slice(emulator.framebuffer());
Start::Live { checkpoint } => { agent.warmup(Some(&frame.frame_buffer)).expect("warm-up");
agent.import_state(&checkpoint.agent).expect("the checkpoint's agent should import");
let (width, height) = (agent.frame.width, agent.frame.height);
agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height);
} }
// The stream's own restore, into the stream's own frame.
Start::Live { checkpoint } => frame
.restore(
&mut Parts {
agent: &mut agent,
emulator: &mut emulator,
adapter: &mut adapter,
ratchet: &mut ratchet,
macros: None,
},
checkpoint,
)
.expect("the checkpoint should restore"),
} }
// The layer under test, built the way the sim loop builds it: from the configuration, so raw // The layer under test, built the way the sim loop builds it: from the configuration, so raw
@ -328,136 +305,62 @@ fn run_arm(
let began_ms = agent.network.ms; let began_ms = agent.network.ms;
let until = began_ms + hours * HOUR_MS; let until = began_ms + hours * HOUR_MS;
let mut frame = emulator.framebuffer().to_vec();
let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new();
let mut location = adapter.location();
let mut blocked_since_ms = began_ms;
let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string);
let mut rank = adapter.progress().rank; let mut rank = adapter.progress().rank;
let tiles_at_start = adapter.progress().unique_locations; let tiles_at_start = adapter.progress().unique_locations;
arm.rungs.push((rank, adapter.progress().rank_label, 0.0)); arm.rungs.push((rank, adapter.progress().rank_label, 0.0));
// The scene has not been observed yet, so the first frame is decided on an empty palette, // One observation before the first frame, as the sim loop takes after a restore, so frame one
// which presses nothing. That is one frame, and it is the honest starting state. // is decided on a real palette.
if let Some(layer) = macros.as_mut() { if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter); let ledger = AdapterLedger(&adapter);
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms); let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
} }
while agent.network.ms < until { while agent.network.ms < until {
let rewards: Vec<NeuralReward> = payouts let mut parts = Parts {
.iter() agent: &mut agent,
.map(|event| { emulator: &mut emulator,
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms)) adapter: &mut adapter,
}) ratchet: &mut ratchet,
.collect(); macros: macros.as_mut(),
let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true }; };
let mut owned = MacroOwned(false);
// The blocked-direction cooldown's input, as `simloop.rs` computes it. let transition = frame.transition(&mut parts, &mut owned).expect("a frame");
let ms = agent.network.ms; let ms = transition.ms;
let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms) let executed = &transition.executed;
.then(|| agent.decoder.current().map(str::to_string)) if let Some(layer) = parts.macros.as_deref() {
.flatten(); if let Some(silence) = executed.silence {
// The scene's own macro buttons, from the palette the previous frame's `observe` dealt:
// the same mask the sim loop passes (`docs/design/macros.md` section 12). `None` in the
// raw arm, which has no layer and no macro group at all.
let bound = macros.as_ref().map(MacroLayer::bound_channels);
let result = agent
.tick_bound(&frame, &options, blocked.as_deref(), bound.as_deref())
.expect("a tick");
let held = agent.decoder.current().map(str::to_string);
if held != held_channel {
held_channel = held;
blocked_since_ms = ms;
}
// Step 4, with the layer in the middle of it in macros mode and absent in raw mode.
let ms = agent.network.ms;
let mut mask = to_button_mask(&result.active);
if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter);
let decision = layer.decide(&result.active, mask, ms, &mut emulator, &ledger);
mask = decision.mask;
if let Some(silence) = decision.silence {
*arm.silence.entry(silence.label()).or_insert(0) += 1; *arm.silence.entry(silence.label()).or_insert(0) += 1;
} }
for event in decision.events.iter().filter(|event| event.outcome.is_none()) { for event in executed.events.iter().filter(|event| event.outcome.is_none()) {
*arm.by_rank.entry(event.slot).or_insert(0) += 1; *arm.by_rank.entry(event.slot).or_insert(0) += 1;
} }
if layer.running().is_some() { if owned.0 {
arm.macro_frames += 1; arm.macro_frames += 1;
} }
*arm.scenes.entry(layer.scene_name()).or_insert(0) += 1;
} }
if mask == 0 { if executed.mask == 0 {
arm.idle_frames += 1; arm.idle_frames += 1;
} }
emulator.set_buttons(mask as u8);
emulator.run_frame().expect("a frame should complete");
arm.frames += 1; arm.frames += 1;
frame.copy_from_slice(emulator.framebuffer()); for event in &transition.evaluated.rewards {
// Steps 7 to 9, then the scene.
payouts = adapter.sample(&mut emulator, ms);
for event in &payouts {
arm.reward += event.value; arm.reward += event.value;
*arm.payouts.entry(event.kind).or_insert(0) += 1; *arm.payouts.entry(event.kind).or_insert(0) += 1;
} }
if let Some(layer) = macros.as_mut() { arm.digest = hash(arm.digest, u64::from(executed.mask));
let ledger = AdapterLedger(&adapter); if let Some((map, x, y)) = frame.location {
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
*arm.scenes.entry(layer.scene_name()).or_insert(0) += 1;
}
let now = adapter.location();
if now.is_some() && now != location {
location = now;
blocked_since_ms = ms;
}
arm.digest = hash(arm.digest, u64::from(mask));
if let Some((map, x, y)) = location {
arm.digest = hash(arm.digest, u64::from(map) << 32 | u64::from(x) << 16 | u64::from(y)); arm.digest = hash(arm.digest, u64::from(map) << 32 | u64::from(x) << 16 | u64::from(y));
} }
// Step 10: the ratchet, with the adapter's own policy. let progress = transition.evaluated.progress;
let progress = adapter.progress();
if progress.rank != rank { if progress.rank != rank {
rank = progress.rank; rank = progress.rank;
arm.rungs.push((rank, progress.rank_label, ms - began_ms)); arm.rungs.push((rank, progress.rank_label, ms - began_ms));
} }
let safe = adapter.safe_for_snapshot(); let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best; if boundary.rollback.is_some() {
let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot {
game: emulator.export_state().expect("state export"),
frame: frame.clone(),
});
let recover = ratchet.observe_with_game_over(
safe,
u64::from(progress.rank),
progress.unique_locations as u64,
ms as u64,
adapter.game_over(),
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
);
if recover {
let snapshot = flybrain_gb::ratchet::Snapshot {
game: ratchet.game().expect("a recovery has a snapshot").to_vec(),
frame: ratchet.frame().expect("a recovery has a framebuffer").to_vec(),
};
let restored = {
let mut neural = AgentRecovery { agent: &mut agent };
recover_game(&mut emulator, &mut adapter, &mut neural, &snapshot)
.expect("recovering the game")
};
frame.copy_from_slice(&restored);
emulator.set_buttons(0);
arm.recoveries += 1; arm.recoveries += 1;
location = adapter.location();
held_channel = None;
blocked_since_ms = ms;
// The sim loop abandons a running macro on a rollback, and so does this.
if let Some(layer) = macros.as_mut() {
layer.cancel(ms);
}
} }
} }

View file

@ -77,7 +77,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use flybrain_core::agent::{ use flybrain_core::agent::{
AgentConfig, GAMEBOY_MS_PER_FRAME, NeuralAgent, RewardEvent as NeuralReward, TickOptions, AgentConfig, GAMEBOY_MS_PER_FRAME, NeuralAgent,
}; };
use flybrain_core::dataset::load_brain_dataset_from_dir; use flybrain_core::dataset::load_brain_dataset_from_dir;
use flybrain_core::decoder::gameboy::{GAMEBOY_BUTTONS, gameboy_decoder_config, to_button_mask}; use flybrain_core::decoder::gameboy::{GAMEBOY_BUTTONS, gameboy_decoder_config, to_button_mask};
@ -88,8 +88,8 @@ use flybrain_gb::adapter::{GameAdapter, MemoryReader};
use flybrain_gb::pokemon_red::symbols::ram; use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM}; use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM};
use flybrain_gb::ratchet::Ratchet; use flybrain_gb::ratchet::Ratchet;
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons}; use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons};
use flysim::frame::{FrameObserver, FramePhase, LegacyFrame, Parts};
/// `constants/map_constants.asm`: Red's bedroom and the ground floor of his house. /// `constants/map_constants.asm`: Red's bedroom and the ground floor of his house.
const REDS_HOUSE_2F: u32 = 0x26; const REDS_HOUSE_2F: u32 = 0x26;
@ -496,28 +496,6 @@ fn print_table(title: &str, cells: &BTreeMap<(String, u64, u64, u64), Cell>, roo
// The real brain // The real brain
// ------------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------------
/// The neural half of a ratchet recovery, exactly as `flysim::simloop` wires it.
struct AgentRecovery<'a> {
agent: &'a mut NeuralAgent,
}
impl NeuralRecovery for AgentRecovery<'_> {
fn clear_decoder_holds(&mut self) {
let ms = self.agent.network.ms;
self.agent.decoder.clear_holds(ms);
}
fn clear_eligibility(&mut self) {
let ms = self.agent.network.ms;
self.agent.network.plasticity.clear_eligibility(ms);
}
fn set_visual_frame(&mut self, frame: &[u8]) {
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
self.agent.network.set_visual_frame(frame, width, height);
}
}
/// Where one brain run starts. /// Where one brain run starts.
enum Start<'a> { enum Start<'a> {
/// A save state and the map it stands in: a fresh fly, warmed up here. /// A save state and the map it stands in: a fresh fly, warmed up here.
@ -666,6 +644,97 @@ fn survey(rom: &[u8], state: &[u8]) -> Survey {
(reachable, exits) (reachable, exits)
} }
/// The room-escape instrumentation inside the stream's frame: the decoder as it stood before
/// the decode and after it, read at the one point between the two.
struct Escape<'a> {
/// The readout before this frame's decode.
before: Option<flybrain_core::decoder::DecoderState>,
hold_start: (Option<(u32, u32, u32)>, f64),
run_winner: Option<String>,
run_length: f64,
start_map: u32,
exits: &'a BTreeMap<(u32, u32), Vec<&'static str>>,
hold_ms: f64,
trace: Trace,
}
impl FrameObserver for Escape<'_> {
fn after(&mut self, phase: FramePhase, agent: &mut NeuralAgent) {
if phase == FramePhase::Ticked {
self.before = Some(agent.decoder.export_state());
}
}
fn before_execute(&mut self, frame: &LegacyFrame, parts: &mut Parts<'_>, _active: &[String]) {
let Some(before) = self.before.take() else { return };
let agent = &*parts.agent;
let after = agent.decoder.export_state();
if after.next_decision == before.next_decision {
return;
}
let ms = agent.network.ms;
let location = frame.location;
let trace = &mut self.trace;
trace.decisions += 1;
let winner = after.current.clone().expect("a decision names a winner");
// The raw argmax, recomputed from the decoder's own inputs: the rates the decode saw, the
// calibrated baseline, and the fatigue as it stood *before* the decision. Comparing it
// with the winner is what "the incumbent won by hysteresis" means.
let adjusted = |channel: &str| {
let index = DIRECTIONS.iter().position(|name| *name == channel).expect("a direction");
let role = ROLES[index];
let rate = agent.network.rates.get_or_zero(role);
let base = after.baseline.get_or_zero(role);
(rate + 1.0) / (base + 1.0) / (1.0 + before.fatigue.get_or_zero(channel))
};
let mut argmax = DIRECTIONS[0];
for channel in DIRECTIONS.iter().skip(1) {
if adjusted(channel) > adjusted(argmax) {
argmax = channel;
}
}
if argmax != winner {
trace.hysteresis_holds += 1;
}
if let Some(name) = DIRECTIONS.iter().find(|name| **name == winner) {
*trace.wins.entry(name).or_insert(0) += 1;
}
if self.run_winner.as_deref() == Some(winner.as_str()) {
self.run_length += 1.0;
} else {
if self.run_length > 0.0 {
trace.runs.push(self.run_length);
}
self.run_winner = Some(winner.clone());
self.run_length = 1.0;
}
// Was the hold that just ended a wall bump? The location now against the location at the
// previous decision, for the direction that was held in between.
if let (Some(previous), Some(held)) = (self.hold_start.0, before.current.as_deref())
&& ms - self.hold_start.1 >= self.hold_ms
&& location == Some(previous)
&& let Some(name) = DIRECTIONS.iter().find(|name| **name == held)
{
*trace.blocked_holds.entry(name).or_insert(0) += 1;
}
self.hold_start = (location, ms);
// The decision this whole exercise is about: standing on a tile one press from leaving,
// did the readout choose that press? Only on the starting map: the bedroom has walkable
// tiles at the same coordinates and they are not these exits.
if let Some(leaving) = location
.filter(|(map, _, _)| *map == self.start_map)
.and_then(|(_, x, y)| self.exits.get(&(x, y)))
{
trace.exit_decisions += 1;
if leaving.iter().any(|direction| *direction == winner) {
trace.exit_decisions_taken += 1;
}
}
}
}
/// One instrumented brain run: the real network, the real readout, the real adapter, and -- from a /// One instrumented brain run: the real network, the real readout, the real adapter, and -- from a
/// live checkpoint -- the real reward ledger and the real ratchet. /// live checkpoint -- the real reward ledger and the real ratchet.
/// ///
@ -697,174 +766,81 @@ fn brain_trace(
let mut emulator = emulator(rom); let mut emulator = emulator(rom);
let mut adapter = PokemonRedReward::new(); let mut adapter = PokemonRedReward::new();
let mut agent_config = AgentConfig::with_decoder(config.clone()); let mut agent_config = AgentConfig::with_decoder(config.clone());
let (hold_ms, blocked_ms) = { let hold_ms =
let group = config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group"); config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group").hold_ms;
(group.hold_ms, group.blocked_ms)
};
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy()); let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
let mut trace = Trace::default(); let mut trace = Trace::default();
if let Start::Fresh { warmup_ms, .. } = &start {
let start_map = match &start { agent_config.warmup_ms = *warmup_ms;
Start::Fresh { state, map, warmup_ms } => { }
emulator.import_state(state).expect("the starting state should import");
agent_config.warmup_ms = *warmup_ms;
*map
}
Start::Live { checkpoint, .. } => {
emulator
.import_state(&checkpoint.runtime.emulator)
.expect("the checkpoint's emulator state should import");
adapter
.import_state(&checkpoint.runtime.reward)
.expect("the checkpoint's reward ledger should import");
let snapshot = (!checkpoint.runtime.ratchet_game.is_empty()).then(|| {
flybrain_gb::ratchet::Snapshot {
game: checkpoint.runtime.ratchet_game.clone(),
frame: checkpoint.runtime.ratchet_frame.clone(),
}
});
ratchet
.import(Some(checkpoint.runtime.ratchet), snapshot, adapter.rank_ladder().len())
.expect("the checkpoint's ratchet state should import");
u32::from(emulator.read8(ram::wCurMap))
}
};
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent"); let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
if threads > 1 { if threads > 1 {
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan")); agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
} }
match &start { // The stream's own frame (`flysim::frame::LegacyFrame`), in raw mode: no macro layer.
Start::Fresh { .. } => agent.warmup(Some(emulator.framebuffer())).expect("warm-up"), let mut frame = LegacyFrame::new();
Start::Live { checkpoint, rng } => { let start_map = match &start {
let mut state = checkpoint.agent.clone(); Start::Fresh { state, map, .. } => {
state.network.rng = *rng; emulator.import_state(state).expect("the starting state should import");
agent.import_state(&state).expect("the checkpoint's agent state should import"); frame.frame_buffer.copy_from_slice(emulator.framebuffer());
let (width, height) = (agent.frame.width, agent.frame.height); agent.warmup(Some(&frame.frame_buffer)).expect("warm-up");
agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height); *map
} }
} Start::Live { checkpoint, rng } => {
let mut checkpoint = (*checkpoint).clone();
checkpoint.agent.network.rng = *rng;
frame
.restore(
&mut Parts {
agent: &mut agent,
emulator: &mut emulator,
adapter: &mut adapter,
ratchet: &mut ratchet,
macros: None,
},
&checkpoint,
)
.expect("the checkpoint should restore");
u32::from(emulator.read8(ram::wCurMap))
}
};
trace.maps.push(start_map); trace.maps.push(start_map);
let began_ms = agent.network.ms; let began_ms = agent.network.ms;
let until = began_ms + minutes * 60_000.0; let until = began_ms + minutes * 60_000.0;
// The sim loop's own order (`simloop.rs`, steps 2 to 10), as `NeuralAgent::tick` expresses it: let mut escape = Escape {
// the frame and the payouts handed to a tick are the ones the previous tick's buttons produced. before: None,
let mut frame = emulator.framebuffer().to_vec(); hold_start: (frame.location, began_ms),
let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new(); run_winner: None,
let mut location = adapter.location(); run_length: 0.0,
// The blocked-direction cooldown's window, restarted by a move *or* by a new winner, exactly start_map,
// as `simloop.rs` restarts it: a direction that has just won has not had a hold to move in yet. exits,
let mut blocked_since_ms = began_ms; hold_ms,
let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string); trace,
let mut hold_start = (location, began_ms); };
let mut run_winner: Option<String> = None;
let mut run_length = 0.0f64;
// Reported on its own: the longest stretch with no movement at all, whatever was held. // Reported on its own: the longest stretch with no movement at all, whatever was held.
let mut still_since_ms = began_ms; let mut still_since_ms = began_ms;
let tiles_at_start = adapter.progress().unique_locations; let tiles_at_start = adapter.progress().unique_locations;
trace.tiles = tiles_at_start; escape.trace.tiles = tiles_at_start;
while agent.network.ms < until { while agent.network.ms < until {
let rewards: Vec<NeuralReward> = payouts let mut parts = Parts {
.iter() agent: &mut agent,
.map(|event| { emulator: &mut emulator,
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms)) adapter: &mut adapter,
}) ratchet: &mut ratchet,
.collect(); macros: None,
let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true }; };
let location_before = frame.location;
let transition = frame.transition(&mut parts, &mut escape).expect("a frame");
let ms = transition.ms;
let trace = &mut escape.trace;
trace.reward += transition.evaluated.rewards.iter().map(|event| event.value).sum::<f64>();
trace.tiles = transition.evaluated.progress.unique_locations;
// The blocked-direction cooldown's input, computed the way `simloop.rs` computes it: the let location = frame.location;
// channel the readout is holding, once the adapter's location has stood still for a whole if location != location_before {
// hold. `blocked_ms == 0` is the rule switched off, and reports nothing.
let ms = agent.network.ms;
let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms)
.then(|| agent.decoder.current().map(str::to_string))
.flatten();
let before = agent.decoder.export_state();
let result = agent.tick_blocked(&frame, &options, blocked.as_deref()).expect("a tick");
let after = agent.decoder.export_state();
let held = agent.decoder.current().map(str::to_string);
if held != held_channel {
held_channel = held;
blocked_since_ms = ms;
}
if after.next_decision != before.next_decision {
trace.decisions += 1;
let winner = after.current.clone().expect("a decision names a winner");
// The raw argmax, recomputed from the decoder's own inputs: the rates the decode saw
// (`tick` decodes on the post-step rates and nothing changes them afterwards), the
// calibrated baseline, and the fatigue as it stood *before* the decision. Comparing it
// with the winner is what "the incumbent won by hysteresis" means.
let adjusted = |channel: &str| {
let index =
DIRECTIONS.iter().position(|name| *name == channel).expect("a direction");
let role = ROLES[index];
let rate = agent.network.rates.get_or_zero(role);
let base = after.baseline.get_or_zero(role);
(rate + 1.0) / (base + 1.0) / (1.0 + before.fatigue.get_or_zero(channel))
};
let mut argmax = DIRECTIONS[0];
for channel in DIRECTIONS.iter().skip(1) {
if adjusted(channel) > adjusted(argmax) {
argmax = channel;
}
}
if argmax != winner {
trace.hysteresis_holds += 1;
}
if let Some(name) = DIRECTIONS.iter().find(|name| **name == winner) {
*trace.wins.entry(name).or_insert(0) += 1;
}
if run_winner.as_deref() == Some(winner.as_str()) {
run_length += 1.0;
} else {
if run_length > 0.0 {
trace.runs.push(run_length);
}
run_winner = Some(winner.clone());
run_length = 1.0;
}
// Was the hold that just ended a wall bump? The location now against the location at
// the previous decision, for the direction that was held in between.
if let (Some(previous), Some(held)) = (hold_start.0, before.current.as_deref())
&& ms - hold_start.1 >= hold_ms
&& location == Some(previous)
&& let Some(name) = DIRECTIONS.iter().find(|name| **name == held)
{
*trace.blocked_holds.entry(name).or_insert(0) += 1;
}
hold_start = (location, ms);
// The decision this whole exercise is about: standing on a tile one press from
// leaving, did the readout choose that press? Only on the starting map: the bedroom
// has walkable tiles at the same coordinates and they are not these exits.
if let Some(leaving) = location
.filter(|(map, _, _)| *map == start_map)
.and_then(|(_, x, y)| exits.get(&(x, y)))
{
trace.exit_decisions += 1;
if leaving.iter().any(|direction| *direction == winner) {
trace.exit_decisions_taken += 1;
}
}
}
emulator.set_buttons(to_button_mask(&result.active) as u8);
emulator.run_frame().expect("a frame should complete");
frame.copy_from_slice(emulator.framebuffer());
let ms = agent.network.ms;
payouts = adapter.sample(&mut emulator, ms);
trace.reward += payouts.iter().map(|event| event.value).sum::<f64>();
trace.tiles = adapter.progress().unique_locations;
let now = adapter.location();
if now.is_some() && now != location {
location = now;
blocked_since_ms = ms;
still_since_ms = ms; still_since_ms = ms;
} }
trace.longest_still_ms = trace.longest_still_ms.max(ms - still_since_ms); trace.longest_still_ms = trace.longest_still_ms.max(ms - still_since_ms);
@ -876,40 +852,15 @@ fn brain_trace(
*trace.exit_tile_frames.entry(tile).or_insert(0) += 1; *trace.exit_tile_frames.entry(tile).or_insert(0) += 1;
} }
// Step 10: the ratchet, with the adapter's own policy and the checkpoint's own budget. // The ratchet, with the adapter's own policy and the checkpoint's own budget.
let progress = adapter.progress(); let progress = transition.evaluated.progress;
let safe = adapter.safe_for_snapshot(); let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best; if boundary.rollback.is_some() {
let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot { escape.trace.recoveries += 1;
game: emulator.export_state().expect("state export"),
frame: frame.clone(),
});
let recover = ratchet.observe(
safe,
u64::from(progress.rank),
progress.unique_locations as u64,
ms as u64,
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
);
if recover {
let snapshot = flybrain_gb::ratchet::Snapshot {
game: ratchet.game().expect("a recovery has a snapshot").to_vec(),
frame: ratchet.frame().expect("a recovery has a framebuffer").to_vec(),
};
let restored = {
let mut neural = AgentRecovery { agent: &mut agent };
recover_game(&mut emulator, &mut adapter, &mut neural, &snapshot)
.expect("recovering the game")
};
frame.copy_from_slice(&restored);
emulator.set_buttons(0);
trace.recoveries += 1;
location = adapter.location();
held_channel = None;
blocked_since_ms = ms;
still_since_ms = ms; still_since_ms = ms;
hold_start = (location, ms); escape.hold_start = (frame.location, ms);
} }
let trace = &mut escape.trace;
let Some(map) = adapter.map_id() else { continue }; let Some(map) = adapter.map_id() else { continue };
if trace.maps.last() != Some(&map) { if trace.maps.last() != Some(&map) {
@ -925,6 +876,7 @@ fn brain_trace(
} }
} }
} }
let Escape { mut trace, run_length, .. } = escape;
if run_length > 0.0 { if run_length > 0.0 {
trace.runs.push(run_length); trace.runs.push(run_length);
} }

View file

@ -189,11 +189,12 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
println!("- scene `{scene:?}`, player {player:?}, map {}x{}", size.width, size.height); println!("- scene `{scene:?}`, player {player:?}, map {}x{}", size.width, size.height);
println!("- objective: {:?}", state.objective()); println!("- objective: {:?}", state.objective());
if let Some(objective) = state.objective() { if let Some(objective) = state.objective() {
let from = palette::region_here(state)
.unwrap_or(geography::Region::whole(player.map));
println!( println!(
"- `next_hop({:?}, {:#04x})` = {:?}, neighbours {:?}", "- `next_step({from:?}, {:#04x})` = {:?}, neighbours {:?}",
geography::region_at(player.map, player.y),
objective.map, objective.map,
geography::next_hop(geography::region_at(player.map, player.y), objective.map), geography::next_step(from, objective.map),
geography::neighbours(player.map) geography::neighbours(player.map)
); );
} }
@ -808,6 +809,8 @@ fn accept_survey(
// [box not drawn, box drawn] x [press refused, press honoured], over every frame whose cursor // [box not drawn, box drawn] x [press refused, press honoured], over every frame whose cursor
// bytes say "the move list" -- which is the whole of what the seam read before row 50. // bytes say "the move list" -- which is the whole of what the seam read before row 50.
let mut readings = [[0usize; 2]; 2]; let mut readings = [[0usize; 2]; 2];
// The stream's frame (`flysim::frame::LegacyFrame`), behind the stub readout.
let mut legacy = flysim::frame::LegacyFrame::new();
println!("\n## Row 50: every battle frame, pressed at\n"); println!("\n## Row 50: every battle frame, pressed at\n");
println!("```"); println!("```");
@ -823,18 +826,9 @@ fn accept_survey(
} }
let bound = layer.bound_channels(); let bound = layer.bound_channels();
let active = decoder.decode_bound(&rates(hot), *ms, false, None, Some(&bound)); let active = decoder.decode_bound(&rates(hot), *ms, false, None, Some(&bound));
let mask = { legacy.execute(Some(&mut *layer), &active, 0, *ms, gb, &*adapter);
let ledger = AdapterLedger(adapter);
layer.decide(&active, 0, *ms, gb, &ledger).mask
};
gb.set_buttons(mask as u8);
gb.run_frame().expect("a frame should complete");
*ms += MS_PER_FRAME; *ms += MS_PER_FRAME;
adapter.sample(gb, *ms); legacy.stub_advance(Some(&mut *layer), gb, adapter, *ms).expect("a frame should complete");
{
let ledger = AdapterLedger(adapter);
let _ = layer.observe(gb, &ledger, *ms);
}
let Some((name, own_turn, forced)) = battle_reading(gb, adapter) else { continue }; let Some((name, own_turn, forced)) = battle_reading(gb, adapter) else { continue };
let geom = move_cursor_geometry(gb); let geom = move_cursor_geometry(gb);
@ -1353,7 +1347,12 @@ fn dialog_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64
/// (comma-separated names) presses those buttons whenever they are dealt. /// (comma-separated names) presses those buttons whenever they are dealt.
/// ///
/// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette /// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette
fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) { fn route_survey(
gb: &mut Emulator,
adapter: &mut PokemonRedReward,
ms: &mut f64,
checkpoint: &flysim::store::Checkpoint,
) {
use flybrain_gb::MacroPalette; use flybrain_gb::MacroPalette;
use flybrain_gb::pokemon_red::macros::cartridge::{FACINGS, MacroState, TalkTarget, TargetKey}; use flybrain_gb::pokemon_red::macros::cartridge::{FACINGS, MacroState, TalkTarget, TargetKey};
use flybrain_gb::pokemon_red::macros::path::Way; use flybrain_gb::pokemon_red::macros::path::Way;
@ -1387,6 +1386,13 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
let mut entries = 0usize; let mut entries = 0usize;
let mut arrived_at = 0usize; let mut arrived_at = 0usize;
let mut last_map: Option<u8> = None; let mut last_map: Option<u8> = None;
let save_rank: Option<u32> =
std::env::var("FLY_PROBE_SAVE_RANK").ok().and_then(|value| value.parse().ok());
let mut saved = false;
// `FLY_PROBE_CATCH_FRAME=41600` reads the first frame at or after that one on which the fly
// has the buttons and no macro is running (row 61: the frame a walk in the forest refused).
let catch_frame: Option<usize> =
std::env::var("FLY_PROBE_CATCH_FRAME").ok().and_then(|value| value.parse().ok());
// `FLY_PROBE_HOLD=right:96,up:32` holds raw directions first and prints where the fly is // `FLY_PROBE_HOLD=right:96,up:32` holds raw directions first and prints where the fly is
// every eight frames: what the cartridge does with a press, before any macro is asked. // every eight frames: what the cartridge does with a press, before any macro is asked.
@ -1434,6 +1440,9 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
let pad = format!("{:?} {names:?}", observed.scene); let pad = format!("{:?} {names:?}", observed.scene);
if pad != last_pad { if pad != last_pad {
println!("f{frame:<6} {:?} pad {pad}", player.map(|p| (p.map, p.x, p.y))); println!("f{frame:<6} {:?} pad {pad}", player.map(|p| (p.map, p.x, p.y)));
if let Some(line) = battle_line(gb) {
println!(" {line}");
}
last_pad = pad; last_pad = pad;
} }
let mut mask = 0u8; let mut mask = 0u8;
@ -1512,10 +1521,40 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
gb.run_frame().expect("a frame should complete"); gb.run_frame().expect("a frame should complete");
*ms += MS_PER_FRAME; *ms += MS_PER_FRAME;
adapter.sample(gb, *ms); adapter.sample(gb, *ms);
// `FLY_PROBE_SAVE_RANK=11` with `FLY_PROBE_SAVE` writes the first frame at or past that
// rung where the fly has the buttons in the overworld and no macro is running: a state the
// stream could be restored into next (row 59, after the badge), carried forward by the
// survey from the source checkpoint. The session ledgers are not in it, as they are in no
// checkpoint.
if let Some(want) = save_rank
&& !saved
&& adapter.progress().rank >= want
&& !running
&& matches!(observed.scene, flybrain_gb::SceneId::Overworld)
&& state::controllable(gb)
&& let Some(path) = std::env::var_os("FLY_PROBE_SAVE")
{
saved = true;
let bytes = save_state(checkpoint, gb, adapter, frame, &path);
println!(
"f{frame:<6} {:?} saved rank {} to `{}` ({bytes} bytes)",
state::player(gb).map(|p| (p.map, p.x, p.y)),
adapter.progress().rank,
std::path::Path::new(&path).display()
);
}
if single_refusals >= catch_after { if single_refusals >= catch_after {
caught_at = Some(frame); caught_at = Some(frame);
break; break;
} }
if catch_frame.is_some_and(|at| frame >= at)
&& !running
&& matches!(observed.scene, flybrain_gb::SceneId::Overworld)
&& !observed.bindings.is_empty()
{
caught_at = Some(frame);
break;
}
if let (Some(want), Some(player)) = (catch_map, player) { if let (Some(want), Some(player)) = (catch_map, player) {
if player.map == want && last_map != Some(want) { if player.map == want && last_map != Some(want) {
entries += 1; entries += 1;
@ -1554,6 +1593,8 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
frame as f64 * MS_PER_FRAME / 60_000.0, frame as f64 * MS_PER_FRAME / 60_000.0,
if single_refusals >= catch_after { if single_refusals >= catch_after {
"one button, refused twenty holds running".to_string() "one button, refused twenty holds running".to_string()
} else if catch_frame.is_some_and(|at| frame >= at) {
format!("the first free frame at or after {catch_frame:?}")
} else { } else {
format!("arrival {entries} on map {catch_map:?}") format!("arrival {entries} on map {catch_map:?}")
} }
@ -1607,7 +1648,9 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
// A room small enough to print whole is printed whole, with its people on it (row 58: // A room small enough to print whole is printed whole, with its people on it (row 58:
// the gym's leader is twelve rows from the door). // the gym's leader is twelve rows from the door).
let size = state.map_size().expect("a loaded map"); let size = state.map_size().expect("a loaded map");
let whole = size.width <= 24 && size.height <= 24; // `FLY_PROBE_WHOLE=1` prints a bigger map whole too (row 61: the forest is 34 by 48).
let whole = (size.width <= 24 && size.height <= 24)
|| std::env::var("FLY_PROBE_WHOLE").is_ok_and(|value| value == "1");
let people: Vec<(Tile, TalkTarget)> = path::person_targets(state) let people: Vec<(Tile, TalkTarget)> = path::person_targets(state)
.into_iter() .into_iter()
.chain(path::offscreen_person_targets(state)) .chain(path::offscreen_person_targets(state))
@ -1677,6 +1720,28 @@ fn separator_table(classes: &BTreeMap<(bool, bool, bool), u64>) -> String {
out out
} }
/// This state as a `FLYSIM01` checkpoint: the agent half is the source checkpoint's, unchanged --
/// the release box's own run carried forward by the stub, not a synthesised save -- and `.local/`
/// is not tracked, exactly as every other checkpoint in this workspace. Returns the size written.
fn save_state(
checkpoint: &flysim::store::Checkpoint,
gb: &mut Emulator,
adapter: &PokemonRedReward,
frame: usize,
path: &std::ffi::OsStr,
) -> usize {
let mut runtime = checkpoint.runtime.clone();
runtime.emulator = gb.export_state().expect("the emulator should export");
runtime.reward = adapter.export_state();
runtime.framebuffer = gb.framebuffer().to_vec();
runtime.emulator_frame = frame as u64;
let bytes =
flysim::store::encode(&checkpoint.agent, &runtime).expect("the envelope should encode");
flysim::store::write_atomic(std::path::Path::new(path), &bytes)
.expect("the checkpoint should be writable");
bytes.len()
}
fn main() { fn main() {
let Some(path) = std::env::var_os("FLY_ROM") else { let Some(path) = std::env::var_os("FLY_ROM") else {
println!("FLY_ROM is not set, so there is nothing to probe."); println!("FLY_ROM is not set, so there is nothing to probe.");
@ -1768,7 +1833,7 @@ fn main() {
// Row 57's pad survey: earn the session's ledgers from the checkpoint with the real palette, // Row 57's pad survey: earn the session's ledgers from the checkpoint with the real palette,
// and read the frame the pad comes down to one refusing button on. // and read the frame the pad comes down to one refusing button on.
if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "route") { if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "route") {
route_survey(&mut gb, &mut adapter, &mut ms); route_survey(&mut gb, &mut adapter, &mut ms, &checkpoint);
return; return;
} }
@ -1798,6 +1863,8 @@ fn main() {
let mut noattack = 0usize; let mut noattack = 0usize;
let mut before = (0u8, 0u8, 0u8); let mut before = (0u8, 0u8, 0u8);
let mut surveyed = 0usize; let mut surveyed = 0usize;
// The stream's frame (`flysim::frame::LegacyFrame`), behind the stub readout.
let mut legacy = flysim::frame::LegacyFrame::new();
for frame in 0..budget { for frame in 0..budget {
let bursting = ms < next_burst + BURST_MS; let bursting = ms < next_burst + BURST_MS;
let hot = bursting.then(|| channels[(burst / HOLDS_PER_SLOT) % channels.len()]); let hot = bursting.then(|| channels[(burst / HOLDS_PER_SLOT) % channels.len()]);
@ -1807,18 +1874,11 @@ fn main() {
} }
let bound = layer.bound_channels(); let bound = layer.bound_channels();
let active = decoder.decode_bound(&rates(hot), ms, false, None, Some(&bound)); let active = decoder.decode_bound(&rates(hot), ms, false, None, Some(&bound));
let mask = { legacy.execute(Some(&mut layer), &active, 0, ms, &mut gb, &adapter);
let ledger = AdapterLedger(&adapter);
layer.decide(&active, 0, ms, &mut gb, &ledger).mask
};
gb.set_buttons(mask as u8);
gb.run_frame().expect("a frame should complete");
ms += MS_PER_FRAME; ms += MS_PER_FRAME;
adapter.sample(&mut gb, ms); legacy
{ .stub_advance(Some(&mut layer), &mut gb, &mut adapter, ms)
let ledger = AdapterLedger(&adapter); .expect("a frame should complete");
let _ = layer.observe(&mut gb, &ledger, ms);
}
if catch_script if catch_script
&& gb.read8(ram::wSimulatedJoypadStatesIndex) != 0 && gb.read8(ram::wSimulatedJoypadStatesIndex) != 0
&& gb.read8(ram::wCurMap) == 1 && gb.read8(ram::wCurMap) == 1
@ -1886,19 +1946,10 @@ fn main() {
// release box's own run carried forward by the stub, not a synthesised save -- and // release box's own run carried forward by the stub, not a synthesised save -- and
// `.local/` is not tracked, exactly as every other checkpoint in this workspace. // `.local/` is not tracked, exactly as every other checkpoint in this workspace.
if let Some(save) = std::env::var_os("FLY_PROBE_SAVE") { if let Some(save) = std::env::var_os("FLY_PROBE_SAVE") {
let mut runtime = checkpoint.runtime.clone(); let bytes = save_state(&checkpoint, &mut gb, &adapter, frame, &save);
runtime.emulator = gb.export_state().expect("the emulator should export");
runtime.reward = adapter.export_state();
runtime.framebuffer = gb.framebuffer().to_vec();
runtime.emulator_frame = frame as u64;
let bytes = flysim::store::encode(&checkpoint.agent, &runtime)
.expect("the envelope should encode");
flysim::store::write_atomic(std::path::Path::new(&save), &bytes)
.expect("the checkpoint should be writable");
println!( println!(
"\nWrote this state to `{}` ({} bytes).", "\nWrote this state to `{}` ({bytes} bytes).",
std::path::Path::new(&save).display(), std::path::Path::new(&save).display()
bytes.len()
); );
} }
// The survey method on the one question the fix turns on: **what does the // The survey method on the one question the fix turns on: **what does the
@ -2064,3 +2115,36 @@ fn main() {
), ),
} }
} }
/// Row 60: the battle bytes a `MOVE n` button's effect rests on, in one line -- the fly's moves
/// with PP and whether the cartridge would answer each with nothing, both sides' stat stages
/// (7 is normal, 1 is -6), the enemy's stats, status and HP.
fn battle_line(gb: &mut Emulator) -> Option<String> {
let battle = state::battle(gb)?;
let own = battle.own?;
let moves: Vec<String> = own
.moves
.iter()
.flatten()
.map(|entry| {
let nothing = state::move_without_effect(gb, entry.id);
let row = state::move_data(gb, entry.id).map(|data| (data.effect, data.power));
format!("{:#04x} pp{} row{row:?} nothing={nothing:?}", entry.id, entry.pp)
})
.collect();
let stages = |gb: &mut Emulator, base: u16| -> Vec<u8> { (0..6).map(|i| gb.read8(base + i)).collect() };
let own_stages = stages(gb, ram::wPlayerMonStatMods);
let enemy_stages = stages(gb, ram::wEnemyMonStatMods);
let enemy_stats: Vec<u16> = (0..4)
.map(|i| u16::from(gb.read8(ram::wEnemyMonAttack + 2 * i)) * 256 + u16::from(gb.read8(ram::wEnemyMonAttack + 2 * i + 1)))
.collect();
Some(format!(
"menu={:?} own hp {}/{} moves [{}] stages {own_stages:?} | enemy {:?} stages {enemy_stages:?} stats {enemy_stats:?} status {:#04x}",
battle.menu,
own.hp,
own.max_hp,
moves.join(", "),
battle.enemy.map(|enemy| (enemy.species, enemy.level, enemy.hp, enemy.max_hp)),
gb.read8(ram::wEnemyMonStatus),
))
}

View file

@ -36,9 +36,11 @@
//! | `FLY_TRAP_THREADS` | 4 | sweep threads | //! | `FLY_TRAP_THREADS` | 4 | sweep threads |
//! | `FLY_TRAP_SEED` | 20260917 | seeds the palette | //! | `FLY_TRAP_SEED` | 20260917 | seeds the palette |
//! | `FLY_TRAP_SEED_*` | unset | rebuilds session ledgers a restore starts empty: `PUSHED`, `EXHAUSTED`, `TALKED`, `BLOCKED`, `STOOD` (`examples/support/ledgers.rs`, row 57) | //! | `FLY_TRAP_SEED_*` | unset | rebuilds session ledgers a restore starts empty: `PUSHED`, `EXHAUSTED`, `TALKED`, `BLOCKED`, `STOOD` (`examples/support/ledgers.rs`, row 57) |
//! | `FLY_TRACE` | unset | a path: the stream's per-frame trace of this run (`flysim::trace`), comparable with the service's own |
//! //!
//! The frame order is `simloop.rs`'s, as `examples/palette_bench.rs` expresses it, so what this //! The frame is `flysim::frame::LegacyFrame`, the one the stream runs, restored the way the stream
//! measures is the loop that ships rather than a second implementation of it. Without a //! restores it, so what this measures is the loop that ships rather than a second implementation
//! of it; `FLY_TRACE` records it in the stream's own trace format. Without a
//! checkpoint it refuses rather than booting the intro: a trap hunt is about a state the stream //! checkpoint it refuses rather than booting the intro: a trap hunt is about a state the stream
//! was actually in. //! was actually in.
@ -46,18 +48,19 @@ use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use flybrain_core::agent::{AgentConfig, NeuralAgent, RewardEvent as NeuralReward, TickOptions}; use flybrain_core::agent::{AgentConfig, NeuralAgent};
use flybrain_core::dataset::load_brain_dataset_from_dir; use flybrain_core::dataset::load_brain_dataset_from_dir;
use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask}; use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
use flybrain_core::lif::SweepPlan; use flybrain_core::lif::SweepPlan;
use flybrain_gb::adapter::GameAdapter; use flybrain_gb::adapter::GameAdapter;
use flybrain_gb::pokemon_red::PokemonRedReward; use flybrain_gb::pokemon_red::PokemonRedReward;
use flybrain_gb::ratchet::Ratchet; use flybrain_gb::ratchet::Ratchet;
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator}; use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator};
use flysim::config::Config; use flysim::config::Config;
use flysim::frame::{Executed, FrameObserver, LegacyFrame, Parts};
use flysim::macros::{MacroLayer, macro_layer}; use flysim::macros::{MacroLayer, macro_layer};
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
use flysim::trace::FrameTrace;
#[path = "support/ledgers.rs"] #[path = "support/ledgers.rs"]
mod ledgers; mod ledgers;
@ -154,28 +157,6 @@ fn env_usize(name: &str, default: usize) -> usize {
std::env::var(name).ok().and_then(|value| value.parse().ok()).unwrap_or(default) std::env::var(name).ok().and_then(|value| value.parse().ok()).unwrap_or(default)
} }
/// The neural half of a ratchet recovery, exactly as `simloop.rs` wires it.
struct AgentRecovery<'a> {
agent: &'a mut NeuralAgent,
}
impl NeuralRecovery for AgentRecovery<'_> {
fn clear_decoder_holds(&mut self) {
let ms = self.agent.network.ms;
self.agent.decoder.clear_holds(ms);
}
fn clear_eligibility(&mut self) {
let ms = self.agent.network.ms;
self.agent.network.plasticity.clear_eligibility(ms);
}
fn set_visual_frame(&mut self, frame: &[u8]) {
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
self.agent.network.set_visual_frame(frame, width, height);
}
}
/// Where the fly stood on one frame, and what it started on it. /// Where the fly stood on one frame, and what it started on it.
/// A refused macro and the `(map, x, y)` it was refused on. /// A refused macro and the `(map, x, y)` it was refused on.
type RefusedAt = (&'static str, Option<(u32, u32, u32)>); type RefusedAt = (&'static str, Option<(u32, u32, u32)>);
@ -247,6 +228,12 @@ struct Trace {
/// The pad's composition, measured rather than read off the table: "which sub-state offers /// The pad's composition, measured rather than read off the table: "which sub-state offers
/// `BACK`" is a question about the build under test and not about the document. /// `BACK`" is a question about the build under test and not about the document.
battle_pads: BTreeMap<&'static str, BTreeSet<String>>, battle_pads: BTreeMap<&'static str, BTreeSet<String>>,
/// Row 60: every battle as `(frames, macro starts, won)`, the one running, payouts by kind,
/// and `MOVE n` starts against how many chose a move the cartridge answers with nothing.
battles: Vec<(u64, u64, bool)>,
battle_now: Option<(u64, u64, bool)>,
payouts_by_kind: BTreeMap<&'static str, (u64, f64)>,
move_starts: (u64, u64),
wall_seconds: f64, wall_seconds: f64,
} }
@ -306,6 +293,161 @@ struct Trap {
macros: usize, macros: usize,
} }
/// The hunt's look inside the stream's frame (`flysim::frame`): what it reads before and after
/// the executor decides, and the stub readout. Everything else is the frame's own order.
struct Hunt {
stub: bool,
stub_hold: usize,
stub_next_ms: f64,
hold_ms: f64,
running: Option<Running>,
scene_run: (&'static str, u64, f64),
/// Read before `decide`, because `decide` is what starts the macro whose scene this is.
dialog_map: Option<u8>,
battle_sub: Option<&'static str>,
trace: Trace,
}
impl FrameObserver for Hunt {
/// The brain is still ticked -- the frame order, the plasticity and the cost are the run's --
/// and only the *readout* is replaced, so a stub run and a brain run differ in who chooses and
/// in nothing else.
fn readout(&mut self, ms: f64, bound: Option<&[String]>, active: &mut Vec<String>) {
if !self.stub {
return;
}
let hot = STUB_CHANNELS[(self.stub_hold / STUB_HOLDS_PER_CHANNEL) % STUB_CHANNELS.len()];
if ms >= self.stub_next_ms {
self.stub_next_ms = ms + self.hold_ms;
self.stub_hold += 1;
}
*active = bound.unwrap_or_default().iter().filter(|channel| *channel == hot).cloned().collect();
}
fn before_execute(&mut self, _frame: &LegacyFrame, parts: &mut Parts<'_>, _active: &[String]) {
let layer_scene = parts.macros.as_deref().map_or("", MacroLayer::scene_name);
self.dialog_map = (layer_scene == "dialog" || layer_scene == "unknown")
.then(|| flybrain_gb::pokemon_red::state::player(parts.emulator).map(|p| p.map))
.flatten();
self.battle_sub = battle_sub_state(parts.emulator);
if let Some(sub) = self.battle_sub {
*self.trace.battle_frames.entry(sub).or_insert(0) += 1;
let pad = self.trace.battle_pads.entry(sub).or_default();
for channel in parts.macros.as_deref().map(MacroLayer::bound_channels).unwrap_or_default() {
pad.insert(channel);
}
}
}
fn executed(&mut self, frame: &LegacyFrame, parts: &mut Parts<'_>, executed: &Executed) {
let ms = parts.agent.network.ms;
let location = frame.location;
let trace = &mut self.trace;
for event in &executed.events {
match event.outcome {
None => {
trace.starts.push((ms, event.name));
// Which press answered a box, and on which map: 991 `YES` in twenty brain
// minutes is a fact about one conversation, and this is what says which.
if let Some(map) = self.dialog_map {
*trace.dialog_macros.entry((event.name, map)).or_insert(0) += 1;
}
if let Some(sub) = self.battle_sub {
*trace.battle_starts.entry((event.name, sub)).or_insert(0) += 1;
}
if let Some(battle) = trace.battle_now.as_mut() {
battle.1 += 1;
}
if let Some(slot) = event.name.strip_prefix("MOVE ") {
trace.move_starts.0 += 1;
let id = flybrain_gb::pokemon_red::state::battle(parts.emulator)
.and_then(|battle| battle.own)
.zip(slot.parse::<usize>().ok())
.and_then(|(own, slot)| own.moves.get(slot - 1).copied().flatten())
.map(|entry| entry.id);
if id.is_some_and(|id| {
flybrain_gb::pokemon_red::state::move_without_effect(parts.emulator, id)
== Some(true)
}) {
trace.move_starts.1 += 1;
}
}
self.running = Some(Running {
name: event.name,
from: location,
tiles: location.into_iter().collect(),
frames: 0,
reach: 0,
});
}
Some(outcome) => {
*trace.outcomes.entry(outcome.as_str()).or_insert(0) += 1;
if outcome.as_str() == "refused" {
*trace.refusals.entry(event.name).or_insert(0) += 1;
let key = Some((event.name, location));
trace.refusal_run = if trace.refusal_run.0 == key {
(key, trace.refusal_run.1 + 1)
} else {
(key, 1)
};
if trace.refusal_run.1 > trace.longest_refusal_run.0 {
trace.longest_refusal_run = (trace.refusal_run.1, event.name);
}
}
if let Some(run) = self.running.take() {
let net = match (run.from, location) {
(Some((map, x, y)), Some((at, ax, ay))) if map == at => {
ax.abs_diff(x) + ay.abs_diff(y)
}
_ => 0,
};
trace.episodes.push(Episode {
name: run.name,
outcome: outcome.as_str(),
frames: run.frames,
tiles: run.tiles.len(),
net,
reach: run.reach,
});
}
}
}
}
{
let text = flybrain_gb::pokemon_red::state::text_box(parts.emulator);
let (corners, border) = flybrain_gb::pokemon_red::state::dialog_border(parts.emulator);
match (text.open, corners, border) {
(true, true, true) => trace.font_corners_border += 1,
(true, true, false) => trace.font_corners_no_border += 1,
(true, false, _) => trace.font_no_corners += 1,
(false, true, _) => trace.corners_no_font += 1,
(false, false, _) => {}
}
}
if let Some(layer) = parts.macros.as_deref() {
let name = layer.scene_name();
*trace.scenes.entry(name).or_insert(0) += 1;
if name == self.scene_run.0 {
self.scene_run.1 += 1;
} else {
self.scene_run = (name, 1, ms);
}
let longest = trace.longest_scene.entry(name).or_insert((0, 0.0));
if self.scene_run.1 > longest.0 {
*longest = (self.scene_run.1, self.scene_run.2 - trace.began_ms);
}
// Where the text box is, which is the half the scene histogram could not say.
if name == "dialog" || name == "unknown" {
let where_ = flybrain_gb::pokemon_red::state::player(parts.emulator)
.map(|player| (player.map, player.x, player.y));
if let Some(key) = where_ {
*trace.dialog_frames.entry(key).or_insert(0) += 1;
}
}
}
}
}
/// Run `minutes` brain minutes of the sim loop's frame order from `checkpoint`, recording where /// Run `minutes` brain minutes of the sim loop's frame order from `checkpoint`, recording where
/// the fly stood and what it started. /// the fly stood and what it started.
fn run( fn run(
@ -319,8 +461,6 @@ fn run(
) -> Trace { ) -> Trace {
let began_wall = std::time::Instant::now(); let began_wall = std::time::Instant::now();
let stub = std::env::var("FLY_TRAP_STUB").is_ok_and(|value| value == "1"); let stub = std::env::var("FLY_TRAP_STUB").is_ok_and(|value| value == "1");
let mut stub_hold = 0usize;
let mut stub_next_ms = f64::NEG_INFINITY;
let mut emulator = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES) let mut emulator = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
.expect("binjgb should accept the cartridge"); .expect("binjgb should accept the cartridge");
let mut adapter = PokemonRedReward::new(); let mut adapter = PokemonRedReward::new();
@ -333,34 +473,28 @@ fn run(
.or(preset.exclusive.as_ref()) .or(preset.exclusive.as_ref())
.expect("the preset has a group") .expect("the preset has a group")
.hold_ms; .hold_ms;
let blocked_ms =
preset.exclusive.as_ref().expect("the preset has an exclusive group").blocked_ms;
let agent_config = AgentConfig::with_decoder(preset); let agent_config = AgentConfig::with_decoder(preset);
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy()); let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
emulator
.import_state(&checkpoint.runtime.emulator)
.expect("the checkpoint's emulator state should import");
adapter
.import_state(&checkpoint.runtime.reward)
.expect("the checkpoint's reward ledger should import");
let snapshot = (!checkpoint.runtime.ratchet_game.is_empty()).then(|| {
flybrain_gb::ratchet::Snapshot {
game: checkpoint.runtime.ratchet_game.clone(),
frame: checkpoint.runtime.ratchet_frame.clone(),
}
});
ratchet
.import(Some(checkpoint.runtime.ratchet), snapshot, adapter.rank_ladder().len())
.expect("the checkpoint's ratchet state should import");
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent"); let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
if threads > 1 { if threads > 1 {
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan")); agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
} }
agent.import_state(&checkpoint.agent).expect("the checkpoint's agent should import"); // The stream's own restore, into the stream's own frame: a fresh process's readout transient
let (width, height) = (agent.frame.width, agent.frame.height); // (`restore: legacy-transient-reset`), which is what the fly has after the service restarts.
agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height); let mut frame = LegacyFrame::new()
.with_trace(FrameTrace::from_env().expect("FLY_TRACE should name a writable file"));
frame
.restore(
&mut Parts {
agent: &mut agent,
emulator: &mut emulator,
adapter: &mut adapter,
ratchet: &mut ratchet,
macros: None,
},
checkpoint,
)
.expect("the checkpoint should restore");
let mut config = Config::default(); let mut config = Config::default();
config.loop_.game = "pokemon-red".to_string(); config.loop_.game = "pokemon-red".to_string();
@ -385,222 +519,113 @@ fn run(
let began_ms = agent.network.ms; let began_ms = agent.network.ms;
let until = began_ms + minutes * MINUTE_MS; let until = began_ms + minutes * MINUTE_MS;
let mut frame = emulator.framebuffer().to_vec(); let rank = adapter.progress().rank;
let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new();
let mut location = adapter.location();
let mut blocked_since_ms = began_ms;
let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string);
let mut rank = adapter.progress().rank;
let mut running: Option<Running> = None;
// A periodic one-liner for a run that is going nowhere: what the fly is standing on, what it // A periodic one-liner for a run that is going nowhere: what the fly is standing on, what it
// faces, and which text box the detector is looking at. Off unless asked for, because it is a // faces, and which text box the detector is looking at. Off unless asked for, because it is a
// diagnostic and the tables above are the report. // diagnostic and the tables above are the report.
let trace_every_ms = env_f64("FLY_TRAP_TRACE_SECONDS", 0.0) * 1000.0; let trace_every_ms = env_f64("FLY_TRAP_TRACE_SECONDS", 0.0) * 1000.0;
let mut next_trace = began_ms; let mut next_trace = began_ms;
// The scene of the frames in a row, for "stuck in a text box" against "in and out of one". let mut hunt = Hunt {
let mut scene_run: (&'static str, u64, f64) = ("", 0, began_ms); stub,
let mut trace = Trace { stub_hold: 0,
steps: Vec::new(), stub_next_ms: f64::NEG_INFINITY,
starts: Vec::new(), hold_ms,
episodes: Vec::new(), running: None,
began_ms, // The scene of the frames in a row, for "stuck in a text box" against "in and out of one".
ended_ms: began_ms, scene_run: ("", 0, began_ms),
frames: 0, dialog_map: None,
recoveries: 0, battle_sub: None,
rungs: vec![(rank, adapter.progress().rank_label, 0.0)], trace: Trace {
outcomes: BTreeMap::new(), steps: Vec::new(),
scenes: BTreeMap::new(), starts: Vec::new(),
dialog_frames: BTreeMap::new(), episodes: Vec::new(),
dialog_macros: BTreeMap::new(), began_ms,
ended_in: ("", String::new()), ended_ms: began_ms,
longest_scene: BTreeMap::new(), frames: 0,
ended_why: String::new(), recoveries: 0,
ended_grid: String::new(), rungs: vec![(rank, adapter.progress().rank_label, 0.0)],
font_corners_border: 0, outcomes: BTreeMap::new(),
font_corners_no_border: 0, scenes: BTreeMap::new(),
font_no_corners: 0, dialog_frames: BTreeMap::new(),
corners_no_font: 0, dialog_macros: BTreeMap::new(),
battle_frames: BTreeMap::new(), ended_in: ("", String::new()),
battle_starts: BTreeMap::new(), longest_scene: BTreeMap::new(),
battle_pads: BTreeMap::new(), ended_why: String::new(),
wall_seconds: 0.0, ended_grid: String::new(),
seeded: seeded_note, font_corners_border: 0,
refusals: BTreeMap::new(), font_corners_no_border: 0,
refusal_run: (None, 0), font_no_corners: 0,
longest_refusal_run: (0, ""), corners_no_font: 0,
battle_frames: BTreeMap::new(),
battle_starts: BTreeMap::new(),
battle_pads: BTreeMap::new(),
battles: Vec::new(),
battle_now: None,
payouts_by_kind: BTreeMap::new(),
move_starts: (0, 0),
wall_seconds: 0.0,
seeded: seeded_note,
refusals: BTreeMap::new(),
refusal_run: (None, 0),
longest_refusal_run: (0, ""),
},
}; };
let mut rank = rank;
// One observation before the first frame, as the sim loop takes after a restore.
if let Some(layer) = macros.as_mut() { if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter); let ledger = AdapterLedger(&adapter);
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms); let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
} }
while agent.network.ms < until { while agent.network.ms < until {
let rewards: Vec<NeuralReward> = payouts let mut parts = Parts {
.iter() agent: &mut agent,
.map(|event| { emulator: &mut emulator,
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms)) adapter: &mut adapter,
}) ratchet: &mut ratchet,
.collect(); macros: macros.as_mut(),
let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true };
let ms = agent.network.ms;
let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms)
.then(|| agent.decoder.current().map(str::to_string))
.flatten();
let bound = macros.as_ref().map(MacroLayer::bound_channels);
let result = agent
.tick_bound(&frame, &options, blocked.as_deref(), bound.as_deref())
.expect("a tick");
// The brain is still ticked — the frame order, the plasticity and the cost are the run's —
// and only the *readout* is replaced, so a stub run and a brain run differ in who chooses
// and in nothing else.
let active: Vec<String> = if stub {
let hot = STUB_CHANNELS[(stub_hold / STUB_HOLDS_PER_CHANNEL) % STUB_CHANNELS.len()];
if ms >= stub_next_ms {
stub_next_ms = ms + hold_ms;
stub_hold += 1;
}
bound
.as_deref()
.unwrap_or_default()
.iter()
.filter(|channel| *channel == hot)
.cloned()
.collect()
} else {
result.active.clone()
}; };
let held = agent.decoder.current().map(str::to_string); let transition = frame.transition(&mut parts, &mut hunt).expect("a frame");
if held != held_channel { let ms = transition.ms;
held_channel = held; hunt.trace.frames += 1;
blocked_since_ms = ms; for payout in &transition.evaluated.rewards {
} let entry = hunt.trace.payouts_by_kind.entry(payout.kind).or_insert((0, 0.0));
*entry = (entry.0 + 1, entry.1 + payout.value);
let ms = agent.network.ms;
let mut mask = to_button_mask(&active);
// Read before `decide`, because `decide` is what starts the macro whose scene this is.
let layer_scene = macros.as_ref().map_or("", MacroLayer::scene_name);
let dialog_map = (layer_scene == "dialog" || layer_scene == "unknown")
.then(|| flybrain_gb::pokemon_red::state::player(&mut emulator).map(|p| p.map))
.flatten();
let battle_sub = battle_sub_state(&mut emulator);
if let Some(sub) = battle_sub {
*trace.battle_frames.entry(sub).or_insert(0) += 1;
let pad = trace.battle_pads.entry(sub).or_default();
for channel in bound.as_deref().unwrap_or_default() {
pad.insert(channel.clone());
}
}
if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter);
let decision = layer.decide(&active, mask, ms, &mut emulator, &ledger);
mask = decision.mask;
for event in &decision.events {
match event.outcome {
None => {
trace.starts.push((ms, event.name));
// Which press answered a box, and on which map: 991 `YES` in twenty brain
// minutes is a fact about one conversation, and this is what says which.
if let Some(map) = dialog_map {
*trace.dialog_macros.entry((event.name, map)).or_insert(0) += 1;
}
if let Some(sub) = battle_sub {
*trace.battle_starts.entry((event.name, sub)).or_insert(0) += 1;
}
running = Some(Running {
name: event.name,
from: location,
tiles: location.into_iter().collect(),
frames: 0,
reach: 0,
});
}
Some(outcome) => {
*trace.outcomes.entry(outcome.as_str()).or_insert(0) += 1;
if outcome.as_str() == "refused" {
*trace.refusals.entry(event.name).or_insert(0) += 1;
let key = Some((event.name, location));
trace.refusal_run = if trace.refusal_run.0 == key {
(key, trace.refusal_run.1 + 1)
} else {
(key, 1)
};
if trace.refusal_run.1 > trace.longest_refusal_run.0 {
trace.longest_refusal_run = (trace.refusal_run.1, event.name);
}
}
if let Some(run) = running.take() {
let net = match (run.from, location) {
(Some((map, x, y)), Some((at, ax, ay))) if map == at => {
ax.abs_diff(x) + ay.abs_diff(y)
}
_ => 0,
};
trace.episodes.push(Episode {
name: run.name,
outcome: outcome.as_str(),
frames: run.frames,
tiles: run.tiles.len(),
net,
reach: run.reach,
});
}
}
}
}
} }
{ {
let text = flybrain_gb::pokemon_red::state::text_box(&mut emulator); use flybrain_gb::MemoryReader;
let (corners, border) = let trace = &mut hunt.trace;
flybrain_gb::pokemon_red::state::dialog_border(&mut emulator); let fighting =
match (text.open, corners, border) { parts.emulator.read8(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle) != 0;
(true, true, true) => trace.font_corners_border += 1, let won = transition
(true, true, false) => trace.font_corners_no_border += 1, .evaluated
(true, false, _) => trace.font_no_corners += 1, .rewards
(false, true, _) => trace.corners_no_font += 1, .iter()
(false, false, _) => {} .any(|payout| matches!(payout.kind, "battle" | "trainer"));
} match (fighting, trace.battle_now.as_mut()) {
} (true, Some(battle)) => {
if let Some(layer) = macros.as_ref() { battle.0 += 1;
let name = layer.scene_name(); battle.2 |= won;
*trace.scenes.entry(name).or_insert(0) += 1;
if name == scene_run.0 {
scene_run.1 += 1;
} else {
scene_run = (name, 1, ms);
}
let longest = trace.longest_scene.entry(name).or_insert((0, 0.0));
if scene_run.1 > longest.0 {
*longest = (scene_run.1, scene_run.2 - began_ms);
}
// Where the text box is, which is the half the scene histogram could not say.
if name == "dialog" || name == "unknown" {
let where_ = flybrain_gb::pokemon_red::state::player(&mut emulator)
.map(|player| (player.map, player.x, player.y));
if let Some(key) = where_ {
*trace.dialog_frames.entry(key).or_insert(0) += 1;
} }
(true, None) => trace.battle_now = Some((1, 0, won)),
(false, Some(_)) => {
let mut battle = trace.battle_now.take().expect("a battle");
battle.2 |= won;
trace.battles.push(battle);
}
(false, None) => {}
} }
} }
emulator.set_buttons(mask as u8);
emulator.run_frame().expect("a frame should complete");
trace.frames += 1;
frame.copy_from_slice(emulator.framebuffer());
payouts = adapter.sample(&mut emulator, ms);
if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter);
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
}
if trace_every_ms > 0.0 && ms >= next_trace { if trace_every_ms > 0.0 && ms >= next_trace {
next_trace = ms + trace_every_ms; next_trace = ms + trace_every_ms;
let scene = macros.as_ref().map_or("", MacroLayer::scene_name); let scene = parts.macros.as_deref().map_or("", MacroLayer::scene_name);
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile}; use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile};
// Read before the state borrows the emulator: this is the same call the state makes, // Read before the state borrows the emulator: this is the same call the state makes,
// and the only one that can say *which* refusal a frame is. // and the only one that can say *which* refusal a frame is.
let refusal = flybrain_gb::pokemon_red::state::map_grid(&mut emulator).err(); let refusal = flybrain_gb::pokemon_red::state::map_grid(parts.emulator).err();
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator); let mut state = flybrain_gb::pokemon_red::state::PokeState::new(parts.emulator);
let state: &mut dyn MacroState = &mut state; let state: &mut dyn MacroState = &mut state;
let player = state.player(); let player = state.player();
let ahead = player.and_then(|player| { let ahead = player.and_then(|player| {
@ -608,22 +633,18 @@ fn run(
flybrain_gb::pokemon_red::macros::path::target_at(state, ahead) flybrain_gb::pokemon_red::macros::path::target_at(state, ahead)
}); });
let ground = grid_line(state, player, refusal); let ground = grid_line(state, player, refusal);
let why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator); let why = flybrain_gb::pokemon_red::scene::why_unknown(parts.emulator);
println!( println!(
"trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}\n {ground}", "trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}\n {ground}",
(ms - began_ms) / MINUTE_MS (ms - began_ms) / MINUTE_MS
); );
} }
let now = adapter.location(); let location = frame.location;
if now.is_some() && now != location {
location = now;
blocked_since_ms = ms;
}
if let Some((map, x, y)) = location { if let Some((map, x, y)) = location {
trace.steps.push((ms, map, x, y)); hunt.trace.steps.push((ms, map, x, y));
} }
if let Some(run) = running.as_mut() { if let Some(run) = hunt.running.as_mut() {
run.frames += 1; run.frames += 1;
if let Some(at) = location { if let Some(at) = location {
run.tiles.insert(at); run.tiles.insert(at);
@ -635,47 +656,18 @@ fn run(
} }
} }
let progress = adapter.progress(); let progress = transition.evaluated.progress;
if progress.rank != rank { if progress.rank != rank {
rank = progress.rank; rank = progress.rank;
trace.rungs.push((rank, progress.rank_label, ms - began_ms)); hunt.trace.rungs.push((rank, progress.rank_label, ms - began_ms));
} }
let safe = adapter.safe_for_snapshot(); let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best; if boundary.rollback.is_some() {
let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot { hunt.trace.recoveries += 1;
game: emulator.export_state().expect("state export"), hunt.running = None;
frame: frame.clone(),
});
let recover = ratchet.observe_with_game_over(
safe,
u64::from(progress.rank),
progress.unique_locations as u64,
ms as u64,
adapter.game_over(),
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
);
if recover {
let snapshot = flybrain_gb::ratchet::Snapshot {
game: ratchet.game().expect("a recovery has a snapshot").to_vec(),
frame: ratchet.frame().expect("a recovery has a framebuffer").to_vec(),
};
let restored = {
let mut neural = AgentRecovery { agent: &mut agent };
recover_game(&mut emulator, &mut adapter, &mut neural, &snapshot)
.expect("recovering the game")
};
frame.copy_from_slice(&restored);
emulator.set_buttons(0);
trace.recoveries += 1;
location = adapter.location();
held_channel = None;
blocked_since_ms = ms;
if let Some(layer) = macros.as_mut() {
layer.cancel(ms);
}
running = None;
} }
} }
let mut trace = hunt.trace;
trace.ended_in = ( trace.ended_in = (
macros.as_ref().map_or("", MacroLayer::scene_name), macros.as_ref().map_or("", MacroLayer::scene_name),
adapter.mode().to_string(), adapter.mode().to_string(),
@ -987,6 +979,7 @@ fn main() {
println!("| {name} | {sub} | {n} |"); println!("| {name} | {sub} | {n} |");
} }
} }
battle_report(&trace);
println!("\n| scene | frames | longest run | run began (brain min) |"); println!("\n| scene | frames | longest run | run began (brain min) |");
println!("| --- | ---: | ---: | ---: |"); println!("| --- | ---: | ---: | ---: |");
for (scene, frames) in &trace.scenes { for (scene, frames) in &trace.scenes {
@ -1017,3 +1010,43 @@ fn main() {
WINDOW_MS / 1000.0 WINDOW_MS / 1000.0
); );
} }
/// Row 60's numbers: payouts by kind, every battle's length and whether it paid a win, and the
/// `MOVE n` starts that chose a move the cartridge answers with nothing.
fn battle_report(trace: &Trace) {
println!("\n| payout kind | n | total |");
println!("| --- | ---: | ---: |");
for (kind, (n, total)) in &trace.payouts_by_kind {
println!("| {kind} | {n} | {total:.2} |");
}
let mut lengths: Vec<u64> = trace.battles.iter().map(|battle| battle.0).collect();
lengths.sort_unstable();
let at = |q: f64| -> u64 {
if lengths.is_empty() {
return 0;
}
lengths[((lengths.len() - 1) as f64 * q).round() as usize]
};
println!("\n| battles | n |");
println!("| --- | ---: |");
println!("| ended | {} |", trace.battles.len());
println!("| won (a battle or trainer payout) | {} |", trace.battles.iter().filter(|b| b.2).count());
println!(
"| still running at the end | {} |",
trace.battle_now.map_or("no".to_string(), |b| format!("{} frames, {} macros", b.0, b.1))
);
println!(
"| frames, median / p90 / max | {} / {} / {} |",
at(0.5),
at(0.9),
lengths.last().copied().unwrap_or(0)
);
println!(
"| macros per battle, max | {} |",
trace.battles.iter().map(|b| b.1).max().unwrap_or(0)
);
println!(
"| `MOVE n` starts / on a move without effect | {} / {} |",
trace.move_starts.0, trace.move_starts.1
);
}

View file

@ -0,0 +1,705 @@
//! The legacy frame: one Game Boy frame of the live loop, in its phases, in the one order.
//!
//! This is the order `simloop.rs` runs on the stream, and the order every harness that claims to
//! measure the stream runs: the trap hunt, the palette bench, the room-escape bench, and the
//! stub-readout drivers of the ROM tests and the scene probe. It used to be written out in each of
//! them, and the copies had drifted: the benches ticked the brain through `NeuralAgent::tick`, which
//! installs the previous frame and its rewards *after* the next ticks, so every bench ran the
//! brain one frame behind the stream. There is one copy now, and the parity oracle is this file.
//!
//! The phases are named for the lockstep transaction they become in the session framework
//! (`docs/design/session-framework/legacy-gameboy-v1.md` section 4):
//!
//! | phase | what it does | lockstep |
//! | --- | --- | --- |
//! | (host) | drains commands: sugar and operator pulses are applied here, before the ticks | admission at `Ready(k)` |
//! | [`LegacyFrame::prepare`] | 16 or 17 brain ticks, the remainder carried; decode with the scene's bound channels and the blocked direction | A: `Agent.Prepare` |
//! | [`LegacyFrame::execute`] | the raw mask, then the macro layer decides the mask | B: the executor |
//! | [`LegacyFrame::advance`] | the joypad, one emulator frame, the framebuffer, the audio | B: `Environment.Advance` |
//! | [`LegacyFrame::evaluate`] | reward events, the macro layer's observation, the location, the rank | C: the task |
//! | [`LegacyFrame::commit`] | install the frame, one stimulation per event, one reinforcement | D: `Agent.Commit` |
//! | (host) | the milestone archive | a capture at `Ready(k+1)` |
//! | [`LegacyFrame::boundary`] | the ratchet's capture and decision, and the rollback when it fires | C decides; slot save and rollback at `Ready(k+1)` |
//!
//! Two moves from the order `simloop.rs` used to spell out, both between operations that touch
//! disjoint state, so neither changes a byte: the visual frame is installed in `commit` rather
//! than straight after the emulator frame (nothing reads the network in between), and the
//! stimulation and reinforcement come after the macro layer's observation and the location
//! (which read the emulator and the adapter, never the network). The milestone archive stays
//! where the stream has it -- after the reinforcement, before the ratchet captures -- which is
//! why the ratchet is its own call after `transition`: the host takes its archive between the
//! two. `FLY_TRACE` (`crate::trace`) records every phase, and a trace of the stream from one
//! checkpoint is byte-identical before and after this extraction.
use anyhow::{Result, anyhow};
use flybrain_core::agent::NeuralAgent;
use flybrain_core::decoder::gameboy::to_button_mask;
use flybrain_gb::RewardEvent;
use flybrain_gb::adapter::{GameAdapter, ProgressSnapshot};
use flybrain_gb::emulator::{Emulator, FRAMEBUFFER_LEN};
use flybrain_gb::macros::AdapterLedger;
use flybrain_gb::ratchet::{Ratchet, Snapshot};
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use crate::macros::{MacroEvent, MacroLayer, Silence};
use crate::trace::FrameTrace;
/// Everything one frame reads and writes besides the frame's own state: the parts the loop owns.
pub struct Parts<'a> {
pub agent: &'a mut NeuralAgent,
pub emulator: &'a mut Emulator,
pub adapter: &'a mut dyn GameAdapter,
pub ratchet: &'a mut Ratchet,
/// `None` in raw mode, where not one line of the macro layer runs.
pub macros: Option<&'a mut MacroLayer>,
}
/// Where a host may look in, or time a phase. Every method defaults to nothing.
///
/// The stream's loop uses [`FrameObserver::after`] for its per-phase profile and nothing else. A
/// harness may read the emulator between phases to measure the run, and a stub-readout harness may
/// replace the decision in [`FrameObserver::readout`]; nothing else about the frame is open.
pub trait FrameObserver {
/// A phase has finished. `agent` is lent for the profiler's kernel timings.
fn after(&mut self, _phase: FramePhase, _agent: &mut NeuralAgent) {}
/// The decoded decision, before the executor sees it. The stream never replaces it; the trap
/// hunt's `FLY_TRAP_STUB` does, and the brain still ticks exactly as it would.
fn readout(&mut self, _ms: f64, _bound: Option<&[String]>, _active: &mut Vec<String>) {}
/// Just before the executor decides: O[k] is on the emulator, the palette is the one dealt
/// for it.
fn before_execute(&mut self, _frame: &LegacyFrame, _parts: &mut Parts<'_>, _active: &[String]) {
}
/// The executor has decided and the mask is not yet on the joypad.
fn executed(&mut self, _frame: &LegacyFrame, _parts: &mut Parts<'_>, _executed: &Executed) {}
}
/// The observer that observes nothing.
impl FrameObserver for () {}
/// The points [`FrameObserver::after`] is called at, in order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FramePhase {
/// The brain ticks are done.
Ticked,
/// The decode and the executor's decision are done.
Executed,
/// The emulator frame has run.
Emulated,
/// The framebuffer and the audio are taken.
Advanced,
/// Rewards are sampled, the scene observed and the transition committed to the brain.
Committed,
}
/// Phase B's result.
#[derive(Debug, Default)]
pub struct Executed {
/// The mask the emulator is given.
pub mask: u32,
/// The macro layer's start and finish events, in order.
pub events: Vec<MacroEvent>,
/// Why nothing was pressed, when nothing was (`crate::macros::Decision::silence`).
pub silence: Option<Silence>,
}
/// Phase C's result.
#[derive(Debug)]
pub struct Evaluated {
/// Reward events from the frame just produced, in adapter order.
pub rewards: Vec<RewardEvent>,
/// The macro layer's own events from observing that frame (at most one abandonment).
pub abandoned: Vec<MacroEvent>,
pub progress: ProgressSnapshot,
}
/// One transition `k -> k+1`, up to and including its commit.
#[derive(Debug)]
pub struct Transition {
/// Brain ticks this frame advanced.
pub ticks: u64,
/// The brain clock after them, which is the clock of every phase that follows.
pub ms: f64,
/// The scene's bound macro channels the decode was masked to; `None` in raw mode.
pub bound: Option<Vec<String>>,
/// The decision the executor was given.
pub active: Vec<String>,
pub executed: Executed,
/// The frame's audio, binjgb's unsigned 8-bit interleaved stereo.
pub audio: Vec<u8>,
pub evaluated: Evaluated,
}
/// Why the ratchet rolled the game back.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RollbackTrigger {
GameOver,
Stall,
}
/// A rollback at the boundary.
#[derive(Debug)]
pub struct Rollback {
pub trigger: RollbackTrigger,
/// The running macro's abandonment and the restored scene's observation, in order.
pub events: Vec<MacroEvent>,
}
/// What happened at `Ready(k+1)`.
#[derive(Debug, Default)]
pub struct Boundary {
/// The ratchet captured a slot this boundary.
pub captured: bool,
pub rollback: Option<Rollback>,
}
/// The neural half of a ratchet recovery, wired to `flybrain-core`.
struct AgentRecovery<'a> {
agent: &'a mut NeuralAgent,
}
impl NeuralRecovery for AgentRecovery<'_> {
fn clear_decoder_holds(&mut self) {
let ms = self.agent.network.ms;
self.agent.decoder.clear_holds(ms);
}
fn clear_eligibility(&mut self) {
let ms = self.agent.network.ms;
self.agent.network.plasticity.clear_eligibility(ms);
}
fn set_visual_frame(&mut self, frame: &[u8]) {
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
self.agent.network.set_visual_frame(frame, width, height);
}
}
/// The frame's own state: the clock remainder, the frame counter, the frame on screen, the mask,
/// and the readout's blocked-direction window.
///
/// The window (`docs/readout.md`) is the player's area and tile as of the last frame the adapter
/// reported one, the channel the group is holding, and the brain clock at which *either* of those
/// last changed. A direction is only blamed once it has been held for a whole `blocked_ms` with no
/// movement, so a direction that has just won is never blamed for a wall the previous one hit.
/// All three are transient and never checkpointed: one hold of a wall after a restart is cheaper
/// than a stale position surviving a restore (`restore: legacy-transient-reset`).
pub struct LegacyFrame {
/// Fractional millisecond carried into the next frame; checkpointed.
pub remainder: f64,
/// Frames the emulator has run in this fly's life; checkpointed.
pub frame_counter: u64,
/// The frame on screen: the last one produced, or a restored slot's.
pub frame_buffer: Vec<u8>,
/// The mask on the joypad; checkpointed.
pub buttons: u32,
pub location: Option<(u32, u32, u32)>,
pub held_channel: Option<String>,
pub blocked_since_ms: f64,
trace: Option<FrameTrace>,
}
impl Default for LegacyFrame {
fn default() -> Self {
Self::new()
}
}
impl LegacyFrame {
/// The state of a fresh process: nothing held, no location, the blocked window starting at
/// brain time 0 (legacy-gameboy-v1 section 14), and a black frame.
pub fn new() -> Self {
Self {
remainder: 0.0,
frame_counter: 0,
frame_buffer: vec![0u8; FRAMEBUFFER_LEN],
buttons: 0,
location: None,
held_channel: None,
blocked_since_ms: 0.0,
trace: None,
}
}
/// Record every phase into `trace` (`FLY_TRACE`).
pub fn with_trace(mut self, trace: Option<FrameTrace>) -> Self {
self.trace = trace;
self
}
/// The trace, when one is on: a host records its admissions and captures through it.
pub fn trace_mut(&mut self) -> Option<&mut FrameTrace> {
self.trace.as_mut()
}
// -- setup -------------------------------------------------------------------------------
/// A fresh start: one frame with no button down, then the brain's warm-up on it
/// (`Environment.Initialize`, legacy-gameboy-v1 section 9). Returns that frame's audio.
pub fn initialize(
&mut self,
emulator: &mut Emulator,
agent: &mut NeuralAgent,
) -> Result<Vec<u8>> {
emulator
.run_frame()
.map_err(|error| anyhow!("running the first frame: {error}"))?;
self.frame_buffer.copy_from_slice(emulator.framebuffer());
self.frame_counter = 1;
let audio = emulator.take_audio_u8();
agent
.warmup(Some(&self.frame_buffer))
.map_err(|error| anyhow!("{error}"))?;
Ok(audio)
}
/// Everything a `FLYSIM01` checkpoint restores into the parts and the frame, in the order the
/// stream restores it; the host checks the cartridge and the compatibility string first.
///
/// The readout transient is left as a fresh process has it (`legacy-transient-reset`), which
/// is what a restart of the service gives the fly. `import_state` of the agent is
/// self-validating, so a refused checkpoint leaves the agent as it was.
pub fn restore(
&mut self,
parts: &mut Parts<'_>,
checkpoint: &crate::store::Checkpoint,
) -> Result<()> {
let runtime = &checkpoint.runtime;
if runtime.framebuffer.len() != FRAMEBUFFER_LEN {
anyhow::bail!(
"checkpoint framebuffer is {} bytes",
runtime.framebuffer.len()
);
}
parts
.agent
.import_state(&checkpoint.agent)
.map_err(|error| anyhow!("{error}"))?;
parts
.emulator
.import_state(&runtime.emulator)
.map_err(|error| anyhow!("{error}"))?;
if !runtime.reward.is_null() {
parts
.adapter
.import_state(&runtime.reward)
.map_err(|error| anyhow!("{error}"))?;
}
let snapshot = if runtime.ratchet_game.is_empty() {
None
} else {
Some(Snapshot {
game: runtime.ratchet_game.clone(),
frame: runtime.ratchet_frame.clone(),
})
};
parts
.ratchet
.import(
Some(runtime.ratchet),
snapshot,
parts.adapter.rank_ladder().len(),
)
.map_err(|error| anyhow!("{error}"))?;
self.remainder = checkpoint.agent.remainder;
self.frame_counter = runtime.emulator_frame;
self.buttons = runtime.buttons;
self.frame_buffer.copy_from_slice(&runtime.framebuffer);
let (width, height) = (parts.agent.frame.width, parts.agent.frame.height);
parts
.agent
.network
.set_visual_frame(&self.frame_buffer, width, height);
parts.emulator.set_buttons(self.buttons as u8);
Ok(())
}
// -- the transition ----------------------------------------------------------------------
/// Transition `k -> k+1`, prepare through commit. The host takes its milestone archive after
/// this and then calls [`LegacyFrame::boundary`].
pub fn transition(
&mut self,
parts: &mut Parts<'_>,
observer: &mut dyn FrameObserver,
) -> Result<Transition> {
let ticks = self.tick(parts.agent);
observer.after(FramePhase::Ticked, parts.agent);
// Not the mode string: the adapter decides what counts as boot, because a platformer
// needs the permissive Start variant in four of its five modes (`GameAdapter::boot`).
let boot = parts.adapter.boot();
// The scene's own macro buttons, for the macro group's per-decision mask
// (`docs/design/macros.md` section 12: "unbound channels are masked from the decision").
// They are the bindings the previous frame's `observe` dealt, which is the palette the
// page is showing, so the fly is choosing among exactly the buttons the audience can see.
let bound = parts.macros.as_deref().map(MacroLayer::bound_channels);
let mut active = self.decode(parts.agent, boot, bound.as_deref());
let ms = parts.agent.network.ms;
observer.readout(ms, bound.as_deref(), &mut active);
observer.before_execute(self, parts, &active);
let raw = to_button_mask(&active);
let executed = self.execute(
parts.macros.as_deref_mut(),
&active,
raw,
ms,
parts.emulator,
&*parts.adapter,
);
observer.executed(self, parts, &executed);
observer.after(FramePhase::Executed, parts.agent);
let audio = self.advance(parts.emulator, parts.agent, observer)?;
observer.after(FramePhase::Advanced, parts.agent);
let evaluated = self.evaluate(
parts.emulator,
parts.adapter,
parts.macros.as_deref_mut(),
ms,
);
self.commit(parts.agent, &evaluated.rewards, ms);
observer.after(FramePhase::Committed, parts.agent);
Ok(Transition {
ticks,
ms,
bound,
active,
executed,
audio,
evaluated,
})
}
/// The rest of phase B and phase C behind a stub readout, for the drivers that measure the
/// macros without a brain (the ROM tests, the scene probe). The driver decodes its stub,
/// calls [`LegacyFrame::execute`] with no raw mask, reads what it measures, and then this runs
/// the frame and evaluates it. There is no commit and no ratchet.
///
/// A stub has no phase A to advance its clock in, so it keeps its own and advances it with the
/// emulator frame: it decides at its clock and evaluates at `evaluate_ms`, one frame later.
pub fn stub_advance(
&mut self,
macros: Option<&mut MacroLayer>,
emulator: &mut Emulator,
adapter: &mut dyn GameAdapter,
evaluate_ms: f64,
) -> Result<Evaluated> {
self.run(emulator)?;
let _ = self.take_frame(emulator);
Ok(self.evaluate(emulator, adapter, macros, evaluate_ms))
}
/// Phase A: brain ticks and the decode, masked to `bound`.
pub fn prepare(
&mut self,
agent: &mut NeuralAgent,
boot: bool,
bound: Option<&[String]>,
) -> (u64, Vec<String>) {
let ticks = self.tick(agent);
(ticks, self.decode(agent, boot, bound))
}
/// Phase A, the ticks: 16 or 17 whole milliseconds, the fraction carried to the next frame.
pub fn tick(&mut self, agent: &mut NeuralAgent) -> u64 {
if let Some(trace) = self.trace.as_mut() {
trace.begin(self.frame_counter, agent.network.ms);
}
self.remainder += agent.ms_per_frame;
let steps = self.remainder.floor();
self.remainder -= steps;
agent.network.step(steps as u64);
if let Some(trace) = self.trace.as_mut() {
trace.ticked(steps as u64, self.remainder, &agent.network);
}
steps as u64
}
/// Phase A, the readout: decode the rates with the blocked direction and the bound channels,
/// and restart the blocked window when the held channel changes.
pub fn decode(
&mut self,
agent: &mut NeuralAgent,
boot: bool,
bound: Option<&[String]>,
) -> Vec<String> {
let ms = agent.network.ms;
let rates = agent.network.rates.clone();
// The readout's blocked-direction cooldown (`docs/readout.md`): the direction the group
// is holding, once the adapter's position has stood still for a whole `blocked_ms`. The
// loop owns the clock and the position; the decoder only learns *which* channel did
// nothing. `blocked_ms == 0` -- the platformer preset, and the Game Boy preset before
// v0.1.1 -- switches the rule off here, before the decoder is asked.
let blocked_ms = agent.decoder.blocked_ms();
let blocked = (blocked_ms > 0.0 && ms - self.blocked_since_ms >= blocked_ms)
.then(|| agent.decoder.current())
.flatten()
.map(str::to_string);
let active = agent
.decoder
.decode_bound(&rates, ms, boot, blocked.as_deref(), bound);
// A new winner starts its own window: it has not had a hold to move in yet.
let held = agent.decoder.current().map(str::to_string);
if held != self.held_channel {
self.held_channel = held;
self.blocked_since_ms = ms;
}
active
}
/// Phase B: the mask. `raw_mask` is the decision's own buttons (`to_button_mask`); in macros
/// mode the mask that reaches the emulator is the running macro's, or nothing, or -- on the
/// title screen alone -- the raw mask (`docs/design/macros.md` sections 4 and 12). In raw mode
/// it is the raw mask.
pub fn execute(
&mut self,
macros: Option<&mut MacroLayer>,
active: &[String],
raw_mask: u32,
ms: f64,
emulator: &mut Emulator,
adapter: &dyn GameAdapter,
) -> Executed {
self.buttons = raw_mask;
let executed = match macros {
// The adapter's exploration ledger answers the ways out' "unvisited" -- read-only, by
// `&dyn`, and the only thing the palette is told about the reward side.
Some(layer) => {
let ledger = AdapterLedger(adapter);
let decision = layer.decide(active, self.buttons, ms, emulator, &ledger);
self.buttons = decision.mask;
Executed {
mask: decision.mask,
events: decision.events,
silence: decision.silence,
}
}
None => Executed {
mask: self.buttons,
..Executed::default()
},
};
if let Some(trace) = self.trace.as_mut() {
trace.decided(active);
trace.executed(self.buttons, &executed.events);
}
executed
}
/// Phase B, the environment: the joypad, one emulator frame, the frame it drew and its audio.
pub fn advance(
&mut self,
emulator: &mut Emulator,
agent: &mut NeuralAgent,
observer: &mut dyn FrameObserver,
) -> Result<Vec<u8>> {
self.run(emulator)?;
observer.after(FramePhase::Emulated, agent);
Ok(self.take_frame(emulator))
}
fn run(&mut self, emulator: &mut Emulator) -> Result<()> {
emulator.set_buttons(self.buttons as u8);
emulator
.run_frame()
.map_err(|error| anyhow!("frame {}: {error}", self.frame_counter + 1))?;
self.frame_counter += 1;
Ok(())
}
fn take_frame(&mut self, emulator: &mut Emulator) -> Vec<u8> {
self.frame_buffer.copy_from_slice(emulator.framebuffer());
if let Some(trace) = self.trace.as_mut() {
trace.advanced(&self.frame_buffer, emulator);
}
emulator.take_audio_u8()
}
/// Phase C: rewards from the frame just produced, then the scene, then the location.
///
/// `docs/design/macros.md` section 2: the scene is sampled once per game frame, after the
/// frame, so the palette the fly is offered on the next frame is the one for the frame it can
/// actually see.
pub fn evaluate(
&mut self,
emulator: &mut Emulator,
adapter: &mut dyn GameAdapter,
macros: Option<&mut MacroLayer>,
ms: f64,
) -> Evaluated {
let rewards = adapter.sample(emulator, ms);
// At most one: a macro that has run into a scene with no palette.
let abandoned = match macros {
Some(layer) => {
let ledger = AdapterLedger(&*adapter);
layer.observe(emulator, &ledger, ms)
}
None => Vec::new(),
};
// The cooldown's other reset: the player actually moved. `None` -- a battle, a script, a
// map transition -- is no information rather than "still", so the rule cannot fire while
// the fly has no control anyway.
let location = adapter.location();
if location.is_some() && location != self.location {
self.location = location;
self.blocked_since_ms = ms;
}
let progress = adapter.progress();
if let Some(trace) = self.trace.as_mut() {
trace.evaluated(&rewards, &abandoned, progress.rank);
}
Evaluated {
rewards,
abandoned,
progress,
}
}
/// Phase D: the frame just produced becomes the next ticks' visual drive, each reward event
/// stimulates once, and the summed value reinforces once.
pub fn commit(&mut self, agent: &mut NeuralAgent, rewards: &[RewardEvent], ms: f64) {
let (width, height) = (agent.frame.width, agent.frame.height);
agent
.network
.set_visual_frame(&self.frame_buffer, width, height);
let mut total = 0.0;
for event in rewards {
agent.network.stimulate(f64::from(event.stimulation_ms));
total += event.value;
}
if agent.network.plasticity.enabled {
agent.network.plasticity.reinforce(total, ms);
}
}
// -- the boundary ------------------------------------------------------------------------
/// `Ready(k+1)`: the ratchet captures on a safe frame above its best, observes, and rolls the
/// game back when it says so.
pub fn boundary(
&mut self,
parts: &mut Parts<'_>,
progress: &ProgressSnapshot,
ms: f64,
) -> Result<Boundary> {
let safe = parts.adapter.safe_for_snapshot();
let capture_due = safe && u64::from(progress.rank) > parts.ratchet.state.best;
let captured = if capture_due {
Some(Snapshot {
game: parts
.emulator
.export_state()
.map_err(|error| anyhow!("capturing a ratchet snapshot: {error}"))?,
frame: self.frame_buffer.clone(),
})
} else {
None
};
// The stall window's second progress signal (`docs/design/ladder.md`, the 2026-09-17
// rule as amended 2026-09-22): the macro layer answers "nearer the objective" with the map
// graph it already walks (`docs/design/macros.md` section 12.15); in raw mode there is no
// layer and no objective, and the answer is false.
let nearer = parts
.macros
.as_deref()
.is_some_and(MacroLayer::nearer_the_objective);
let trace = &mut self.trace;
let mut saved = false;
let recover = parts.ratchet.observe_with_progress(
safe,
u64::from(progress.rank),
progress.unique_locations as u64,
ms as u64,
parts.adapter.game_over(),
nearer,
|| {
let snapshot =
captured.expect("the ratchet only captures when a snapshot was prepared");
if let Some(trace) = trace.as_mut() {
trace.slot_saved(&snapshot.game);
}
saved = true;
snapshot
},
);
let rollback = if recover {
// Two triggers, two stories on the ticker: a game over ended the run, a stall did not.
let trigger = if parts.adapter.game_over() {
RollbackTrigger::GameOver
} else {
RollbackTrigger::Stall
};
let events = self.rollback(parts)?;
Some(Rollback { trigger, events })
} else {
None
};
Ok(Boundary {
captured: saved,
rollback,
})
}
/// The ratchet's game-only rollback (`legacy-ratchet-rollback-v1`): the slot is restored, the
/// brain's holds and eligibility are cleared and it is shown the slot's frame, the buttons are
/// released, the blocked window restarts, and a running macro is abandoned and the restored
/// scene observed. The brain clock, its learning and the ratchet's ledger carry on.
pub fn rollback(&mut self, parts: &mut Parts<'_>) -> Result<Vec<MacroEvent>> {
let snapshot = Snapshot {
game: parts
.ratchet
.game()
.ok_or_else(|| anyhow!("the ratchet asked to recover with no snapshot"))?
.to_vec(),
frame: parts
.ratchet
.frame()
.ok_or_else(|| anyhow!("the ratchet snapshot has no framebuffer"))?
.to_vec(),
};
let frame = {
let mut neural = AgentRecovery { agent: parts.agent };
recover_game(parts.emulator, parts.adapter, &mut neural, &snapshot)
.map_err(|error| anyhow!("recovering the game: {error}"))?
};
self.frame_buffer.copy_from_slice(&frame);
self.buttons = 0;
parts.emulator.set_buttons(0);
let ms = parts.agent.network.ms;
self.location = parts.adapter.location();
self.held_channel = None;
self.blocked_since_ms = ms;
// A rollback restores a game the running macro's plan was never made for, so the macro is
// abandoned rather than carried over a map change it cannot see. The frame's `observe` ran
// before the ratchet decided, so the scene describes the run just thrown away: re-detect
// on the restored game rather than decide the next frame against a map the fly is no
// longer standing on.
let events = match parts.macros.as_deref_mut() {
Some(layer) => {
let mut events = layer.cancel(ms);
let ledger = AdapterLedger(&*parts.adapter);
events.extend(layer.observe(parts.emulator, &ledger, ms));
events
}
None => Vec::new(),
};
if let Some(trace) = self.trace.as_mut() {
trace.rolled_back(&events);
}
Ok(events)
}
/// Write the open transition of the trace, if one is on.
pub fn finish_trace(&mut self) {
if let Some(trace) = self.trace.as_mut() {
trace.finish();
}
}
}

View file

@ -0,0 +1,176 @@
//! The sugar journal: every admitted audience input, stamped with the frame it was applied before.
//!
//! `sugar-journal.jsonl` in `[paths] hot_dir`, one JSON object per line, append-only. It is not
//! part of a checkpoint and nothing reads it back into the fly: it is the record a shadow run
//! (the session framework's CUT-01, `docs/design/session-framework/legacy-gameboy-v1.md` section
//! 15) replays audience input from. The legacy loop applies an admitted sugar at once, in the
//! command drain at the top of a frame, so an input is fully placed by the transition it precedes:
//!
//! ```json
//! {"frame":"6465126","brainMs":108246189,"kind":"sugar","durationMs":400,"by":"viewer","source":"twitch","eventId":81234,"wallMs":1790000000000}
//! ```
//!
//! - `frame` is the frame counter when the input was applied, which is the `step` of the next
//! transition in the frame trace (`crate::trace`): replay applies it before that transition's
//! ticks. A restore carries the frame counter, so stamps continue across restarts.
//! - `kind` is `sugar` (a `reward-pulse` stimulation of `durationMs`, after the admission rules
//! and the clamp) or `reward` (an operator's `POST /reward`, one reinforcement of `value`).
//! - `brainMs` cross-checks the stamp; `eventId` joins the event log; `wallMs` is for people.
//!
//! Refused requests are not journalled: admission is wall-clock policy, and a replay applies what
//! was admitted. A write that fails is a warning, never a refusal: the input has already reached
//! the fly.
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
/// The journal's file name inside the hot directory.
pub const FILE_NAME: &str = "sugar-journal.jsonl";
/// What was applied.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Input {
Sugar { duration_ms: f64 },
Reward { value: f64 },
}
/// One journal line.
#[derive(Debug, Clone, PartialEq)]
pub struct Entry<'a> {
pub frame: u64,
pub brain_ms: f64,
pub input: Input,
pub by: &'a str,
pub source: &'a str,
pub event_id: u64,
pub wall_ms: u64,
}
impl Entry<'_> {
pub fn to_json(&self) -> Value {
let mut line = json!({ "frame": self.frame.to_string(), "brainMs": self.brain_ms });
let map = line.as_object_mut().expect("an object");
match self.input {
Input::Sugar { duration_ms } => {
map.insert("kind".into(), "sugar".into());
map.insert("durationMs".into(), json!(duration_ms));
}
Input::Reward { value } => {
map.insert("kind".into(), "reward".into());
map.insert("value".into(), json!(value));
}
}
map.insert("by".into(), self.by.into());
map.insert("source".into(), self.source.into());
map.insert("eventId".into(), self.event_id.into());
map.insert("wallMs".into(), self.wall_ms.into());
line
}
}
/// The append-only journal. Opened on the first input, so a run nobody feeds writes no file.
pub struct SugarJournal {
path: PathBuf,
file: Option<File>,
}
impl SugarJournal {
pub fn new(hot_dir: &Path) -> Self {
Self {
path: hot_dir.join(FILE_NAME),
file: None,
}
}
pub fn path(&self) -> &Path {
&self.path
}
/// Append one line. Each line is one `write` of the whole line, so a crash leaves at most a
/// torn last line, which a reader skips.
pub fn record(&mut self, entry: &Entry<'_>) {
if self.file.is_none() {
match OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
{
Ok(file) => self.file = Some(file),
Err(error) => {
tracing::warn!(%error, path = %self.path.display(), "could not open the sugar journal");
return;
}
}
}
let mut line = entry.to_json().to_string();
line.push('\n');
if let Some(file) = self.file.as_mut()
&& let Err(error) = file.write_all(line.as_bytes())
{
tracing::warn!(%error, path = %self.path.display(), "could not append to the sugar journal");
// Reopen on the next input rather than write through a broken handle.
self.file = None;
}
}
}
/// Read a journal back: every whole line, in order. A torn or unreadable line is skipped.
pub fn read(path: &Path) -> std::io::Result<Vec<Value>> {
let text = std::fs::read_to_string(path)?;
Ok(text
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter(|value| value.get("frame").is_some())
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inputs_are_appended_with_their_frame_and_survive_a_reopen() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut journal = SugarJournal::new(dir.path());
assert!(
!journal.path().exists(),
"nothing is written before an input"
);
let sugar = Entry {
frame: 42,
brain_ms: 703.0,
input: Input::Sugar { duration_ms: 400.0 },
by: "viewer",
source: "test",
event_id: 7,
wall_ms: 1,
};
journal.record(&sugar);
drop(journal);
let mut journal = SugarJournal::new(dir.path());
journal.record(&Entry {
frame: 43,
input: Input::Reward { value: 0.5 },
event_id: 8,
..sugar
});
// A torn tail from a crash is skipped, not fatal.
std::fs::OpenOptions::new()
.append(true)
.open(journal.path())
.and_then(|mut file| file.write_all(b"{\"frame\":\"44\",\"kin"))
.expect("appending a torn line");
let lines = read(journal.path()).expect("the journal reads back");
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["frame"], "42");
assert_eq!(lines[0]["kind"], "sugar");
assert_eq!(lines[0]["durationMs"], 400.0);
assert_eq!(lines[1]["frame"], "43");
assert_eq!(lines[1]["kind"], "reward");
assert_eq!(lines[1]["value"], 0.5);
assert_eq!(lines[1]["eventId"], 8);
}
}

View file

@ -27,6 +27,8 @@ pub mod config;
pub mod eventlog; pub mod eventlog;
pub mod feed; pub mod feed;
pub mod feedbus; pub mod feedbus;
pub mod frame;
pub mod journal;
pub mod macros; pub mod macros;
pub mod metrics; pub mod metrics;
pub mod pacing; pub mod pacing;
@ -37,6 +39,7 @@ pub mod sdnotify;
pub mod simloop; pub mod simloop;
pub mod snapshot; pub mod snapshot;
pub mod store; pub mod store;
pub mod trace;
use std::sync::Arc; use std::sync::Arc;

View file

@ -321,7 +321,7 @@ mod tests {
wall_ms: 1_700_000_000_000, wall_ms: 1_700_000_000_000,
rom_sha256: "ab".repeat(32), rom_sha256: "ab".repeat(32),
emulator_frame: 12_345, emulator_frame: 12_345,
compatibility: "kernel/pokered-unique8-v6/fingerprint".to_string(), compatibility: "kernel/pokered-unique8-v7/fingerprint".to_string(),
speed: 1.0, speed: 1.0,
buttons: 0, buttons: 0,
rank_since_ms: 4_242.0, rank_since_ms: 4_242.0,

View file

@ -8,7 +8,8 @@
//! //!
//! The per-frame order is the prototype worker's (`fly-plays-pokemon/src/simulation.worker.ts`, //! The per-frame order is the prototype worker's (`fly-plays-pokemon/src/simulation.worker.ts`,
//! `tick()`), not `NeuralAgent::tick`'s argument order, because the prototype samples reward //! `tick()`), not `NeuralAgent::tick`'s argument order, because the prototype samples reward
//! inside the same frame it produced: //! inside the same frame it produced. It lives in `crate::frame::LegacyFrame`, the one copy every
//! harness runs too; in outline:
//! //!
//! 1. drain commands //! 1. drain commands
//! 2. step the brain 16 or 17 ms (the fractional remainder carries and is checkpointed) //! 2. step the brain 16 or 17 ms (the fractional remainder carries and is checkpointed)
@ -30,14 +31,13 @@ use anyhow::{Context, Result, anyhow, bail};
use flybrain_core::agent::{AgentConfig, NeuralAgent}; use flybrain_core::agent::{AgentConfig, NeuralAgent};
use flybrain_core::dataset::load_brain_dataset_from_dir; use flybrain_core::dataset::load_brain_dataset_from_dir;
use flybrain_core::decoder::DecoderConfig; use flybrain_core::decoder::DecoderConfig;
use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask}; use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
use flybrain_core::decoder::platformer::platformer_decoder_config; use flybrain_core::decoder::platformer::platformer_decoder_config;
use flybrain_core::lif::SweepPlan; use flybrain_core::lif::SweepPlan;
use flybrain_gb::adapter::{DecoderPresetId, GameAdapter, ProgressSnapshot}; use flybrain_gb::adapter::{DecoderPresetId, GameAdapter, ProgressSnapshot};
use flybrain_gb::macros::AdapterLedger; use flybrain_gb::macros::AdapterLedger;
use flybrain_gb::emulator::{DEFAULT_AUDIO_FRAMES, Emulator, FRAMEBUFFER_LEN}; use flybrain_gb::emulator::{DEFAULT_AUDIO_FRAMES, Emulator};
use flybrain_gb::ratchet::Ratchet; use flybrain_gb::ratchet::Ratchet;
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use serde::Serialize; use serde::Serialize;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use tokio::sync::{mpsc, oneshot, watch}; use tokio::sync::{mpsc, oneshot, watch};
@ -45,6 +45,8 @@ use tokio::sync::{mpsc, oneshot, watch};
use crate::chat::{ChatLimiter, ChatRefusal, ChatRing, DenyList, RejectReason}; use crate::chat::{ChatLimiter, ChatRefusal, ChatRing, DenyList, RejectReason};
use crate::config::Config; use crate::config::Config;
use crate::eventlog::{EventLog, EventRing, NewEvent, now_wall_ms, utc_day}; use crate::eventlog::{EventLog, EventRing, NewEvent, now_wall_ms, utc_day};
use crate::journal::{Input, SugarJournal};
use crate::frame::{FrameObserver, FramePhase, LegacyFrame, Parts, RollbackTrigger};
use crate::macros::{MacroEvent, MacroLayer, macro_layer}; use crate::macros::{MacroEvent, MacroLayer, macro_layer};
use crate::metrics::Metrics; use crate::metrics::Metrics;
use crate::pacing::{Pacer, RealtimeWindow}; use crate::pacing::{Pacer, RealtimeWindow};
@ -56,6 +58,7 @@ use crate::snapshot::{
RewardCounts, RewardKind, Snapshot, f32_bytes, finite, spike_bitset, RewardCounts, RewardKind, Snapshot, f32_bytes, finite, spike_bitset,
}; };
use crate::store::{self, RuntimeState, Store}; use crate::store::{self, RuntimeState, Store};
use crate::trace::FrameTrace;
/// Commands the control API queues for the sim thread. /// Commands the control API queues for the sim thread.
#[derive(Debug)] #[derive(Debug)]
@ -273,25 +276,26 @@ pub fn booting_snapshot(seq: u64, wall_ms: u64, mode: MacroMode) -> Snapshot {
} }
} }
/// The neural half of a ratchet recovery, wired to `flybrain-core`. /// The stream's only look inside the frame: the per-phase profile (`crate::profile`).
struct AgentRecovery<'a> { struct Laps<'a> {
agent: &'a mut NeuralAgent, profiler: &'a mut Profiler,
} }
impl NeuralRecovery for AgentRecovery<'_> { impl FrameObserver for Laps<'_> {
fn clear_decoder_holds(&mut self) { fn after(&mut self, phase: FramePhase, agent: &mut NeuralAgent) {
let ms = self.agent.network.ms; match phase {
self.agent.decoder.clear_holds(ms); FramePhase::Ticked => {
} self.profiler.lap(Phase::Step);
if self.profiler.enabled() {
fn clear_eligibility(&mut self) { self.profiler.absorb_brain(agent.network.timings());
let ms = self.agent.network.ms; agent.network.reset_timings();
self.agent.network.plasticity.clear_eligibility(ms); }
} }
FramePhase::Executed => self.profiler.lap(Phase::Decode),
fn set_visual_frame(&mut self, frame: &[u8]) { FramePhase::Emulated => self.profiler.lap(Phase::Emulate),
let (width, height) = (self.agent.frame.width, self.agent.frame.height); FramePhase::Advanced => self.profiler.lap(Phase::Retina),
self.agent.network.set_visual_frame(frame, width, height); FramePhase::Committed => self.profiler.lap(Phase::Rewards),
}
} }
} }
@ -336,24 +340,11 @@ pub struct Sim {
next_generation: u64, next_generation: u64,
best_archived_rank: Option<u32>, best_archived_rank: Option<u32>,
/// Fractional millisecond carried into the next frame, exactly as `NeuralAgent` keeps it. /// The frame order and its state: the remainder, the frame counter, the frame on screen, the
remainder: f64, /// mask and the readout's blocked-direction window (`crate::frame`).
frame_counter: u64, frame: LegacyFrame,
frame_buffer: Vec<u8>,
pending_audio: Vec<f32>, pending_audio: Vec<f32>,
dc_blocker: DcBlocker, dc_blocker: DcBlocker,
buttons: u32,
/// The readout's blocked-direction cooldown (`docs/readout.md`), as the loop computes it: the
/// player's area and tile as of the last frame the adapter reported one, the channel the group is
/// holding, and the brain clock at which *either* of those last changed. A direction is only
/// blamed once it has been held for a whole `blocked_ms` with no movement, so a direction that
/// has just won is never blamed for a wall the previous one hit.
///
/// All three are transient and deliberately not checkpointed: one hold of a wall after a
/// restart is cheaper than a stale position surviving a restore.
location: Option<(u32, u32, u32)>,
held_channel: Option<String>,
blocked_since_ms: f64,
/// Palette mode (`docs/design/macros.md`), or `None` in raw mode — which is the default and /// Palette mode (`docs/design/macros.md`), or `None` in raw mode — which is the default and
/// which is byte for byte the behaviour that predates it: every call site below is inside an /// which is byte for byte the behaviour that predates it: every call site below is inside an
@ -385,6 +376,9 @@ pub struct Sim {
sugar_last_by: Option<String>, sugar_last_by: Option<String>,
sugar_today: u64, sugar_today: u64,
sugar_day: String, sugar_day: String,
/// Every admitted sugar and operator pulse, frame-stamped, in the hot directory
/// (`crate::journal`): what a shadow run replays. Not checkpointed.
journal: SugarJournal,
/// The chat path. None of it is wired to the agent, the emulator or plasticity. /// The chat path. None of it is wired to the agent, the emulator or plasticity.
chat_ring: ChatRing, chat_ring: ChatRing,
@ -566,16 +560,14 @@ impl Sim {
writer_thread: None, writer_thread: None,
next_generation: 1, next_generation: 1,
best_archived_rank: None, best_archived_rank: None,
remainder: 0.0, // The per-frame trace, off unless `FLY_TRACE` names a file (`crate::trace`).
frame_counter: 0, frame: LegacyFrame::new().with_trace(
frame_buffer: vec![0u8; FRAMEBUFFER_LEN], FrameTrace::from_env()
.with_context(|| format!("creating the {} file", crate::trace::ENV))?,
),
pending_audio: Vec::new(), pending_audio: Vec::new(),
dc_blocker: DcBlocker::default(), dc_blocker: DcBlocker::default(),
buttons: 0,
status: FeedStatus::Booting, status: FeedStatus::Booting,
location: None,
held_channel: None,
blocked_since_ms: 0.0,
pending_recovery: false, pending_recovery: false,
seq: 0, seq: 0,
started, started,
@ -589,6 +581,7 @@ impl Sim {
sugar_last_by: None, sugar_last_by: None,
sugar_today: 0, sugar_today: 0,
sugar_day: utc_day(now_wall_ms()), sugar_day: utc_day(now_wall_ms()),
journal: SugarJournal::new(&config.paths.hot_dir),
chat_ring: ChatRing::new(config.chat.ring), chat_ring: ChatRing::new(config.chat.ring),
chat_limits: ChatLimiter::default(), chat_limits: ChatLimiter::default(),
deny_list: if config.chat.enabled { deny_list: if config.chat.enabled {
@ -686,7 +679,7 @@ impl Sim {
} }
tracing::info!( tracing::info!(
origin = %candidate.origin, origin = %candidate.origin,
frame = self.frame_counter, frame = self.frame.frame_counter,
brain_ms = self.agent.network.ms, brain_ms = self.agent.network.ms,
rank = self.rank, rank = self.rank,
"restored" "restored"
@ -749,46 +742,21 @@ impl Sim {
self.compatibility self.compatibility
), ),
} }
if runtime.framebuffer.len() != FRAMEBUFFER_LEN {
bail!("checkpoint framebuffer is {} bytes", runtime.framebuffer.len());
}
// `import_state` is self-validating and a no-op on failure, so a refused checkpoint // `import_state` is self-validating and a no-op on failure, so a refused checkpoint
// leaves the agent exactly as it was and the next candidate starts clean. // leaves the agent exactly as it was and the next candidate starts clean.
self.agent {
.import_state(&checkpoint.agent) let mut parts = Parts {
.map_err(|error| anyhow!("{error}"))?; agent: &mut self.agent,
self.emulator emulator: &mut self.emulator,
.import_state(&runtime.emulator) adapter: self.adapter.as_mut(),
.map_err(|error| anyhow!("{error}"))?; ratchet: &mut self.ratchet,
if !runtime.reward.is_null() { macros: self.macros.as_mut(),
self.adapter };
.import_state(&runtime.reward) self.frame.restore(&mut parts, &checkpoint)?;
.map_err(|error| anyhow!("{error}"))?;
} }
let snapshot = if runtime.ratchet_game.is_empty() {
None
} else {
Some(flybrain_gb::ratchet::Snapshot {
game: runtime.ratchet_game.clone(),
frame: runtime.ratchet_frame.clone(),
})
};
self.ratchet
.import(Some(runtime.ratchet), snapshot, self.adapter.rank_ladder().len())
.map_err(|error| anyhow!("{error}"))?;
self.remainder = checkpoint.agent.remainder;
self.frame_counter = runtime.emulator_frame;
self.buttons = runtime.buttons;
self.rank_since_ms = runtime.rank_since_ms; self.rank_since_ms = runtime.rank_since_ms;
self.frame_buffer.copy_from_slice(&runtime.framebuffer);
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
self.agent
.network
.set_visual_frame(&self.frame_buffer, width, height);
self.rank = self.adapter.progress().rank; self.rank = self.adapter.progress().rank;
self.log.resume_from(runtime.last_event_id); self.log.resume_from(runtime.last_event_id);
self.emulator.set_buttons(self.buttons as u8);
Ok(()) Ok(())
} }
@ -796,16 +764,8 @@ impl Sim {
/// readout on the settled rates. Never after a restore, which already carries a settled /// readout on the settled rates. Never after a restore, which already carries a settled
/// network and a calibrated decoder. /// network and a calibrated decoder.
fn fresh_start(&mut self) -> Result<()> { fn fresh_start(&mut self) -> Result<()> {
self.emulator let raw = self.frame.initialize(&mut self.emulator, &mut self.agent)?;
.run_frame()
.map_err(|error| anyhow!("running the first frame: {error}"))?;
self.frame_buffer.copy_from_slice(self.emulator.framebuffer());
self.frame_counter = 1;
let raw = self.emulator.take_audio_u8();
self.dc_blocker.process_into(&raw, &mut self.pending_audio); self.dc_blocker.process_into(&raw, &mut self.pending_audio);
self.agent
.warmup(Some(&self.frame_buffer))
.map_err(|error| anyhow!("{error}"))?;
self.emit(NewEvent::new(FeedEventKind::System, "Fresh start: the fly woke up")); self.emit(NewEvent::new(FeedEventKind::System, "Fresh start: the fly woke up"));
Ok(()) Ok(())
} }
@ -927,117 +887,27 @@ impl Sim {
self.deny_list.maybe_reload(Instant::now(), forced); self.deny_list.maybe_reload(Instant::now(), forced);
} }
/// One frame, in the prototype's order. /// One frame, in the prototype's order (`crate::frame`).
fn step_frame(&mut self) -> Result<()> { fn step_frame(&mut self) -> Result<()> {
// 2. Step the brain: 16 or 17 integer ticks, the remainder carried and checkpointed. // Prepare through commit: ticks, decode, the executor's mask, one emulator frame, rewards,
self.remainder += self.agent.ms_per_frame; // the scene and the location, then the stimulations and the reinforcement.
let steps = self.remainder.floor(); let transition = {
self.remainder -= steps; let mut parts = Parts {
self.agent.network.step(steps as u64); agent: &mut self.agent,
self.profiler.lap(Phase::Step); emulator: &mut self.emulator,
if self.profiler.enabled() { adapter: self.adapter.as_mut(),
self.profiler.absorb_brain(self.agent.network.timings()); ratchet: &mut self.ratchet,
self.agent.network.reset_timings(); macros: self.macros.as_mut(),
} };
let mut laps = Laps { profiler: &mut self.profiler };
// 3. Decode, 4. apply the buttons. self.frame.transition(&mut parts, &mut laps)?
// Not the mode string: the adapter decides what counts as boot, because a platformer needs
// the permissive Start variant in four of its five modes (`GameAdapter::boot`).
let boot = self.adapter.boot();
let ms = self.agent.network.ms;
let rates = self.agent.network.rates.clone();
// The readout's blocked-direction cooldown (`docs/readout.md`): the direction the group is
// holding, once the adapter's position has stood still for a whole `blocked_ms`. The loop
// owns the clock and the position; the decoder only learns *which* channel did nothing.
// `blocked_ms == 0` -- the platformer preset, and the Game Boy preset before v0.1.1 --
// switches the rule off here, before the decoder is asked.
let blocked_ms = self.agent.decoder.blocked_ms();
let blocked = (blocked_ms > 0.0 && ms - self.blocked_since_ms >= blocked_ms)
.then(|| self.agent.decoder.current())
.flatten()
.map(str::to_string);
// The scene's own macro buttons, for the macro group's per-decision mask
// (`docs/design/macros.md` section 12: "unbound channels are masked from the decision").
// They are the bindings the previous frame's `observe` dealt, which is the palette the
// page is showing, so the fly is choosing among exactly the buttons the audience can see.
// `None` in raw mode, where the group has no channels to mask anyway.
let bound = self.macros.as_ref().map(MacroLayer::bound_channels);
let active = self.agent.decoder.decode_bound(
&rates,
ms,
boot,
blocked.as_deref(),
bound.as_deref(),
);
// A new winner starts its own window: it has not had a hold to move in yet.
let held = self.agent.decoder.current().map(str::to_string);
if held != self.held_channel {
self.held_channel = held;
self.blocked_since_ms = ms;
}
self.buttons = to_button_mask(&active);
// Macros mode (`docs/design/macros.md` sections 4 and 12): the same decode, plus the
// macro group whose winner is in `active` alongside the buttons. The mask that reaches
// the emulator is the running macro's, or nothing, or -- on the title screen alone -- the
// raw mask above. In raw mode `self.macros` is `None` and not one line of this runs.
let started_or_finished = match self.macros.as_mut() {
// Two disjoint fields of the same struct, so the layer can read the emulator while
// the loop still owns both. Taking the layer out and putting it back would leave
// raw mode running silently if anything in between ever panicked.
Some(layer) => {
// Three disjoint fields: the layer decides, the emulator is read, and the
// adapter's exploration ledger answers the ways out' "unvisited" — read-only, by
// `&dyn`, and the only thing the palette is told about the reward side.
let ledger = AdapterLedger(self.adapter.as_ref());
let decision = layer.decide(
&active,
self.buttons,
ms,
&mut self.emulator,
&ledger,
);
self.buttons = decision.mask;
decision.events
}
None => Vec::new(),
}; };
self.emit_macro_events(&started_or_finished);
self.emulator.set_buttons(self.buttons as u8);
self.profiler.lap(Phase::Decode);
// 5. Run one emulator frame, 6. set the visual frame from it.
self.emulator
.run_frame()
.map_err(|error| anyhow!("frame {}: {error}", self.frame_counter + 1))?;
self.frame_counter += 1;
Metrics::incr(&self.shared.metrics.sim_frames); Metrics::incr(&self.shared.metrics.sim_frames);
self.profiler.lap(Phase::Emulate); self.dc_blocker.process_into(&transition.audio, &mut self.pending_audio);
self.frame_buffer.copy_from_slice(self.emulator.framebuffer()); // The feed events, in the order the phases produced them: the executor's starts and
let (width, height) = (self.agent.frame.width, self.agent.frame.height); // finishes, the rewards, then the observation's abandonment.
self.agent self.emit_macro_events(&transition.executed.events);
.network for event in &transition.evaluated.rewards {
.set_visual_frame(&self.frame_buffer, width, height);
let raw = self.emulator.take_audio_u8();
self.dc_blocker.process_into(&raw, &mut self.pending_audio);
self.profiler.lap(Phase::Retina);
// 7. Sample rewards from the frame just produced.
let ms = self.agent.network.ms;
let events = {
let (adapter, emulator) = (&mut self.adapter, &mut self.emulator);
adapter.sample(emulator, ms)
};
// 8. Stimulate once per event, 9. reinforce with the sum.
let mut total = 0.0;
for event in &events {
self.agent.network.stimulate(f64::from(event.stimulation_ms));
total += event.value;
}
if self.agent.network.plasticity.enabled {
self.agent.network.plasticity.reinforce(total, ms);
}
for event in &events {
let kind = RewardKind::from_adapter(event.kind); let kind = RewardKind::from_adapter(event.kind);
let mut new = NewEvent::new(FeedEventKind::Reward, event.label.clone()) let mut new = NewEvent::new(FeedEventKind::Reward, event.label.clone())
.value(event.value); .value(event.value);
@ -1046,68 +916,27 @@ impl Sim {
} }
self.emit(new); self.emit(new);
} }
self.emit_macro_events(&transition.evaluated.abandoned);
self.profiler.lap(Phase::Rewards); // The milestone archive sits here, after the commit and before the ratchet captures,
// which is the legacy order legacy-gameboy-v1 section 4 declares.
// `docs/design/macros.md` section 2: the scene is sampled once per game frame, after the let ms = transition.ms;
// frame. So the palette the fly is offered on the next frame is the one for the frame it let progress = transition.evaluated.progress;
// can actually see, and the feed's `game.scene` is never a frame ahead of the screen.
let abandoned = match self.macros.as_mut() {
Some(layer) => {
let ledger = AdapterLedger(self.adapter.as_ref());
layer.observe(&mut self.emulator, &ledger, ms)
}
None => Vec::new(),
};
// At most one: a macro that has run into a scene with no palette, abandoned before the
// header this frame publishes can show it beside that scene.
self.emit_macro_events(&abandoned);
// The cooldown's other reset: the player actually moved. `None` -- a battle, a script, a
// map transition -- is no information rather than "still", so the rule cannot fire while
// the fly has no control anyway.
let location = self.adapter.location();
if location.is_some() && location != self.location {
self.location = location;
self.blocked_since_ms = ms;
}
// 10. Ratchet: observe, and recover if it says so.
let progress = self.adapter.progress();
self.track_rank(&progress, ms); self.track_rank(&progress, ms);
let safe = self.adapter.safe_for_snapshot();
let capture_due = safe && u64::from(progress.rank) > self.ratchet.state.best; // `Ready(k+1)`: the ratchet captures, observes, and rolls the game back if it says so.
let captured = if capture_due { let boundary = {
Some(flybrain_gb::ratchet::Snapshot { let mut parts = Parts {
game: self agent: &mut self.agent,
.emulator emulator: &mut self.emulator,
.export_state() adapter: self.adapter.as_mut(),
.map_err(|error| anyhow!("capturing a ratchet snapshot: {error}"))?, ratchet: &mut self.ratchet,
frame: self.frame_buffer.clone(), macros: self.macros.as_mut(),
}) };
} else { self.frame.boundary(&mut parts, &progress, ms)?
None
}; };
// The stall window's second progress signal (`docs/design/ladder.md`, the 2026-09-17 if let Some(rollback) = boundary.rollback {
// rule as amended 2026-09-22): coverage is ground never stood on, and a fly crossing a self.recovered(rollback.trigger, &rollback.events);
// town it has already covered to reach the rung's own door earns none of it while it is
// plainly getting somewhere. The macro layer answers with the map graph it already walks
// (`docs/design/macros.md` section 12.15); in raw mode there is no layer and no
// objective, and the answer is false.
let nearer = self.macros.as_ref().is_some_and(MacroLayer::nearer_the_objective);
let recover = self.ratchet.observe_with_progress(
safe,
u64::from(progress.rank),
progress.unique_locations as u64,
ms as u64,
self.adapter.game_over(),
nearer,
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
);
if recover {
// Two triggers, two stories on the ticker: a game over ended the run, a stall did not.
let reason = if self.adapter.game_over() { "Game over" } else { "Stuck" };
self.recover(reason)?;
} }
self.profiler.lap(Phase::Ratchet); self.profiler.lap(Phase::Ratchet);
Ok(()) Ok(())
@ -1140,32 +969,14 @@ impl Sim {
} }
} }
fn recover(&mut self, reason: &str) -> Result<()> { /// The host's half of a rollback the frame has already applied: the ticker, the metric, the
let snapshot = flybrain_gb::ratchet::Snapshot { /// `recovering` status and a durable checkpoint.
game: self fn recovered(&mut self, trigger: RollbackTrigger, events: &[crate::macros::MacroEvent]) {
.ratchet // Two triggers, two stories on the ticker: a game over ended the run, a stall did not.
.game() let reason = match trigger {
.ok_or_else(|| anyhow!("the ratchet asked to recover with no snapshot"))? RollbackTrigger::GameOver => "Game over",
.to_vec(), RollbackTrigger::Stall => "Stuck",
frame: self
.ratchet
.frame()
.ok_or_else(|| anyhow!("the ratchet snapshot has no framebuffer"))?
.to_vec(),
}; };
let frame = {
let mut neural = AgentRecovery { agent: &mut self.agent };
recover_game(
&mut self.emulator,
self.adapter.as_mut(),
&mut neural,
&snapshot,
)
.map_err(|error| anyhow!("recovering the game: {error}"))?
};
self.frame_buffer.copy_from_slice(&frame);
self.buttons = 0;
self.emulator.set_buttons(0);
Metrics::incr(&self.shared.metrics.recoveries_total); Metrics::incr(&self.shared.metrics.recoveries_total);
let attempts = self.ratchet.state.attempts; let attempts = self.ratchet.state.attempts;
self.emit( self.emit(
@ -1178,31 +989,11 @@ impl Sim {
) )
.value(f64::from(self.rank)), .value(f64::from(self.rank)),
); );
self.location = self.adapter.location(); self.emit_macro_events(events);
self.held_channel = None;
self.blocked_since_ms = self.agent.network.ms;
// A rollback restores a game the running macro's plan was never made for, so the macro is
// abandoned here rather than carried over a map change it cannot see.
let abandoned = match self.macros.as_mut() {
Some(layer) => {
let events = layer.cancel(self.agent.network.ms);
// The frame's `observe` ran before the ratchet decided to roll back, so the scene
// and the palette describe the run that was just thrown away. The restored game
// is in WRAM now, so re-detect here rather than let the next frame's decision be
// made against a map the fly is no longer standing on.
let ledger = AdapterLedger(self.adapter.as_ref());
let mut events = events;
events.extend(layer.observe(&mut self.emulator, &ledger, self.agent.network.ms));
events
}
None => Vec::new(),
};
self.emit_macro_events(&abandoned);
self.pending_recovery = true; self.pending_recovery = true;
if let Err(error) = self.checkpoint(true, None) { if let Err(error) = self.checkpoint(true, None) {
tracing::error!(%error, "could not checkpoint after a recovery"); tracing::error!(%error, "could not checkpoint after a recovery");
} }
Ok(())
} }
// -- commands ------------------------------------------------------------------------ // -- commands ------------------------------------------------------------------------
@ -1292,6 +1083,9 @@ impl Sim {
.clamp(1.0, self.shared.config.control.sugar_max_ms) .clamp(1.0, self.shared.config.control.sugar_max_ms)
.min(self.shared.config.control.sugar_max_ms); .min(self.shared.config.control.sugar_max_ms);
self.agent.network.stimulate(duration); self.agent.network.stimulate(duration);
if let Some(trace) = self.frame.trace_mut() {
trace.sugar(duration);
}
let day = utc_day(now_ms); let day = utc_day(now_ms);
if day != self.sugar_day { if day != self.sugar_day {
@ -1304,6 +1098,15 @@ impl Sim {
let event = self.emit(NewEvent::new(FeedEventKind::Sugar, sugar_label(by)) let event = self.emit(NewEvent::new(FeedEventKind::Sugar, sugar_label(by))
.by(by) .by(by)
.value(duration)); .value(duration));
self.journal.record(&crate::journal::Entry {
frame: self.frame.frame_counter,
brain_ms: self.agent.network.ms,
input: Input::Sugar { duration_ms: duration },
by,
source,
event_id: event.id,
wall_ms: event.wall_ms,
});
tracing::info!(by, source, duration_ms = duration, "sugar accepted"); tracing::info!(by, source, duration_ms = duration, "sugar accepted");
Ok(event.id) Ok(event.id)
} }
@ -1313,13 +1116,25 @@ impl Sim {
fn reward(&mut self, value: f64, by: &str, source: &str) -> u64 { fn reward(&mut self, value: f64, by: &str, source: &str) -> u64 {
let ms = self.agent.network.ms; let ms = self.agent.network.ms;
self.agent.network.plasticity.reinforce(value, ms); self.agent.network.plasticity.reinforce(value, ms);
if let Some(trace) = self.frame.trace_mut() {
trace.reward_pulse(value);
}
tracing::info!(by, source, value, "reward pulse applied"); tracing::info!(by, source, value, "reward pulse applied");
self.emit( let event = self.emit(
NewEvent::new(FeedEventKind::Reward, format!("{by} sent a reward pulse ({value})")) NewEvent::new(FeedEventKind::Reward, format!("{by} sent a reward pulse ({value})"))
.by(by) .by(by)
.value(value), .value(value),
) );
.id self.journal.record(&crate::journal::Entry {
frame: self.frame.frame_counter,
brain_ms: ms,
input: Input::Reward { value },
by,
source,
event_id: event.id,
wall_ms: event.wall_ms,
});
event.id
} }
/// `POST /chat`: the on-screen chat path, enforced here rather than trusted from the bridge. /// `POST /chat`: the on-screen chat path, enforced here rather than trusted from the bridge.
@ -1390,6 +1205,7 @@ impl Sim {
if let Err(error) = self.checkpoint_blocking(true, None) { if let Err(error) = self.checkpoint_blocking(true, None) {
tracing::error!(%error, "the final checkpoint failed"); tracing::error!(%error, "the final checkpoint failed");
} }
self.frame.finish_trace();
if let Err(error) = self.log.flush() { if let Err(error) = self.log.flush() {
tracing::error!(%error, "the final event log flush failed"); tracing::error!(%error, "the final event log flush failed");
} }
@ -1499,6 +1315,10 @@ impl Sim {
durable && self.best_archived_rank.is_none_or(|best| *rank > best) durable && self.best_archived_rank.is_none_or(|best| *rank > best)
}); });
let (generation, agent, runtime) = self.snapshot_state()?; let (generation, agent, runtime) = self.snapshot_state()?;
let step = self.frame.frame_counter;
if let Some(trace) = self.frame.trace_mut() {
trace.capture(generation, step);
}
if let Some(rank) = archive_rank { if let Some(rank) = archive_rank {
self.best_archived_rank = Some(rank); self.best_archived_rank = Some(rank);
} }
@ -1547,15 +1367,15 @@ impl Sim {
let mut agent_state = self.agent.export_state(); let mut agent_state = self.agent.export_state();
// The sim loop owns the frame remainder, not `NeuralAgent::tick`, so the exported state // The sim loop owns the frame remainder, not `NeuralAgent::tick`, so the exported state
// carries the loop's value. // carries the loop's value.
agent_state.remainder = self.remainder; agent_state.remainder = self.frame.remainder;
let runtime = RuntimeState { let runtime = RuntimeState {
generation, generation,
wall_ms: now_wall_ms(), wall_ms: now_wall_ms(),
rom_sha256: self.rom_sha256.clone(), rom_sha256: self.rom_sha256.clone(),
emulator_frame: self.frame_counter, emulator_frame: self.frame.frame_counter,
compatibility: self.compatibility.clone(), compatibility: self.compatibility.clone(),
speed: self.shared.config.loop_.speed, speed: self.shared.config.loop_.speed,
buttons: self.buttons, buttons: self.frame.buttons,
rank_since_ms: self.rank_since_ms, rank_since_ms: self.rank_since_ms,
last_event_id: self.log.next_id().saturating_sub(1), last_event_id: self.log.next_id().saturating_sub(1),
reward: self.adapter.export_state(), reward: self.adapter.export_state(),
@ -1564,7 +1384,7 @@ impl Sim {
.emulator .emulator
.export_state() .export_state()
.map_err(|error| anyhow!("exporting the Game Boy: {error}"))?, .map_err(|error| anyhow!("exporting the Game Boy: {error}"))?,
framebuffer: self.frame_buffer.clone(), framebuffer: self.frame.frame_buffer.clone(),
ratchet_game: self.ratchet.game().map(<[u8]>::to_vec).unwrap_or_default(), ratchet_game: self.ratchet.game().map(<[u8]>::to_vec).unwrap_or_default(),
ratchet_frame: self.ratchet.frame().map(<[u8]>::to_vec).unwrap_or_default(), ratchet_frame: self.ratchet.frame().map(<[u8]>::to_vec).unwrap_or_default(),
}; };
@ -1637,7 +1457,7 @@ impl Sim {
let audio = f32_bytes(&self.pending_audio); let audio = f32_bytes(&self.pending_audio);
self.pending_audio.clear(); self.pending_audio.clear();
( (
Arc::new(self.frame_buffer.clone()), Arc::new(self.frame.frame_buffer.clone()),
Arc::new(audio), Arc::new(audio),
Arc::new(bitset), Arc::new(bitset),
count, count,
@ -1663,8 +1483,8 @@ impl Sim {
uptime_seconds: self.started.elapsed().as_secs_f64(), uptime_seconds: self.started.elapsed().as_secs_f64(),
run_seconds: finite(ms / 1000.0).max(0.0), run_seconds: finite(ms / 1000.0).max(0.0),
brain_ms: finite(ms).max(0.0), brain_ms: finite(ms).max(0.0),
frame: self.frame_counter, frame: self.frame.frame_counter,
buttons: self.buttons & 0xff, buttons: self.frame.buttons & 0xff,
rates, rates,
population_rate: finite(self.agent.network.population_rate).max(0.0), population_rate: finite(self.agent.network.population_rate).max(0.0),
spike_count, spike_count,

View file

@ -242,7 +242,7 @@ pub struct FeedMacroOutcome {
/// Reward categories the feed reports counts for. The adapter's own interned kinds /// Reward categories the feed reports counts for. The adapter's own interned kinds
/// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, /// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`,
/// `catch`) map onto these. /// `catch`, `talk`, `item`) map onto these.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum RewardKind { pub enum RewardKind {
@ -291,6 +291,15 @@ impl RewardKind {
// for it on the same frame, so the `pokedex` counter already moves. Mapping `catch` // for it on the same frame, so the `pokedex` counter already moves. Mapping `catch`
// there as well would count one event twice. // there as well would count one event twice.
"catch" => Self::Wildwin, "catch" => Self::Wildwin,
// `talk` and `item` (the operator, 2026-09-23) are the fly finding what is in a place:
// a person or a sign it opened, an item it picked up. That is the family `explore`
// already counts -- new ground, a door found -- at the same quiet scale (0.05 to
// 0.15), so both publish there and the feed's closed kind set does not move. Not
// `area`, which counts maps and is a notable row; not `story`, which is the plot;
// not `wildwin`, which is a battle. The Pokémon Red ticker's copy for `explore` stays
// "new place" for all four, the operator's call (`apps/stage/src/games/pokemon-red.ts`).
"talk" => Self::Explore,
"item" => Self::Explore,
// The platformer. // The platformer.
"band" => Self::Explore, "band" => Self::Explore,
"coin" => Self::Wildwin, "coin" => Self::Wildwin,
@ -749,6 +758,8 @@ mod tests {
assert_eq!(RewardKind::from_adapter("nonsense"), None); assert_eq!(RewardKind::from_adapter("nonsense"), None);
assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore)); assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore));
assert_eq!(RewardKind::from_adapter("catch"), Some(RewardKind::Wildwin)); assert_eq!(RewardKind::from_adapter("catch"), Some(RewardKind::Wildwin));
assert_eq!(RewardKind::from_adapter("talk"), Some(RewardKind::Explore));
assert_eq!(RewardKind::from_adapter("item"), Some(RewardKind::Explore));
} }
#[test] #[test]

View file

@ -0,0 +1,385 @@
//! `FLY_TRACE=<path>`: a per-frame record of the legacy loop, for parity and for the port.
//!
//! One JSON object per line. The first line names the format; every other line is either one
//! transition `k -> k+1` of the legacy frame order (`crate::frame`) together with what happened at
//! the boundary it reached, or -- once, before the first transition -- the captures taken at the
//! boundary the run started on.
//!
//! The field names follow the step trace of the session framework
//! (`docs/design/session-framework/step-v1.md` section 8 and its 2026-09-23 amendment, Rust
//! `fly_session_types::trace`) wherever a legacy field is the same thing:
//!
//! - `behaviour` is what two runs of one build pair must agree on, byte for byte:
//! `step`, `ticksAdvanced`, `brainTicks` and `remainder` (a `RationalNs`, exact, because every
//! legacy remainder is a multiple of 2^-15 ms: legacy-gameboy-v1 section 3), the decision, the
//! controller mask, the macro and reward events in the order they happened, the digests of the
//! rates, of the spikes of this transition, of the frame and of work RAM, the rank, and
//! `boundaryActions` -- a ratchet capture is a `save-slot` of slot `best` with the digest of the
//! saved emulator state, a ratchet recovery is a `rollback` to `best` -- in the shape of
//! `TraceBehaviour.boundaryActions`;
//! - `operational.captures` is every checkpoint taken at the boundary, in order, each with the
//! number of boundary actions applied before it, in the shape of `TraceOperational.captures`.
//! The legacy loop archives a milestone *before* the ratchet captures (legacy-gameboy-v1 section
//! 4), so a climb records `afterActions: 0` ahead of a `save-slot`: the declared difference, as
//! it happens, which the ported loop's validator refuses on purpose.
//!
//! `admissions` is sugar and operator reward pulses applied at the top of the frame, before the
//! brain ticks: the admission cut of legacy-gameboy-v1 section 15.
//!
//! Wall time is never written, so a run with the periodic checkpoint intervals pushed out of the
//! way produces the same file twice. Off by default; when off, nothing here is constructed and the
//! loop pays one `Option` test per hook.
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use flybrain_core::lif::LifNetwork;
use flybrain_gb::RewardEvent;
use flybrain_gb::emulator::Emulator;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use crate::macros::MacroEvent;
/// The format name on the first line.
pub const FORMAT: &str = "flysim-legacy-frame-trace-v1";
/// The environment variable that turns the trace on.
pub const ENV: &str = "FLY_TRACE";
/// The ratchet's one slot, as the legacy composition declares it (legacy-gameboy-v1 section 9).
pub const SLOT: &str = "best";
/// Work RAM, `$C000..=$DFFF`, as the CPU sees it.
const WRAM: std::ops::RangeInclusive<u16> = 0xc000..=0xdfff;
/// Lowercase hex SHA-256.
pub fn sha256_hex(bytes: &[u8]) -> String {
hex(&Sha256::digest(bytes))
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
/// A legacy remainder in milliseconds as exact nanoseconds, `{numerator, denominator}` in lowest
/// terms. The remainder is always `m / 32768` ms for an integer `m` (legacy-gameboy-v1 section
/// 3), so this never rounds; a value that is not is written with its bits instead.
pub fn remainder_ns(remainder_ms: f64) -> Value {
let scaled = remainder_ms * 32_768.0;
if scaled.fract() != 0.0 || !(0.0..32_768.0 * 1_000.0).contains(&scaled) {
return json!({ "inexactBits": format!("{:016x}", remainder_ms.to_bits()) });
}
// ns = m * 1e6 / 32768 = m * 15625 / 512.
let mut numerator = scaled as u64 * 15_625;
let mut denominator = 512u64;
let (mut a, mut b) = (numerator, denominator);
while b != 0 {
(a, b) = (b, a % b);
}
if a > 1 {
numerator /= a;
denominator /= a;
}
if numerator == 0 {
denominator = 1;
}
json!({ "numerator": numerator.to_string(), "denominator": denominator.to_string() })
}
fn macro_json(phase: &str, event: &MacroEvent) -> Value {
json!({
"phase": phase,
"slot": event.slot,
"name": event.name,
"outcome": event.outcome.map(|outcome| outcome.as_str()),
})
}
/// One transition and the boundary it reached, while it is being recorded.
#[derive(Default)]
struct Record {
step: u64,
admissions: Vec<Value>,
ticks: u64,
brain_ticks: u64,
remainder: Value,
rates: String,
spikes: String,
spike_count: u64,
decision: Vec<String>,
mask: u32,
macros: Vec<Value>,
framebuffer: String,
wram: String,
rewards: Vec<Value>,
rank: u32,
boundary_actions: Vec<Value>,
captures: Vec<Value>,
}
/// The recorder. The loop calls it at fixed points of the frame order; it holds the transition
/// open until the next one starts, so the captures and admissions a host takes between two frames
/// land on the boundary they belong to.
pub struct FrameTrace {
out: BufWriter<File>,
/// Captures at the boundary the run started on, before any transition.
initial: Vec<Value>,
initial_step: Option<u64>,
open: Option<Record>,
/// Admissions since the last transition started: they belong to the next one.
admissions: Vec<Value>,
/// Brain clock before this transition's ticks, the lower edge of its spike window.
ms_before: f64,
}
impl FrameTrace {
/// `FLY_TRACE`, if it is set and non-empty. A path that cannot be created is an error rather
/// than a silent run without the trace that was asked for.
pub fn from_env() -> std::io::Result<Option<Self>> {
match std::env::var_os(ENV) {
Some(path) if !path.is_empty() => Self::create(Path::new(&path)).map(Some),
_ => Ok(None),
}
}
pub fn create(path: &Path) -> std::io::Result<Self> {
let mut out = BufWriter::with_capacity(1 << 20, File::create(path)?);
writeln!(out, "{}", json!({ "format": FORMAT }))?;
Ok(Self {
out,
initial: Vec::new(),
initial_step: None,
open: None,
admissions: Vec::new(),
ms_before: 0.0,
})
}
fn write(&mut self, value: &Value) {
if let Err(error) = writeln!(self.out, "{value}") {
tracing::warn!(%error, "could not write the frame trace");
}
}
/// Sugar admitted at the top of the frame, before the brain ticks.
pub fn sugar(&mut self, duration_ms: f64) {
self.admissions
.push(json!({ "kind": "sugar", "durationMs": duration_ms }));
}
/// An operator reward pulse, applied at the top of the frame.
pub fn reward_pulse(&mut self, value: f64) {
self.admissions
.push(json!({ "kind": "reward", "value": value }));
}
/// A checkpoint capture at the current boundary: the open transition's, or the start's.
pub fn capture(&mut self, generation: u64, step: u64) {
let (captures, after) = match self.open.as_mut() {
Some(record) => {
let after = record.boundary_actions.len();
(&mut record.captures, after)
}
None => {
self.initial_step.get_or_insert(step);
(&mut self.initial, 0)
}
};
captures.push(json!({ "checkpointId": format!("g{generation}"), "afterActions": after }));
}
/// Transition `step -> step + 1` begins: the previous one is complete.
pub fn begin(&mut self, step: u64, ms_before: f64) {
self.flush_open();
if let Some(start) = self.initial_step.take() {
let initial = std::mem::take(&mut self.initial);
self.write(&json!({
"boundary": start.to_string(),
"operational": { "captures": initial },
}));
}
self.ms_before = ms_before;
self.open = Some(Record {
step,
admissions: std::mem::take(&mut self.admissions),
..Record::default()
});
}
/// Phase A's ticks, and the network they left behind.
pub fn ticked(&mut self, ticks: u64, remainder_ms: f64, network: &LifNetwork) {
let Some(record) = self.open.as_mut() else {
return;
};
record.ticks = ticks;
record.brain_ticks = network.ms as u64;
record.remainder = remainder_ns(remainder_ms);
let mut hasher = Sha256::new();
for (role, rate) in network.rates.iter() {
hasher.update((role.len() as u32).to_le_bytes());
hasher.update(role.as_bytes());
hasher.update(rate.to_bits().to_le_bytes());
}
record.rates = hex(&hasher.finalize());
let (bits, count) =
crate::snapshot::spike_bitset(&network.last_spike_ms, self.ms_before, network.ms);
record.spikes = sha256_hex(&bits);
record.spike_count = count;
}
/// The readout's decision, as decoded.
pub fn decided(&mut self, active: &[String]) {
if let Some(record) = self.open.as_mut() {
record.decision = active.to_vec();
}
}
/// Phase B: the mask the emulator is given, and the macro events deciding it produced.
pub fn executed(&mut self, mask: u32, events: &[MacroEvent]) {
if let Some(record) = self.open.as_mut() {
record.mask = mask;
record
.macros
.extend(events.iter().map(|event| macro_json("execute", event)));
}
}
/// The frame the emulator produced, and work RAM after it. Read uncached, so the trace never
/// fills the per-frame read cache the adapter and the macros share.
pub fn advanced(&mut self, framebuffer: &[u8], emulator: &Emulator) {
if let Some(record) = self.open.as_mut() {
record.framebuffer = sha256_hex(framebuffer);
let wram: Vec<u8> = WRAM
.map(|address| emulator.read_uncached(address))
.collect();
record.wram = sha256_hex(&wram);
}
}
/// Phase C: the reward events in adapter order, the scene's own macro events, and the rank.
pub fn evaluated(&mut self, rewards: &[RewardEvent], abandoned: &[MacroEvent], rank: u32) {
if let Some(record) = self.open.as_mut() {
record.rewards.extend(rewards.iter().map(|event| {
json!({
"kind": event.kind,
"value": event.value,
"stimulationMs": event.stimulation_ms,
})
}));
record
.macros
.extend(abandoned.iter().map(|event| macro_json("evaluate", event)));
record.rank = rank;
}
}
/// The ratchet captured: a slot save at the boundary.
pub fn slot_saved(&mut self, state: &[u8]) {
if let Some(record) = self.open.as_mut() {
record.boundary_actions.push(json!({
"kind": "save-slot",
"slotId": SLOT,
"stateDigest": sha256_hex(state),
}));
}
}
/// The ratchet rolled back, and the macro events the rollback produced.
pub fn rolled_back(&mut self, events: &[MacroEvent]) {
if let Some(record) = self.open.as_mut() {
record.boundary_actions.push(json!({
"kind": "rollback",
"slotId": SLOT,
"stateDigest": null,
}));
record
.macros
.extend(events.iter().map(|event| macro_json("rollback", event)));
}
}
fn flush_open(&mut self) {
let Some(record) = self.open.take() else {
return;
};
let line = json!({
"behaviour": {
"step": record.step.to_string(),
"admissions": record.admissions,
"ticksAdvanced": record.ticks.to_string(),
"brainTicks": record.brain_ticks.to_string(),
"remainder": record.remainder,
"ratesDigest": record.rates,
"spikesDigest": record.spikes,
"spikeCount": record.spike_count,
"decision": record.decision,
"mask": record.mask,
"macroEvents": record.macros,
"framebufferDigest": record.framebuffer,
"wramDigest": record.wram,
"rewards": record.rewards,
"rank": record.rank,
"acknowledgedBoundary": (record.step + 1).to_string(),
"boundaryActions": record.boundary_actions,
},
"operational": { "captures": record.captures },
});
self.write(&line);
}
/// Write the open transition and flush the file: on shutdown, and on drop.
pub fn finish(&mut self) {
self.flush_open();
if let Err(error) = self.out.flush() {
tracing::warn!(%error, "could not flush the frame trace");
}
}
}
impl Drop for FrameTrace {
fn drop(&mut self) {
self.finish();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_legacy_remainder_is_exact_nanoseconds() {
// The first twelve legacy frames' remainders, from the clock the fixture records.
let per_frame = 1000.0 / (4_194_304.0 / 70_224.0);
let mut remainder = 0.0f64;
for _ in 0..100_000 {
remainder += per_frame;
remainder -= remainder.floor();
let value = remainder_ns(remainder);
assert!(
value.get("numerator").is_some(),
"{remainder} was not exact: {value}"
);
}
assert_eq!(
remainder_ns(0.0),
json!({ "numerator": "0", "denominator": "1" })
);
// 0.5 ms is 500000 ns.
assert_eq!(
remainder_ns(0.5),
json!({ "numerator": "500000", "denominator": "1" })
);
// One 2^-15 ms step is 15625/512 ns.
assert_eq!(
remainder_ns(1.0 / 32_768.0),
json!({ "numerator": "15625", "denominator": "512" })
);
}
}

View file

@ -1,15 +1,21 @@
//! A `v5` checkpoint restored under `v6`: accepted with the opt-in, refused without it. //! A `v6` checkpoint restored under `v7`: accepted with the opt-in, refused without it.
//! //!
//! The unit tests in `flybrain-gb` cover the decision function and the adapter's own state //! The unit tests in `flybrain-gb` cover the decision function and the adapter's own state
//! migration separately. This is the two of them against one artefact: a real `FLYSIM01` //! migration separately. This is the two of them against one artefact: a real `FLYSIM01`
//! envelope carrying a `pokered-unique8-v5` compatibility string and a `v5` reward ledger — //! envelope carrying a `pokered-unique8-v6` compatibility string and a `v6` reward ledger —
//! written, encoded, decoded, and then put through exactly what `Sim::try_restore` puts a //! written, encoded, decoded, and then put through exactly what `Sim::try_restore` puts a
//! candidate through. //! candidate through, and then one sample of a game in which items were already taken.
//! //!
//! No ROM and no dataset, deliberately. Building a `Sim` would need both, and neither is part of //! No ROM and no dataset, deliberately. Building a `Sim` would need both, and neither is part of
//! the question: what decides a restore is the compatibility string and `import_state`. //! the question: what decides a restore is the compatibility string and `import_state`, and what
//! decides the seed is the first sample's read of the cartridge's own item bits.
//!
//! (`v5` -> `v6`, the catch rule's migration, was this same file; `v6` no longer migrates from
//! anything, so that pair is refused now like any other.)
use flybrain_gb::GameAdapter; use flybrain_gb::GameAdapter;
use flybrain_gb::MemoryReader;
use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::compatibility::{RestoreDecision, accepted_adapters, decide}; use flybrain_gb::compatibility::{RestoreDecision, accepted_adapters, decide};
use flybrain_gb::pokemon_red::PokemonRedReward; use flybrain_gb::pokemon_red::PokemonRedReward;
use flysim::store::{self, RuntimeState}; use flysim::store::{self, RuntimeState};
@ -24,24 +30,30 @@ fn compatibility(adapter: &str) -> String {
) )
} }
/// A `v5` reward ledger: `STATE_VERSION` 4, every field `v5` wrote, and **no** `catchCounts`. /// A `v6` reward ledger: `STATE_VERSION` 4, every field `v6` wrote, and **no** `talk:`,
/// `item:`, `hidden:` or `items:seeded` key in `seen`.
/// ///
/// Written out by hand rather than exported from an adapter, because an exported one would be a /// Written out by hand rather than exported from an adapter, because an exported one would be a
/// `v6` state with the counter deleted — this is the shape the release box's checkpoints really /// `v7` state with keys deleted — this is the shape the release box's checkpoints really carry,
/// carry, field for field. /// field for field, including a `boundary:` key earned indoors (Red's staircase, map 38) that
fn v5_reward() -> serde_json::Value { /// `v7` would not have paid for and keeps anyway.
fn v6_reward() -> serde_json::Value {
serde_json::json!({ serde_json::json!({
"version": 4, "version": 4,
"seen": ["adventure", "map:0", "early:outside", "dex:3", "boundary:0:edge:n:near"], "seen": [
"adventure", "map:0", "early:outside", "dex:3", "boundary:0:edge:n:near",
"boundary:38:1:7:on"
],
"tiles": ["0:5:6", "0:5:7"], "tiles": ["0:5:6", "0:5:7"],
"tileCounts": { "0": 2 }, "tileCounts": { "0": 2 },
"wildWins": { "0:112:4": 2 }, "wildWins": { "0:112:4": 2 },
"catchCounts": { "176": 1 },
"replayBlocked": [], "replayBlocked": [],
"counts": { "counts": {
"milestone": 2, "exploration": 0, "map": 1, "species": 1, "milestone": 2, "exploration": 0, "map": 1, "species": 1,
"trainer": 0, "battle": 2, "badge": 0, "boundary": 1 "trainer": 0, "battle": 2, "badge": 0, "boundary": 2, "catch": 1
}, },
"total": 2.05, "total": 2.45,
"recent": [{ "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 }], "recent": [{ "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 }],
"last": { "species": { "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 } }, "last": { "species": { "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 } },
"initialized": true, "initialized": true,
@ -55,7 +67,7 @@ fn v5_reward() -> serde_json::Value {
}) })
} }
fn v5_checkpoint() -> Vec<u8> { fn v6_checkpoint() -> Vec<u8> {
use flybrain_core::decoder::DecoderState; use flybrain_core::decoder::DecoderState;
use flybrain_core::lif::LifState; use flybrain_core::lif::LifState;
use flybrain_core::ordered::NumberMap; use flybrain_core::ordered::NumberMap;
@ -105,12 +117,12 @@ fn v5_checkpoint() -> Vec<u8> {
wall_ms: 1_790_000_000_000, wall_ms: 1_790_000_000_000,
rom_sha256: flybrain_gb::pokemon_red::SUPPORTED_ROM.to_string(), rom_sha256: flybrain_gb::pokemon_red::SUPPORTED_ROM.to_string(),
emulator_frame: 1_000_000, emulator_frame: 1_000_000,
compatibility: compatibility("pokered-unique8-v5"), compatibility: compatibility("pokered-unique8-v6"),
speed: 1.0, speed: 1.0,
buttons: 0, buttons: 0,
rank_since_ms: 1_000.0, rank_since_ms: 1_000.0,
last_event_id: 4_242, last_event_id: 4_242,
reward: v5_reward(), reward: v6_reward(),
ratchet: flybrain_gb::RatchetState { best: 3, attempts: 1, recoveries: 4, ..Default::default() }, ratchet: flybrain_gb::RatchetState { best: 3, attempts: 1, recoveries: 4, ..Default::default() },
emulator: vec![3; 64], emulator: vec![3; 64],
framebuffer: vec![0; 32], framebuffer: vec![0; 32],
@ -121,13 +133,13 @@ fn v5_checkpoint() -> Vec<u8> {
} }
#[test] #[test]
fn a_v5_checkpoint_is_refused_under_v6_without_the_opt_in() { fn a_v6_checkpoint_is_refused_under_v7_without_the_opt_in() {
let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes"); let checkpoint = store::decode(&v6_checkpoint()).expect("the fixture decodes");
let adapter = PokemonRedReward::new(); let adapter = PokemonRedReward::new();
let current = compatibility(adapter.id()); let current = compatibility(adapter.id());
assert_ne!(checkpoint.runtime.compatibility, current, "v6 is not v5"); assert_ne!(checkpoint.runtime.compatibility, current, "v7 is not v6");
for opt_in in [None, Some(""), Some("pokered-unique8-v4"), Some("some-other-adapter")] { for opt_in in [None, Some(""), Some("pokered-unique8-v5"), Some("some-other-adapter")] {
assert!( assert!(
matches!( matches!(
decide( decide(
@ -143,9 +155,18 @@ fn a_v5_checkpoint_is_refused_under_v6_without_the_opt_in() {
} }
} }
/// A flat 64 KiB address space: the one thing a sample reads.
struct Wram(Vec<u8>);
impl MemoryReader for Wram {
fn read8(&mut self, address: u16) -> u8 {
self.0[address as usize]
}
}
#[test] #[test]
fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_zero() { fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_seeded() {
let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes"); let checkpoint = store::decode(&v6_checkpoint()).expect("the fixture decodes");
let mut adapter = PokemonRedReward::new(); let mut adapter = PokemonRedReward::new();
let current = compatibility(adapter.id()); let current = compatibility(adapter.id());
@ -154,25 +175,25 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z
&checkpoint.runtime.compatibility, &checkpoint.runtime.compatibility,
&current, &current,
adapter.migrates_from(), adapter.migrates_from(),
&accepted_adapters(Some("pokered-unique8-v5")), &accepted_adapters(Some("pokered-unique8-v6")),
), ),
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() } RestoreDecision::MigrateAdapter { from: "pokered-unique8-v6".to_string() }
); );
// The migration itself: `import_state`, exactly as `Sim::try_restore` calls it. // The migration itself: `import_state`, exactly as `Sim::try_restore` calls it.
adapter.import_state(&checkpoint.runtime.reward).expect("a v5 ledger is a valid v6 ledger"); adapter.import_state(&checkpoint.runtime.reward).expect("a v6 ledger is a valid v7 ledger");
let after = adapter.export_state(); let after = adapter.export_state();
assert_eq!(after["catchCounts"], serde_json::json!({}), "the new counter starts at 0"); assert_eq!(after["counts"]["talk"], serde_json::json!(0), "no conversation was ever paid");
assert_eq!(after["counts"]["catch"], serde_json::json!(0)); assert_eq!(after["counts"]["item"], serde_json::json!(0), "nor any item");
// And nothing else moved: every field the v5 state carried round-trips to the same value, // And nothing else moved: every field the v6 state carried round-trips to the same value,
// and the only key v6 adds is the counter. // and v7 adds no field at all -- its ledgers are keys in `seen`.
// //
// `counts` is the one field that is not byte-identical, and it is not a change of meaning: // `counts` is the one field that is not byte-identical, and it is not a change of meaning:
// it serializes every kind in the catalog, so a v6 state lists `catch` where a v5 state had // it serializes every kind in the catalog, so a v7 state lists `talk` and `item` where a v6
// nothing to list. Every kind the v5 state did carry keeps its number. // state had nothing to list. Every kind the v6 state did carry keeps its number.
let before = v5_reward(); let before = v6_reward();
for (key, value) in before.as_object().unwrap() { for (key, value) in before.as_object().unwrap() {
if key == "counts" { if key == "counts" {
for (kind, count) in value.as_object().unwrap() { for (kind, count) in value.as_object().unwrap() {
@ -184,7 +205,7 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z
.keys() .keys()
.filter(|kind| !value.as_object().unwrap().contains_key(*kind)) .filter(|kind| !value.as_object().unwrap().contains_key(*kind))
.collect(); .collect();
assert_eq!(added, vec!["catch"], "v6 counts one more kind and no others"); assert_eq!(added, vec!["talk", "item"], "v7 counts two more kinds and no others");
continue; continue;
} }
assert_eq!(&after[key], value, "{key} must survive the migration byte for byte"); assert_eq!(&after[key], value, "{key} must survive the migration byte for byte");
@ -195,7 +216,29 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z
.keys() .keys()
.filter(|key| !before.as_object().unwrap().contains_key(*key)) .filter(|key| !before.as_object().unwrap().contains_key(*key))
.collect(); .collect();
assert_eq!(added, vec!["catchCounts"], "v6 adds one field and no others"); assert!(added.is_empty(), "v7 adds no field: {added:?}");
// The first sample of the restored game. Under v6 the fly took an item ball (global
// toggleable index 0x2a) and a hidden item (index 9); v6 paid for neither. The sample seeds
// both into the ledger and pays nothing, which is the "no retroactive payout" half.
let mut wram = Wram(vec![0; 0x10000]);
wram.0[ram::wStatusFlags6 as usize] = 1;
wram.0[ram::wPartyCount as usize] = 1;
wram.0[ram::wCurMapWidth as usize] = 10;
wram.0[ram::wCurMapHeight as usize] = 9;
wram.0[ram::wXCoord as usize] = 5;
wram.0[ram::wYCoord as usize] = 7;
wram.0[(ram::wToggleableObjectFlags + 0x2a / 8) as usize] |= 1 << (0x2a % 8);
wram.0[(ram::wObtainedHiddenItemsFlags + 1) as usize] |= 1 << 1;
assert!(adapter.sample(&mut wram, 2_000.0).is_empty(), "the seed pays nothing");
let seen = adapter.export_state()["seen"].clone();
for key in ["item:42", "hidden:9", "items:seeded"] {
assert!(seen.as_array().unwrap().contains(&serde_json::json!(key)), "{key} seeded");
}
assert!(
!seen.as_array().unwrap().iter().any(|key| key.as_str().unwrap().starts_with("talk:")),
"the talk ledger starts empty"
);
// The rest of what a restore reads is untouched by the migration. // The rest of what a restore reads is untouched by the migration.
assert_eq!(adapter.progress().rank, 3); assert_eq!(adapter.progress().rank, 3);
@ -206,11 +249,11 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z
#[test] #[test]
fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() { fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() {
let adapter = PokemonRedReward::new(); let adapter = PokemonRedReward::new();
let accepted = accepted_adapters(Some("pokered-unique8-v5")); let accepted = accepted_adapters(Some("pokered-unique8-v6"));
let current = compatibility(adapter.id()); let current = compatibility(adapter.id());
// A v5 string whose state format also moved: a different build, not a rule change. // A v6 string whose state format also moved: a different build, not a rule change.
let other_abi = compatibility("pokered-unique8-v5").replace("199616", "199617"); let other_abi = compatibility("pokered-unique8-v6").replace("199616", "199617");
assert!(matches!( assert!(matches!(
decide(&other_abi, &current, adapter.migrates_from(), &accepted), decide(&other_abi, &current, adapter.migrates_from(), &accepted),
RestoreDecision::Refuse(_) RestoreDecision::Refuse(_)
@ -221,4 +264,15 @@ fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() {
decide(&current, &current, adapter.migrates_from(), &[]), decide(&current, &current, adapter.migrates_from(), &[]),
RestoreDecision::Exact RestoreDecision::Exact
); );
// And v5 -> v7 is not a migration this adapter wrote, whatever the operator names.
assert!(matches!(
decide(
&compatibility("pokered-unique8-v5"),
&current,
adapter.migrates_from(),
&accepted_adapters(Some("pokered-unique8-v5,pokered-unique8-v6")),
),
RestoreDecision::Refuse(_)
));
} }

View file

@ -0,0 +1,93 @@
//! `FLY_TRACE`'s boundary half is the session framework's, field for field.
//!
//! The recorder writes `behaviour.boundaryActions` and `operational.captures` in the shapes of
//! `TraceBehaviour.boundaryActions` and `TraceOperational.captures` (step-v1 section 8, amended
//! 2026-09-23). This test records the two boundaries the legacy loop produces and reads them with
//! `fly-session-types` itself, by grafting them onto the shared baseline trace:
//!
//! - a rollback followed by the post-recovery checkpoint is a valid transition trace;
//! - a rung climb -- the milestone archive, *then* the ratchet's slot save -- is refused with the
//! capture-before-save error. That is the legacy order legacy-gameboy-v1 section 4 declares, and
//! the trace has to show it as it happens rather than tidy it away.
use fly_session_types::fixtures;
use fly_session_types::scalar::DomainType;
use fly_session_types::trace::TransitionTrace;
use flysim::trace::FrameTrace;
use serde_json::Value;
fn lines(path: &std::path::Path) -> Vec<Value> {
std::fs::read_to_string(path)
.expect("the trace")
.lines()
.map(|line| serde_json::from_str(line).expect("one JSON object per line"))
.collect()
}
/// The shared baseline transition with this boundary's actions and captures in place of its own.
fn grafted(record: &Value) -> Result<TransitionTrace, String> {
let file = fixtures::load("traces.json").expect("traces.json");
let mut baseline = file.get("baseline").expect("baseline").clone();
baseline["behaviour"]["boundaryActions"] = record["behaviour"]["boundaryActions"].clone();
baseline["operational"]["captures"] = record["operational"]["captures"].clone();
TransitionTrace::from_json(&baseline).map_err(|error| error.to_string())
}
#[test]
fn the_boundary_half_of_the_trace_is_the_session_frameworks() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("trace.jsonl");
let mut trace = FrameTrace::create(&path).expect("the trace file");
// The start: the boot checkpoint, before any transition.
trace.capture(1, 100);
// Transition 100 -> 101 ends in a stall rollback, then the post-recovery checkpoint.
trace.sugar(400.0);
trace.begin(100, 1_000.0);
trace.rolled_back(&[]);
trace.capture(2, 101);
// Transition 101 -> 102 climbs a rung: the archive first, then the ratchet captures.
trace.begin(101, 1_017.0);
trace.capture(3, 102);
trace.slot_saved(b"emulator state");
trace.finish();
drop(trace);
let lines = lines(&path);
assert_eq!(lines.len(), 4, "format, start, two transitions: {lines:?}");
assert_eq!(lines[0]["format"], flysim::trace::FORMAT);
assert_eq!(lines[1]["boundary"], "100");
assert_eq!(lines[1]["operational"]["captures"][0]["checkpointId"], "g1");
let rollback = &lines[2];
assert_eq!(rollback["behaviour"]["step"], "100");
assert_eq!(rollback["behaviour"]["admissions"][0]["kind"], "sugar");
assert_eq!(
rollback["behaviour"]["boundaryActions"][0]["kind"],
"rollback"
);
assert_eq!(rollback["operational"]["captures"][0]["afterActions"], 1);
let parsed = grafted(rollback).expect("a rollback and then its checkpoint is a valid trace");
assert_eq!(parsed.behaviour.boundary_actions.len(), 1);
assert_eq!(parsed.operational.captures.len(), 1);
let climb = &lines[3];
assert_eq!(
climb["behaviour"]["boundaryActions"][0]["kind"],
"save-slot"
);
assert_eq!(
climb["behaviour"]["boundaryActions"][0]["slotId"],
flysim::trace::SLOT
);
assert_eq!(
climb["behaviour"]["boundaryActions"][0]["stateDigest"],
flysim::trace::sha256_hex(b"emulator state")
);
assert_eq!(climb["operational"]["captures"][0]["afterActions"], 0);
let refused = grafted(climb).expect_err("the legacy archive precedes the slot save");
assert!(
refused.contains("before this boundary's slot saves"),
"refused for the declared reason: {refused}"
);
}

View file

@ -267,7 +267,7 @@ async fn the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_kil
let (status, versions) = service.get("/status"); let (status, versions) = service.get("/status");
assert_eq!(status, 200); assert_eq!(status, 200);
assert_eq!(versions["version"]["kernel"], json!("lif-1ms-f64-v2")); assert_eq!(versions["version"]["kernel"], json!("lif-1ms-f64-v2"));
assert_eq!(versions["version"]["adapter"], json!("pokered-unique8-v6")); assert_eq!(versions["version"]["adapter"], json!("pokered-unique8-v7"));
assert!( assert!(
versions["version"]["dataset"].as_str().unwrap_or_default().len() > 32, versions["version"]["dataset"].as_str().unwrap_or_default().len() > 32,
"the dataset fingerprint is in /status: {}", "the dataset fingerprint is in /status: {}",
@ -437,6 +437,17 @@ async fn the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_kil
assert_eq!(logged[0]["by"], json!("integration-test")); assert_eq!(logged[0]["by"], json!("integration-test"));
let on_disk = std::fs::read_to_string(dir.path().join("state/events.jsonl")).unwrap(); let on_disk = std::fs::read_to_string(dir.path().join("state/events.jsonl")).unwrap();
assert!(on_disk.contains("integration-test fed the fly sugar"), "{on_disk}"); assert!(on_disk.contains("integration-test fed the fly sugar"), "{on_disk}");
// And in the hot directory's journal, stamped with the frame it was applied before, which
// is what a shadow run replays (`flysim::journal`).
let journal_path = dir.path().join("hot").join(flysim::journal::FILE_NAME);
let journal = flysim::journal::read(&journal_path).expect("the sugar journal");
assert_eq!(journal.len(), 1, "{journal:?}");
assert_eq!(journal[0]["kind"], json!("sugar"));
assert_eq!(journal[0]["eventId"], json!(event_id));
assert_eq!(journal[0]["durationMs"], json!(400.0));
let stamped: u64 =
journal[0]["frame"].as_str().and_then(|frame| frame.parse().ok()).expect("a frame");
assert!(stamped >= 1, "stamped with a frame the emulator has run: {stamped}");
// A second pulse while the first is still being applied is refused, not stacked. // A second pulse while the first is still being applied is refused, not stacked.
let (status, body) = service.post( let (status, body) = service.post(

View file

@ -43,6 +43,7 @@ use flybrain_gb::{
AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter,
}; };
use flysim::config::Config; use flysim::config::Config;
use flysim::frame::LegacyFrame;
use flysim::macros::{MacroLayer, macro_layer}; use flysim::macros::{MacroLayer, macro_layer};
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
@ -92,6 +93,8 @@ struct Run {
gb: Emulator, gb: Emulator,
adapter: PokemonRedReward, adapter: PokemonRedReward,
layer: MacroLayer, layer: MacroLayer,
/// The stream's frame (`flysim::frame::LegacyFrame`), behind the stub readout.
legacy: LegacyFrame,
decoder: PopulationDecoder, decoder: PopulationDecoder,
channels: Vec<&'static str>, channels: Vec<&'static str>,
ms: f64, ms: f64,
@ -124,6 +127,7 @@ impl Run {
gb, gb,
adapter, adapter,
layer, layer,
legacy: LegacyFrame::new(),
decoder, decoder,
channels, channels,
ms: 0.0, ms: 0.0,
@ -161,18 +165,14 @@ impl Run {
}; };
let bound = self.layer.bound_channels(); let bound = self.layer.bound_channels();
let active = self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound)); let active = self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound));
let mask = { self.legacy.execute(Some(&mut self.layer), &active, 0, self.ms, &mut self.gb, &self.adapter);
let ledger = AdapterLedger(&self.adapter);
self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger).mask
};
self.gb.set_buttons(mask as u8);
self.gb.run_frame().expect("a frame should complete");
self.ms += MS_PER_FRAME; self.ms += MS_PER_FRAME;
self.frame += 1; self.frame += 1;
let ms = self.ms; let evaluated = self
self.payouts.extend(self.adapter.sample(&mut self.gb, ms)); .legacy
let ledger = AdapterLedger(&self.adapter); .stub_advance(Some(&mut self.layer), &mut self.gb, &mut self.adapter, self.ms)
let _ = self.layer.observe(&mut self.gb, &ledger, ms); .expect("a frame should complete");
self.payouts.extend(evaluated.rewards);
} }
fn catches(&self) -> Vec<&RewardEvent> { fn catches(&self) -> Vec<&RewardEvent> {

View file

@ -0,0 +1,432 @@
//! The engagement rewards against the real cartridge: a conversation indoors and an item ball.
//!
//! Gated on `FLY_ROM` *and* on a checkpoint, the way every ROM test in this workspace is, and
//! skips cleanly without either:
//!
//! ```sh
//! FLY_ROM="$HOME/roms/pokemon-red.gb" \
//! FLY_ENGAGE_CHECKPOINT=.local/checkpoints/<a rung-10 Pewter checkpoint> \
//! cargo test --release -p flysim --test rom_engage -- --nocapture
//! ```
//!
//! ## What only the cartridge can answer
//!
//! The synthetic traces in `pokemon_red/tests.rs` write `wFontLoaded`, `wSpriteIndex`,
//! `wToggleableObjectFlags` and the rest from the disassembly. They cannot say that an A press at
//! a person on this cartridge opens the box on a frame whose previous sample was the fly's own,
//! that `wSpriteIndex` names that person by the time the adapter samples, or that `PickUpItem`
//! raises the ball's bit on a frame the adapter sees. This test does, with the shipping adapter
//! sampling once a frame, and it restores the checkpoint's own `v6` reward ledger -- so it is also
//! the `v6` -> `v7` migration on real game state: the item keys are seeded from the cartridge's
//! bits and nothing already taken pays.
//!
//! ## How the fly is moved
//!
//! Not by the macro layer, and not by a brain: a small scripted walker with a breadth-first route
//! over the whole-map grid (`state::map_grid`), pressing one direction at a time, answering text
//! with B and battles with A. The question is what the adapter reads, not whether anything finds
//! its way there. From the Pewter checkpoint it walks south out of the city, down Route 2 into the
//! Viridian Forest north gate -- a building, where it talks to the old man twice -- and on into
//! the forest to the Antidote ball at (25, 11), which it picks up, and then, after rolling the
//! emulator back to before the pickup, picks up again.
use std::collections::VecDeque;
use flybrain_gb::adapter::RewardEvent;
use flybrain_gb::pokemon_red::macros::state::{Facing, Walkable};
use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::pokemon_red::{PokemonRedReward, catalog, engage, state};
use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons};
const MS_PER_FRAME: f64 = 1000.0 / 59.7275;
const PEWTER_CITY: u8 = 0x02;
const ROUTE_2: u8 = 0x0d;
const NORTH_GATE: u8 = 0x2f;
const VIRIDIAN_FOREST: u8 = 0x33;
/// `constants/item_constants.asm`: `ANTIDOTE` is `$0b`.
const ANTIDOTE: &str = "FOUND ITEM #11";
fn rom() -> Option<Vec<u8>> {
let path = std::env::var_os("FLY_ROM")?;
match std::fs::read(&path) {
Ok(bytes) => Some(bytes),
Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"),
}
}
fn checkpoint() -> Option<flysim::store::Checkpoint> {
let path = std::env::var_os("FLY_ENGAGE_CHECKPOINT")?;
Some(
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope"),
)
}
struct Run {
gb: Emulator,
adapter: PokemonRedReward,
ms: f64,
frames: u64,
payouts: Vec<RewardEvent>,
/// `(wCurMap, wCurMapTileset)` on the frame each payout in `payouts` was made.
payout_maps: Vec<(u8, u8)>,
}
fn delta(facing: Facing) -> (i16, i16) {
facing.delta()
}
fn mask(facing: Facing) -> u8 {
match facing {
Facing::Up => buttons::UP,
Facing::Down => buttons::DOWN,
Facing::Left => buttons::LEFT,
Facing::Right => buttons::RIGHT,
}
}
const FACINGS: [Facing; 4] = [Facing::Up, Facing::Down, Facing::Left, Facing::Right];
impl Run {
fn resume(rom: &[u8], checkpoint: &flysim::store::Checkpoint) -> Self {
let mut gb = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
.expect("binjgb should accept the cartridge");
gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state");
let mut adapter = PokemonRedReward::new();
// The checkpoint was written by `pokered-unique8-v6`: this is the migration.
adapter.import_state(&checkpoint.runtime.reward).expect("a v6 ledger is a v7 ledger");
Self { gb, adapter, ms: 0.0, frames: 0, payouts: Vec::new(), payout_maps: Vec::new() }
}
fn byte(&mut self, address: u16) -> u8 {
self.gb.read_wram(address)
}
fn frame(&mut self, mask: u8) {
self.gb.set_buttons(mask);
self.gb.run_frame().expect("a frame should complete");
self.ms += MS_PER_FRAME;
self.frames += 1;
let ms = self.ms;
let events = self.adapter.sample(&mut self.gb, ms);
let here = (self.byte(ram::wCurMap), self.byte(ram::wCurMapTileset));
self.payout_maps.extend(events.iter().map(|_| here));
self.payouts.extend(events);
assert!(self.frames < 200_000, "the walker is lost: {}", self.whereabouts());
}
fn whereabouts(&mut self) -> String {
format!(
"map {:#04x} at ({}, {}), battle {}, font {}",
self.byte(ram::wCurMap),
self.byte(ram::wXCoord),
self.byte(ram::wYCoord),
self.byte(ram::wIsInBattle),
self.byte(ram::wFontLoaded)
)
}
fn map(&mut self) -> u8 {
self.byte(ram::wCurMap)
}
fn at(&mut self) -> (u8, u8) {
(self.byte(ram::wXCoord), self.byte(ram::wYCoord))
}
/// Whatever is on screen that is not the fly's to walk through: a battle (A through it) or a
/// text box (B through it). Returns once the overworld is controllable again.
fn settle(&mut self) {
for tick in 0..20_000u32 {
let in_battle = self.byte(ram::wIsInBattle) != 0;
let open = self.byte(ram::wFontLoaded) & 1 != 0;
if !in_battle && !open && state::controllable(&mut self.gb) {
if self.byte(ram::wWalkCounter) == 0 {
return;
}
self.frame(buttons::NONE);
continue;
}
let press = if in_battle { buttons::A } else { buttons::B };
self.frame(if tick % 8 < 3 { press } else { buttons::NONE });
}
panic!("the screen never settled: {}", self.whereabouts());
}
/// Press `facing` until the player has moved one tile, the map has changed, or it is plain
/// that the step is refused (which turns the player to face that way).
fn step(&mut self, facing: Facing) {
let (map, from) = (self.map(), self.at());
for _ in 0..48 {
self.frame(mask(facing));
if self.map() != map || self.at() != from {
break;
}
if self.byte(ram::wIsInBattle) != 0 || self.byte(ram::wFontLoaded) & 1 != 0 {
break;
}
}
self.frame(buttons::NONE);
self.settle();
}
/// Breadth-first over the decoded map, people excluded, from where the player stands to the
/// nearest tile `goal` accepts; the first step of that route, or `None`.
fn route(&mut self, goal: &dyn Fn(u8, u8) -> bool) -> Option<Facing> {
let grid = state::map_grid(&mut self.gb).ok()?;
let people: Vec<(u8, u8)> =
state::npcs(&mut self.gb).iter().map(|npc| (npc.x, npc.y)).collect();
let (width, height) = (grid.width(), grid.height());
let start = self.at();
let mut first: Vec<Option<Facing>> = vec![None; usize::from(width) * usize::from(height)];
let mut seen = vec![false; first.len()];
let index = |x: u8, y: u8| usize::from(y) * usize::from(width) + usize::from(x);
let mut queue = VecDeque::from([start]);
seen[index(start.0, start.1)] = true;
while let Some((x, y)) = queue.pop_front() {
if (x, y) != start && goal(x, y) {
return first[index(x, y)];
}
for facing in FACINGS {
if grid.walled(x, y, facing) {
continue;
}
let (dx, dy) = delta(facing);
let (Ok(nx), Ok(ny)) =
(u8::try_from(i16::from(x) + dx), u8::try_from(i16::from(y) + dy))
else {
continue;
};
if nx >= width || ny >= height || seen[index(nx, ny)] {
continue;
}
if grid.walkable(nx, ny) != Walkable::Yes || people.contains(&(nx, ny)) {
continue;
}
seen[index(nx, ny)] = true;
first[index(nx, ny)] = if (x, y) == start { Some(facing) } else { first[index(x, y)] };
queue.push_back((nx, ny));
}
}
None
}
/// Walk until standing on a tile `goal` accepts, on this map.
fn walk_to(&mut self, goal: &dyn Fn(u8, u8) -> bool) {
let map = self.map();
for _ in 0..400 {
self.settle();
assert_eq!(self.map(), map, "the walk left the map: {}", self.whereabouts());
let (x, y) = self.at();
if goal(x, y) {
return;
}
let Some(facing) = self.route(goal) else {
// A grid refused on this frame, or a person in the way: let a frame go by.
self.frame(buttons::NONE);
continue;
};
self.step(facing);
}
let grid = state::map_grid(&mut self.gb);
let detail = match &grid {
Ok(grid) => {
let (x, y) = self.at();
let mut rows = String::new();
for ty in 0..grid.height().min(24) {
for tx in 0..grid.width() {
rows.push(if (tx, ty) == (x, y) {
'@'
} else {
match grid.walkable(tx, ty) {
Walkable::Yes => '.',
Walkable::No => '#',
Walkable::Unknown => '?',
}
});
}
rows.push('\n');
}
format!("reachable {}\n{rows}", grid.reachable_from(x, y))
}
Err(refusal) => format!("grid refused: {}", refusal.label()),
};
panic!("never reached the goal: {}; {detail}", self.whereabouts());
}
/// Walk to `(x, y)` and keep pressing `out` until the map changes.
fn leave_by(&mut self, x: u8, y: u8, out: Facing) {
let map = self.map();
self.walk_to(&|tx, ty| (tx, ty) == (x, y));
for _ in 0..8 {
self.step(out);
if self.map() != map {
// The warp's fade and the new map's first frames.
for _ in 0..60 {
self.frame(buttons::NONE);
}
self.settle();
return;
}
}
panic!("pressing {out:?} at ({x}, {y}) never left the map: {}", self.whereabouts());
}
/// Stand beside `(x, y)`, face it, press A once and let the conversation run to its end.
/// Returns the payouts the conversation produced.
fn press_a_at(&mut self, x: u8, y: u8) -> Vec<RewardEvent> {
self.walk_to(&|tx, ty| tx.abs_diff(x) + ty.abs_diff(y) == 1);
let (px, py) = self.at();
let facing = FACINGS
.into_iter()
.find(|facing| {
let (dx, dy) = delta(*facing);
i16::from(px) + dx == i16::from(x) && i16::from(py) + dy == i16::from(y)
})
.expect("a neighbour faces the target one way");
// Turn in place: the step is refused because the thing is in the way.
self.step(facing);
assert_eq!(self.at(), (px, py), "turning must not move the player");
self.press_a_here()
}
/// Press A where the player stands and faces, and let whatever it opens run to its end.
fn press_a_here(&mut self) -> Vec<RewardEvent> {
for _ in 0..4 {
self.frame(buttons::NONE);
}
let before = self.payouts.len();
for _ in 0..6 {
self.frame(buttons::A);
}
self.frame(buttons::NONE);
self.settle();
for _ in 0..8 {
self.frame(buttons::NONE);
}
self.payouts[before..].to_vec()
}
fn of_kind(&self, kind: &str) -> usize {
self.payouts.iter().filter(|event| event.kind == kind).count()
}
}
fn kinds(events: &[RewardEvent]) -> Vec<&'static str> {
events.iter().map(|event| event.kind).collect()
}
#[test]
fn a_conversation_indoors_and_an_item_ball_each_pay_exactly_once_on_the_cartridge() {
let Some(rom) = rom() else {
eprintln!("skipped: FLY_ROM is not set");
return;
};
let Some(checkpoint) = checkpoint() else {
eprintln!("skipped: no FLY_ENGAGE_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, &checkpoint);
if run.map() != PEWTER_CITY {
eprintln!("skipped: the checkpoint is on map {:#04x}, not Pewter City", run.map());
return;
}
let before = run.adapter.progress().counts;
// The restore's first sample: the v6 ledger holds no item keys, so this is the seed.
run.settle();
assert!(run.payouts.is_empty(), "the migration pays nothing: {:?}", run.payouts);
// Pewter City's south edge, onto Route 2.
let height = run.byte(ram::wCurMapHeight) * 2;
run.walk_to(&|_, y| y == height - 1);
let (x, y) = run.at();
run.leave_by(x, y, Facing::Down);
assert_eq!(run.map(), ROUTE_2, "{}", run.whereabouts());
// Route 2's door into the forest's north gate, `warp_event 3, 11`: the gate is south of
// the city, so its door is entered heading south, from the tile above it.
run.leave_by(3, 10, Facing::Down);
assert_eq!(run.map(), NORTH_GATE, "{}", run.whereabouts());
let tileset = run.byte(ram::wCurMapTileset);
assert!(engage::indoor(tileset), "the gate is a building (tileset {tileset})");
// The old man at (2, 5): one conversation, one payout, when the box closes.
let first = run.press_a_at(2, 5);
eprintln!("gate, first conversation: {first:?}");
let talks: Vec<&RewardEvent> =
first.iter().filter(|event| event.kind == catalog::kind::TALK).collect();
assert_eq!(talks.len(), 1, "one conversation, one payout: {first:?}");
assert_eq!(talks[0].value, 0.10);
assert!(talks[0].label.starts_with("TALKED TO #"), "{}", talks[0].label);
// The same man again, and again: not a farm.
for _ in 0..2 {
let again = run.press_a_at(2, 5);
assert!(
!again.iter().any(|event| event.kind == catalog::kind::TALK),
"a second conversation with the same person pays nothing: {again:?}"
);
}
// On through the gate, `warp_event 4, 7`, into the forest.
run.leave_by(4, 7, Facing::Down);
assert_eq!(run.map(), VIRIDIAN_FOREST, "{}", run.whereabouts());
assert!(!engage::indoor(run.byte(ram::wCurMapTileset)), "the forest is not a building");
// The Antidote ball at (25, 11). Save the game just before, to take it twice.
run.walk_to(&|tx, ty| tx.abs_diff(25) + ty.abs_diff(11) == 1);
let slot = run.gb.export_state().expect("an emulator state");
let first = run.press_a_at(25, 11);
eprintln!("forest, the ball: {first:?}");
let items: Vec<&RewardEvent> =
first.iter().filter(|event| event.kind == catalog::kind::ITEM).collect();
assert_eq!(items.len(), 1, "one pickup, one payout: {first:?}");
assert_eq!(items[0].value, 0.15);
assert_eq!(items[0].label, ANTIDOTE);
assert!(
!first.iter().any(|event| event.kind == catalog::kind::TALK),
"a ball is not a conversation, and the forest is not indoors"
);
// The ball is gone: pressing A at the empty tile pays nothing.
let empty = run.press_a_here();
assert!(!empty.iter().any(|event| event.kind == catalog::kind::ITEM), "{empty:?}");
// A rollback to the slot with the ball still there, the way the ratchet restores one, and
// the fly takes it again: the same item, and it does not pay twice.
run.gb.import_state(&slot).expect("the slot restores");
run.adapter.clear_transient();
run.frame(buttons::NONE);
run.settle();
let again = run.press_a_at(25, 11);
eprintln!("forest, the ball after a rollback: {again:?}");
assert!(
!again.iter().any(|event| event.kind == catalog::kind::ITEM),
"once per item for the run: {again:?}"
);
let after = run.adapter.progress().counts;
eprintln!(
"{:.1} brain minutes; talk {} -> {}, item {} -> {}; every payout (kind, (map, tileset)): {:?}",
run.ms / 60_000.0,
before[catalog::kind::TALK],
after[catalog::kind::TALK],
before[catalog::kind::ITEM],
after[catalog::kind::ITEM],
kinds(&run.payouts).iter().zip(&run.payout_maps).collect::<Vec<_>>()
);
// The gate's exits were stood beside and walked through, and none of them paid: an indoor
// exit pays nothing. What *does* show up under the gate's id is Route 2's door, the
// outdoor exit the fly took: for the thirty-odd frames of `PlayMapChangeSound` the cartridge
// has already written the new `wCurMap` while the header, the warp table and the tileset
// are still Route 2's, and the rule reads that frame as what it is -- an outdoor exit.
for (event, (map, tileset)) in run.payouts.iter().zip(&run.payout_maps) {
assert!(
!(event.kind == catalog::kind::BOUNDARY && engage::indoor(*tileset)),
"a boundary payout on an indoor map's own header (map {map}): {event:?}"
);
}
assert_eq!(run.of_kind(catalog::kind::TALK), 1);
assert_eq!(run.of_kind(catalog::kind::ITEM), 1);
assert_eq!(after[catalog::kind::TALK], before[catalog::kind::TALK] + 1);
assert_eq!(after[catalog::kind::ITEM], before[catalog::kind::ITEM] + 1);
}

View file

@ -42,6 +42,7 @@ use flybrain_gb::{
AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons, AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons,
}; };
use flysim::config::Config; use flysim::config::Config;
use flysim::frame::LegacyFrame;
use flysim::macros::{MacroLayer, macro_layer}; use flysim::macros::{MacroLayer, macro_layer};
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
@ -147,6 +148,8 @@ struct Run {
gb: Emulator, gb: Emulator,
adapter: PokemonRedReward, adapter: PokemonRedReward,
layer: MacroLayer, layer: MacroLayer,
/// The stream's frame (`flysim::frame::LegacyFrame`), behind the stub readout.
frame: LegacyFrame,
/// The readout under test: the shipping decoder, fed by hand. /// The readout under test: the shipping decoder, fed by hand.
decoder: PopulationDecoder, decoder: PopulationDecoder,
/// The macro channels, in the decoder's own order, for the rotation. /// The macro channels, in the decoder's own order, for the rotation.
@ -402,6 +405,7 @@ impl Run {
gb, gb,
adapter, adapter,
layer, layer,
frame: LegacyFrame::new(),
decoder, decoder,
channels, channels,
ms, ms,
@ -520,6 +524,7 @@ impl Run {
gb, gb,
adapter, adapter,
layer, layer,
frame: LegacyFrame::new(),
decoder, decoder,
channels, channels,
ms, ms,
@ -815,9 +820,15 @@ impl Run {
self.talk_on_pad = talk_bound; self.talk_on_pad = talk_bound;
let active = let active =
self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound)); self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound));
let (mask, started, blocked, done) = { let (started, blocked, done) = {
let ledger = AdapterLedger(&self.adapter); let decision = self.frame.execute(
let decision = self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger); Some(&mut self.layer),
&active,
0,
self.ms,
&mut self.gb,
&self.adapter,
);
let started: Vec<&'static str> = decision let started: Vec<&'static str> = decision
.events .events
.iter() .iter()
@ -840,7 +851,7 @@ impl Run {
}) })
.map(|event| event.name) .map(|event| event.name)
.collect(); .collect();
(decision.mask, started, blocked, done) (started, blocked, done)
}; };
// Row 54's own measure, taken before the starts below so that a macro that finishes and // Row 54's own measure, taken before the starts below so that a macro that finishes and
// another that starts on the same frame are not confused for one another. // another that starts on the same frame are not confused for one another.
@ -963,15 +974,10 @@ impl Run {
let (x, y) = self.tile(); let (x, y) = self.tile();
self.started_at = Some((self.map(), x, y)); self.started_at = Some((self.map(), x, y));
} }
self.gb.set_buttons(mask as u8);
self.gb.run_frame().expect("a frame should complete");
self.ms += MS_PER_FRAME; self.ms += MS_PER_FRAME;
let ms = self.ms; self.frame
self.adapter.sample(&mut self.gb, ms); .stub_advance(Some(&mut self.layer), &mut self.gb, &mut self.adapter, self.ms)
{ .expect("a frame should complete");
let ledger = AdapterLedger(&self.adapter);
let _ = self.layer.observe(&mut self.gb, &ledger, ms);
}
// Battle boundaries, after the frame: what a battle cost in macros, and whether it ended. // Battle boundaries, after the frame: what a battle cost in macros, and whether it ended.
let now_in_battle = self.in_battle() != 0; let now_in_battle = self.in_battle() != 0;
match (self.was_in_battle, now_in_battle) { match (self.was_in_battle, now_in_battle) {
@ -3290,3 +3296,513 @@ fn the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint() {
"the fly never went up the room past the doormat rows: highest row {highest_row:?}" "the fly never went up the room past the doormat rows: highest row {highest_row:?}"
); );
} }
fn row60_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_ROW60_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// Route 1, from the checkpoint taken mid-trap: Squirtle L5 (TACKLE, TAIL WHIP) against wild
/// Pidgey and Rattata.
///
/// **What was live** (2026-09-23, v0.5.5, rank 9 after the reset to milestone 1): `MOVE 2` 183
/// times and `MOVE 1` once, "Nothing happened!" on the screen, twenty-five brain minutes with no
/// reward. TAIL WHIP took the Pidgey's DEFENSE to where the cartridge refuses it (the stat at 1,
/// or the stage at -6) and the pad kept dealing it beside TACKLE; every battle ended with Squirtle
/// fainted and the fly back home (`infra/docs/macros-traps.md` row 60). The checkpoint itself is
/// the frame Squirtle fainted.
///
/// The driver is the real palette with the live readout's measured favourite: `MOVE 2` whenever
/// the pad deals it, otherwise a uniform choice per hold -- a harness choice, not the fly's. The
/// claims:
///
/// - **no `MOVE n` is dealt for a move the cartridge answers with nothing while another move
/// would do something**, on any frame of the fly's own turn, and the run does reach that state;
/// - **a wild battle is won** inside the budget, which the base never does with this driver.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_ROW60_CHECKPOINT=.local/checkpoints/release-rank9-row60.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture tail_whip
/// ```
#[test]
fn tail_whip_at_its_limit_is_not_dealt_and_a_route_one_battle_is_won() {
use flybrain_gb::pokemon_red::macros::PokemonPalette;
use flybrain_gb::pokemon_red::state;
use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::{MacroPalette, MemoryReader, Started};
let rom = skip_without_rom!();
let Some(checkpoint) = row60_checkpoint() else {
eprintln!("skipped: no FLY_ROW60_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert_ne!(run.gb.read8(ram::wIsInBattle), 0, "the checkpoint is inside the Route 1 battle");
let budget = 72_000u32;
let hold_frames = 48u32;
let mut palette = PokemonPalette::new(SEED);
let mut rng = 20_260_923u32;
let mut running = false;
let mut since_decision = hold_frames;
let mut ms = run.ms;
let mut at_a_limit = 0u32;
let mut dealt_without_effect = 0u32;
let mut starts = std::collections::BTreeMap::<&'static str, u32>::new();
// (frames, the enemy fainted, the fly's Pokemon standing at the end)
let mut battles: Vec<(u32, bool, bool)> = Vec::new();
let mut current: Option<(u32, bool, bool)> = Some((0, false, false));
for _ in 0..budget {
palette.clock(ms);
let observed = {
let ledger = AdapterLedger(&run.adapter);
palette.observe(&mut run.gb, &ledger)
};
let names: Vec<&str> = observed.bindings.iter().map(|binding| binding.name).collect();
if let Some(battle) = state::battle(&mut run.gb)
&& battle.own_turn
&& let Some(own) = battle.own
{
let mut nothing = [false; 4];
let mut useful = [false; 4];
for (slot, entry) in own.moves.iter().enumerate() {
if let Some(entry) = entry.filter(|entry| entry.id != 0) {
nothing[slot] = state::move_without_effect(&mut run.gb, entry.id) == Some(true);
useful[slot] = entry.pp > 0 && !nothing[slot];
}
}
if nothing.iter().any(|flag| *flag) && useful.iter().any(|flag| *flag) {
at_a_limit += 1;
for (slot, name) in ["MOVE 1", "MOVE 2", "MOVE 3", "MOVE 4"].iter().enumerate() {
if nothing[slot] && names.contains(name) {
dealt_without_effect += 1;
}
}
}
}
let mut mask = 0u8;
{
let ledger = AdapterLedger(&run.adapter);
if running {
match palette.step(&mut run.gb, &ledger) {
Some(held) => mask = held,
None => running = false,
}
} else if since_decision >= hold_frames && !observed.bindings.is_empty() {
since_decision = 0;
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
let binding = observed
.bindings
.iter()
.find(|binding| binding.name == "MOVE 2")
.unwrap_or(&observed.bindings[rng as usize % observed.bindings.len()]);
if let Started::Running(_) = palette.start(binding.slot, &mut run.gb, &ledger) {
*starts.entry(binding.name).or_default() += 1;
running = true;
match palette.step(&mut run.gb, &ledger) {
Some(held) => mask = held,
None => running = false,
}
}
}
}
since_decision += 1;
run.gb.set_buttons(mask);
run.gb.run_frame().expect("a frame should complete");
ms += MS_PER_FRAME;
run.adapter.sample(&mut run.gb, ms);
let fighting = run.gb.read8(ram::wIsInBattle) != 0;
match (&mut current, fighting) {
(Some((frames, fainted, standing)), true) => {
*frames += 1;
if let Some(battle) = state::battle(&mut run.gb) {
*fainted |= battle.enemy.is_some_and(|enemy| enemy.hp == 0);
if let Some(own) = battle.own {
*standing = own.hp > 0;
}
}
}
(None, true) => current = Some((1, false, true)),
(Some(battle), false) => {
battles.push(*battle);
current = None;
}
(None, false) => {}
}
}
let won = battles.iter().filter(|battle| battle.1 && battle.2).count();
eprintln!(
"{:.1} brain minutes: battles ended {} (won {won}), (frames, enemy fainted, standing) \
{battles:?}, still in one {current:?}; own-turn frames with a move without effect beside a \
useful one {at_a_limit}, the refused move dealt on {dealt_without_effect}; starts \
{starts:?}; rank {}",
(ms - run.ms) / 60_000.0,
battles.len(),
run.adapter.progress().rank,
);
assert!(at_a_limit > 0, "the run never reached the trap's own state");
assert_eq!(
dealt_without_effect, 0,
"a MOVE n the cartridge answers with nothing was dealt beside one it does not"
);
assert!(won > 0, "no wild battle was won in {budget} frames: {battles:?}");
}
const ROUTE_4: u32 = 0x0f;
const MT_MOON_1F: u32 = 0x3b;
/// A rung-11 checkpoint just after the Boulder Badge, or `None` to skip.
///
/// Row 59's is the route survey's own: `examples/scene_probe.rs` driven from the row-58 checkpoint
/// with `FLY_PROBE_CATCH=route FLY_PROBE_PREFER="GO OBJECTIVE,TALK"` until rung 11, written by
/// `FLY_PROBE_SAVE_RANK=11` on the first safe overworld frame -- in the gym, beside BROCK.
fn badge_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_BADGE_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// From the badge: the fly takes the road to Mt. Moon instead of walking the Pewter end of Route 3.
///
/// **What the row-58 review measured** (the route survey carried past the badge, 2026-09-23):
/// from about frame 68,000, Pewter City (39, 17) and Route 3 (0, 9) in a ring, `GO OBJECTIVE`
/// done on Route 3 538 times and `GO ROUTE` done on Pewter 537. The live fly was due to reach
/// the same state as soon as it earned the badge.
///
/// **What was wrong** (`infra/docs/macros-traps.md` row 59), two things stacked:
///
/// - the map graph had Route 4 east of Route 3 and a Mt. Moon door on Route 3. The headers put
/// Route 4 north of Route 3 and both of Mt. Moon's doors on Route 4, so Route 3's north edge,
/// the one road to the mountain, named no map and was nobody's first hop; and Route 4's two
/// sides, which the mountain cuts apart, were one node;
/// - Route 3's first trainer closed his challenge onto five frames of plain overworld before the
/// battle was decided, and the walk he interrupted was written as a refusal on the first of
/// them: (11, 6), the one gap between the road's west end and the rest of it, walled for the
/// session.
///
/// The claims, none of them about which button the fly presses:
///
/// - the fly is on **Route 4** and at **Mt. Moon's door** (map `0x3b`) inside the budget;
/// - Pewter City and Route 3 are **not a ring**: under forty crossings between them, against
/// over a thousand on the base.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_BADGE_CHECKPOINT=.local/checkpoints/survey-rank11-row59.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture the_road_to_mt_moon
/// ```
#[test]
fn the_road_to_mt_moon_is_not_a_ring_at_the_pewter_end_from_the_badge_checkpoint() {
let rom = skip_without_rom!();
let Some(checkpoint) = badge_checkpoint() else {
eprintln!("skipped: no FLY_BADGE_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert!(run.adapter.progress().rank >= 11, "the checkpoint is past the badge");
let mut crossings = 0u32;
let mut on_route_4: Option<u32> = None;
let mut at_mt_moon: Option<u32> = None;
let mut previous = run.map();
for frame in 0..288_000u32 {
run.frame();
let map = run.map();
if map != previous {
if (previous == PEWTER_CITY && map == ROUTE_3) || (previous == ROUTE_3 && map == PEWTER_CITY) {
crossings += 1;
}
previous = map;
}
if on_route_4.is_none() && map == ROUTE_4 {
on_route_4 = Some(frame);
}
if at_mt_moon.is_none() && map == MT_MOON_1F {
at_mt_moon = Some(frame);
break;
}
}
let progress = run.adapter.progress();
eprintln!(
"{:.1} brain minutes: Route 4 at {on_route_4:?}, Mt. Moon at {at_mt_moon:?}, Pewter / Route 3 \
crossings {crossings}, rank {} ({}), route {:?}, macros {:?}",
run.ms / 60_000.0,
progress.rank,
progress.rank_label,
run.route,
run.started
);
assert!(crossings < 40, "{crossings} crossings between Pewter City and Route 3: {:?}", run.started);
assert!(on_route_4.is_some(), "the fly never reached Route 4: {:?}", run.route);
assert!(at_mt_moon.is_some(), "the fly never reached Mt. Moon's door: {:?}", run.route);
}
/// Route 4's two sides, read off the cartridge: the warp table the geography's pieces are keyed
/// by, and the piece the fly is standing in on arrival from Route 3.
///
/// The table's door indices and tiles are the disassembly's (`data/maps/objects/Route4.asm`); this
/// is the same three warps read from `wWarpEntries` on the loaded map, and the piece named by the
/// decoded grid ([`geography::region_on`]) rather than by the doors.
#[test]
fn route_4s_doors_and_sides_are_the_cartridges_from_the_badge_checkpoint() {
use flybrain_gb::pokemon_red::macros::geography::{self, Region};
let rom = skip_without_rom!();
let Some(checkpoint) = badge_checkpoint() else {
eprintln!("skipped: no FLY_BADGE_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
let mut read = None;
for _ in 0..216_000u32 {
run.frame();
if run.map() != ROUTE_4 {
continue;
}
let Some(player) = flybrain_gb::pokemon_red::state::player(&mut run.gb) else { continue };
let Ok(grid) = flybrain_gb::pokemon_red::state::map_grid(&mut run.gb) else { continue };
if u32::from(player.map) != ROUTE_4 || grid.map() != player.map {
continue;
}
let warps = flybrain_gb::pokemon_red::state::warps(&mut run.gb);
read = Some((player, warps, geography::region_on(player.map, player.x, player.y, Some(&grid))));
break;
}
let Some((player, warps, region)) = read else {
panic!("the fly never stood on Route 4 with its grid decoded: {:?}", run.route);
};
eprintln!("on Route 4 at ({}, {}), piece {region:?}, warps {warps:?}", player.x, player.y);
let doors: Vec<(u8, u8, u8)> =
warps.iter().map(|warp| (warp.x, warp.y, warp.destination_map)).collect();
assert_eq!(
doors,
vec![(11, 5, 0x44), (18, 5, 0x3b), (24, 5, 0x3c)],
"the Pokécenter, the cave mouth and B1F's exit, in the table's order"
);
assert_eq!(region, Region::piece(0x0f, 0), "arrived from Route 3, on the cave mouth's side");
}
const MT_MOON_POKECENTER: u32 = 0x44;
/// The live checkpoint from inside row 59's ring (Route 4, rank 12 MT. MOON), or `None` to skip.
fn mt_moon_live_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_MT_MOON_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// From the live checkpoint taken inside the ring: the fly goes into Mt. Moon instead of in and
/// out of the Pokécenter beside it.
///
/// **What was live** (2026-09-23 22:20 UTC, v0.6.0, rank 12 MT. MOON, the objective Cerulean City):
/// on Route 4, per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103, five distinct
/// macros and two new tiles. Route 4 was one node on the map graph with Cerulean off its east
/// edge, which Mt. Moon cuts off from the cave mouth's side, so the objective aimed at ground no
/// walk could reach and the Pokécenter door was the way out that was left. The route survey from
/// this checkpoint on `main` walks Route 4 and the Pokécenter 930 times in 72,000 frames.
///
/// The claims, none of them about which button the fly presses, over twenty brain minutes on the
/// stub rotation: the fly is **inside Mt. Moon** (map `0x3b`), and Route 4's west side and its two
/// doors, the Pokécenter and the cave mouth, are **not a ring**: under twenty-five crossings in all.
/// The base makes 56 (18 through the Pokécenter's door, 38 through the cave's); the rotation walks
/// into the cave on the base too, and back out, and in, because from 1F the graph's Cerulean was
/// Route 4's east edge beside it.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_MT_MOON_CHECKPOINT=.local/checkpoints/release-rank12-route4.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture the_fly_goes_into_mt_moon
/// ```
#[test]
fn the_fly_goes_into_mt_moon_from_the_live_route_4_checkpoint() {
let rom = skip_without_rom!();
let Some(checkpoint) = mt_moon_live_checkpoint() else {
eprintln!("skipped: no FLY_MT_MOON_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert!(run.adapter.progress().rank >= 12, "the checkpoint is the rung the ring was on");
let mut crossings = 0u32;
let mut in_mt_moon: Option<u32> = None;
let mut on_route_4 = 0u32;
let mut previous = run.map();
for frame in 0..72_000u32 {
run.frame();
let map = run.map();
if map != previous {
let door = |other: u32| other == MT_MOON_POKECENTER || other == MT_MOON_1F;
if (previous == ROUTE_4 && door(map)) || (door(previous) && map == ROUTE_4) {
crossings += 1;
}
previous = map;
}
if in_mt_moon.is_none() && map == MT_MOON_1F {
in_mt_moon = Some(frame);
}
if map == ROUTE_4 {
on_route_4 += 1;
}
}
eprintln!(
"{:.1} brain minutes: Mt. Moon at {in_mt_moon:?}, crossings of Route 4's west doors {crossings}, \
frames on Route 4 {on_route_4}, rank {}, route {:?}, macros {:?}",
run.ms / 60_000.0,
run.adapter.progress().rank,
run.route,
run.started
);
assert!(crossings < 25, "{crossings} crossings of Route 4's west doors: {:?}", run.started);
assert!(in_mt_moon.is_some(), "the fly never went into Mt. Moon: {:?}", run.route);
}
fn row61_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_ROW61_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// Viridian Forest's south gate, from the checkpoint pulled during the ring.
///
/// **What was live** (2026-09-23, v0.5.5, rung 9 after the reset to milestone 1): for twenty
/// minutes `GO OBJECTIVE` into the forest's south gate and `GO OUT` straight back onto Route 2,
/// `GO WARP` back from the forest, `GO OBJECTIVE blocked` in the forest, no reward
/// (`infra/docs/macros-traps.md` row 61). The forest's only road to its north gate is a two-wide
/// corridor at x = 1-2 with a Bug Catcher standing on (2, 18) facing west. A walk up the corridor
/// steps onto (1, 18) and the trainer takes the joypad. His "!" bubble (about sixty frames, before
/// `wJoyIgnore` is set) and the five frames after his text (before `wCurOpponent` is) read as the
/// fly's own overworld: the push-back the walk had earned was written on the first frame after the
/// text, (1, 18) was walled for the session, and every later walk to the north gate had no road.
/// Row 59's thirty-frame settle alone keeps the wall out; this row reads `BIT_TRAINER_BATTLE`.
///
/// The driver is the route survey's: the real palette, one uniform choice per hold, xorshift
/// seeded 7 -- a harness choice, not the fly's. Before rows 59 and 61 it never reached the north
/// gate in 72,000 frames. The claims:
///
/// - **no button is offered on an overworld frame inside a trainer's challenge**
/// (`wStatusFlags7` bit 3 set), and the run does reach such frames;
/// - **no tile of the forest is walled by a trainer's challenge**: (1, 18) never enters the pushed
/// ledger;
/// - **the fly goes through the north gate onto Route 2 and into Pewter City** inside the budget.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 \
/// FLY_ROW61_CHECKPOINT=<the rank-9 checkpoint pulled during the ring, under .local/checkpoints> \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture forests_north_gate
/// ```
#[test]
fn a_trainers_challenge_does_not_wall_the_road_to_the_forests_north_gate() {
use flybrain_gb::pokemon_red::macros::PokemonPalette;
use flybrain_gb::pokemon_red::macros::cartridge::{PushedLedger, Tile};
use flybrain_gb::pokemon_red::state;
use flybrain_gb::{MacroPalette, MemoryReader, Started};
const FOREST: u8 = 0x33;
const NORTH_GATE: u8 = 0x2f;
const ROUTE_2: u8 = 0x0d;
const PEWTER: u8 = 0x02;
let rom = skip_without_rom!();
let Some(checkpoint) = row61_checkpoint() else {
eprintln!("skipped: no FLY_ROW61_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert_eq!(run.map(), 0x32, "the checkpoint is the forest's south gate");
let budget = 40_000u32;
let hold_frames = 48u32;
let mut palette = PokemonPalette::new(SEED);
let mut rng = 7u32;
let mut running = false;
let mut since_decision = hold_frames;
let mut ms = run.ms;
let mut arrivals: Vec<(u32, u8)> = Vec::new();
let mut last = None;
let mut walled_at: Option<u32> = None;
// Overworld frames (the shared reading) inside a trainer's challenge, and those that dealt a pad.
let mut engaged = 0u32;
let mut engaged_dealt = 0u32;
for frame in 0..budget {
palette.clock(ms);
let observed = {
let ledger = AdapterLedger(&run.adapter);
palette.observe(&mut run.gb, &ledger)
};
// `wStatusFlags7` bit 3, `BIT_TRAINER_BATTLE`, read here rather than through the seam.
if run.gb.read8(flybrain_gb::pokemon_red::symbols::ram::wStatusFlags7) & (1 << 3) != 0
&& flybrain_gb::pokemon_red::scene::detect(&mut run.gb)
== flybrain_gb::pokemon_red::macros::state::Scene::Overworld
{
engaged += 1;
if !observed.bindings.is_empty() {
engaged_dealt += 1;
}
}
let mut mask = 0u8;
{
let ledger = AdapterLedger(&run.adapter);
if running {
match palette.step(&mut run.gb, &ledger) {
Some(held) => mask = held,
None => running = false,
}
} else if since_decision >= hold_frames && !observed.bindings.is_empty() {
since_decision = 0;
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
let binding = &observed.bindings[rng as usize % observed.bindings.len()];
if let Started::Running(_) = palette.start(binding.slot, &mut run.gb, &ledger) {
running = true;
match palette.step(&mut run.gb, &ledger) {
Some(held) => mask = held,
None => running = false,
}
}
}
}
let _ = palette.take_finished();
since_decision += 1;
run.gb.set_buttons(mask);
run.gb.run_frame().expect("a frame should complete");
ms += MS_PER_FRAME;
run.adapter.sample(&mut run.gb, ms);
if walled_at.is_none() && palette.fences().0.pushed(FOREST, Tile::new(1, 18)) {
walled_at = Some(frame);
}
if let Some(player) = state::player(&mut run.gb)
&& last != Some(player.map)
{
last = Some(player.map);
arrivals.push((frame, player.map));
}
}
let first = |map: u8| arrivals.iter().find(|(_, at)| *at == map).map(|(frame, _)| *frame);
let north_gate = first(NORTH_GATE);
let route_2_north = north_gate
.and_then(|gate| arrivals.iter().find(|(frame, map)| *frame > gate && *map == ROUTE_2))
.map(|(frame, _)| *frame);
let pewter = first(PEWTER);
eprintln!(
"{:.1} brain minutes: challenge overworld frames {engaged}, a pad dealt on {engaged_dealt}; \
(1, 18) walled at {walled_at:?}; north gate {north_gate:?}, Route 2 \
after it {route_2_north:?}, Pewter City {pewter:?}; pushed {:?}; rank {}; arrivals {}",
(ms - run.ms) / 60_000.0,
palette.fences().0,
run.adapter.progress().rank,
arrivals.len(),
);
assert!(engaged > 0, "the run never reached a trainer's challenge");
assert_eq!(engaged_dealt, 0, "a pad was dealt inside a trainer's challenge");
assert_eq!(walled_at, None, "a trainer's challenge walled the forest's corridor");
assert!(north_gate.is_some(), "the fly never reached the forest's north gate: {arrivals:?}");
assert!(route_2_north.is_some(), "nor Route 2 through it: {arrivals:?}");
assert!(pewter.is_some(), "nor Pewter City: {arrivals:?}");
}

View file

@ -209,6 +209,22 @@ EXTRA_RAM = (
# services/flysim/tools/resolve_wram.py is the second reading of it, from ram/wram.asm at # services/flysim/tools/resolve_wram.py is the second reading of it, from ram/wram.asm at
# this commit, bracketed by wFontLoaded and wForcePlayerToChooseMon. # this commit, bracketed by wFontLoaded and wForcePlayerToChooseMon.
'wCapturedMonSpecies', 'wCapturedMonSpecies',
# The engagement rewards (`docs/rewards-learning.md`, the operator 2026-09-23).
# `talk`: DisplayTextID copies its argument -- the sprite slot, or a sign's text id --
# into wSpriteIndex before it looks the text up, and the overworld's A press only
# reaches it while wWalkCounter is zero. `item`: an item ball is a sprite whose
# wMapSpriteExtraData entry is (item id, 0) -- LoadMapHeader writes that shape for
# an ITEM-flagged object_event and no other -- found in the map's
# wToggleableObjectList; PickUpItem hides it by setting its global bit in
# wToggleableObjectFlags, and FoundHiddenItemText sets the hidden item's bit in
# wObtainedHiddenItemsFlags, each only after GiveItem succeeded.
# services/flysim/tools/resolve_wram.py is the second reading of all six.
'wSpriteIndex',
'wWalkCounter',
'wMapSpriteExtraData',
'wToggleableObjectFlags',
'wToggleableObjectList',
'wObtainedHiddenItemsFlags',
) )

View file

@ -57,6 +57,38 @@ WANTED = {
# this one caught", and the only signal that needs no second rule to tell a # this one caught", and the only signal that needs no second rule to tell a
# catch apart from a gift, a trade or an evolution. # catch apart from a gift, a trade or an evolution.
'wCapturedMonSpecies': 'the species a ball just caught, 0 for none', 'wCapturedMonSpecies': 'the species a ball just caught, 0 for none',
# The engagement rewards (`docs/rewards-learning.md`, the operator 2026-09-23).
# DisplayTextID stores its argument here -- a sprite slot, or a sign's text id --
# before it looks the text up, so it names what a conversation is with.
'wSpriteIndex': "DisplayTextID's argument, a sprite slot or a text id",
# Non-zero for the frames of a step; the overworld only reads an A press at zero.
'wWalkCounter': 'frames left in the step the player is taking',
# Two bytes per sprite slot: (item id, 0) for an ITEM-flagged object_event,
# (trainer class, trainer number) for a TRAINER one, zeroes otherwise.
'wMapSpriteExtraData': 'per sprite slot: item id, or trainer class and number',
# One bit per global toggleable object; PickUpItem's HideObject sets an item
# ball's bit once GiveItem has succeeded.
'wToggleableObjectFlags': 'global hidden bits of every toggleable object',
# The current map's (sprite slot, global toggleable index) pairs, $ff-terminated.
'wToggleableObjectList': "this map's toggleable sprites and their global indices",
# One bit per hidden item, set by FoundHiddenItemText once GiveItem succeeded.
'wObtainedHiddenItemsFlags': 'hidden items already found',
# Row 60 (`docs/design/macros.md` 12.23): a move the cartridge will answer
# with "Nothing happened!" is not dealt. StatModifierUpEffect and
# StatModifierDownEffect (engine/battle/effects.asm) refuse on the stage byte
# (1 is -6, 7 normal, 13 is +6) and on the modified stat itself (1 or 999);
# SleepEffect, PoisonEffect and ParalyzeEffect on the target's status byte and
# type. Each is the battle_struct field or stage array those routines read.
'wPlayerMonStatMods': "the active Pokemon's six stat stages, 7 is normal",
'wEnemyMonStatMods': "the enemy's six stat stages, 7 is normal",
'wEnemyMonStatus': "the enemy's status condition byte",
'wEnemyMonType1': "the enemy's first type (wEnemyMonType2 follows it)",
'wEnemyMonAttack': "the enemy's modified Attack, Defense, Speed, Special",
'wBattleMonAttack': "the active Pokemon's modified Attack, Defense, Speed, Special",
# Mist and a substitute turn a stat-lowering move away, a substitute a
# poisoning one, and a target that must recharge is put to sleep whatever
# its status (MoveHitTest, CheckTargetSubstitute, SleepEffect).
'wEnemyBattleStatus2': "the enemy's Mist, substitute and recharge bits",
} }
@ -76,9 +108,33 @@ WANTED = {
COUNTED = { COUNTED = {
'NUM_HMS': ('constants/item_constants.asm', r'^\s*add_hm\s+\w+'), 'NUM_HMS': ('constants/item_constants.asm', r'^\s*add_hm\s+\w+'),
'NUM_TMS': ('constants/item_constants.asm', r'^\s*add_tm\s+\w+'), 'NUM_TMS': ('constants/item_constants.asm', r'^\s*add_tm\s+\w+'),
# The engagement rewards need two more, for the same reason: each is an
# `EQU const_value` over an enumeration, and each is the size of a declaration
# the cursor has to cross. `ds NUM_STATS` sits between wFontLoaded and
# wTrainerClass, the bracket of wWalkCounter; `flag_array NUM_CITY_MAPS` sits
# between wNumHoFTeams and wStatusFlags5, the bracket of the toggleable-object
# and hidden-item flags. Both are counted only up to the line that defines
# them, which is where `const_value` is read.
'NUM_STATS': (
'constants/battle_constants.asm', r'^\s*const\s+STAT_\w+', r'^DEF NUM_STATS\b'
),
'NUM_CITY_MAPS': (
'constants/map_constants.asm', r'^\s*map_const\s+\w+', r'^DEF NUM_CITY_MAPS\b'
),
} }
def count_in(root: Path, path: str, pattern: str, stop: str | None = None) -> int:
"""Lines of `path` matching `pattern`, up to the first line matching `stop`."""
text = (root / path).read_text()
if stop is not None:
match = re.search(stop, text, re.M)
if match is None:
raise SystemExit(f'{path}: no line matches {stop}')
text = text[: match.start()]
return len(re.findall(pattern, text, re.M))
def pinned(text: str) -> dict[str, int]: def pinned(text: str) -> dict[str, int]:
"""Every address `symbols.rs` carries today, by symbol name.""" """Every address `symbols.rs` carries today, by symbol name."""
return { return {
@ -95,19 +151,20 @@ def constants(root: Path) -> dict[str, int]:
BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left
out, which kills the cursor at any declaration that uses it. out, which kills the cursor at any declaration that uses it.
""" """
counted = { counted = {name: count_in(root, *spec) for name, spec in COUNTED.items()}
name: len(re.findall(pattern, (root / path).read_text(), re.M))
for name, (path, pattern) in COUNTED.items()
}
pending: dict[str, str] = {} pending: dict[str, str] = {}
sources = sorted((root / 'constants').glob('*.asm')) + sorted( sources = sorted((root / 'constants').glob('*.asm')) + sorted(
(root / 'constants').glob('*.inc') (root / 'constants').glob('*.inc')
) )
for path in sources: for path in sources:
text = path.read_text()
for name, value in re.findall( for name, value in re.findall(
r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', text, re.M
): ):
pending.setdefault(name, value.strip()) pending.setdefault(name, value.strip())
# A name counted by `const` or `rb` is an expression over the running
# counter at its own line, so it overrides the raw `EQU const_value - 1`.
pending.update(enumerated(text))
out: dict[str, int] = dict(counted) out: dict[str, int] = dict(counted)
while pending: while pending:
progressed = False progressed = False
@ -129,6 +186,91 @@ def constants(root: Path) -> dict[str, int]:
return out return out
def enumerated(text: str) -> dict[str, str]:
"""The names one constants file defines by counting, as expressions.
rgbasm keeps two running counters the decomp enumerates with: `const_value`
(`const_def`, `const`, `const_skip`, `const_next` in macros/const.asm) and
`_RS` (`rsreset`, `rsset`, `DEF NAME rb/rw n`, `rb_skip`). This follows both
in file order and writes each name down as the *expression* the counter held
at its line, never as a number: [`constants`]' passes evaluate it with the
rest, so a count over a constant this tool cannot resolve stays unresolved
rather than becoming a guess. A counter form it does not know (a
`shift_const`, a non-literal step) kills that counter until the next reset.
"""
out: dict[str, str] = {}
value: str | None = None
step = '1'
rs: str | None = None
for raw in text.splitlines():
line = raw.split(';')[0].strip()
if not line:
continue
match = re.fullmatch(r'const_def(?:\s+([^,]+?))?(?:\s*,\s*(.+))?', line)
if match:
value = f'({match.group(1) or "0"})'
step = f'({match.group(2) or "1"})'
continue
match = re.fullmatch(r'(?:const|const_export)\s+(\w+)', line)
if match:
if value is not None:
out[match.group(1)] = value
value = f'({value} + {step})'
continue
match = re.fullmatch(r'const_skip(?:\s+(.+))?', line)
if match:
if value is not None:
value = f'({value} + {step} * ({match.group(1) or "1"}))'
continue
match = re.fullmatch(r'const_next\s+(.+)', line)
if match:
value = f'({match.group(1)})'
continue
if line.startswith(('shift_const', 'dw_const')):
value = None
continue
if line == 'rsreset':
rs = '(0)'
continue
match = re.fullmatch(r'rsset\s+(.+)', line)
if match:
rs = f'({match.group(1)})' if '_RS' not in match.group(1) else None
continue
match = re.fullmatch(r'(rb|rw)_skip(?:\s+(.+))?', line)
if match:
if rs is not None:
unit = 1 if match.group(1) == 'rb' else 2
rs = f'({rs} + {unit} * ({match.group(2) or "1"}))'
continue
match = re.fullmatch(r'(?:DEF|def)\s+(\w+)\s+(rb|rw)(?:\s+(.+))?', line)
if match:
if rs is not None:
out[match.group(1)] = rs
unit = 1 if match.group(2) == 'rb' else 2
rs = f'({rs} + {unit} * ({match.group(3) or "1"}))'
continue
match = re.fullmatch(r'(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+(.+)', line)
if match is None:
# Anything else may be a macro that moves a counter this does not
# follow (`add_tm` advances `const_value`), so both stop here.
if not line.startswith(('ASSERT', 'assert', 'EXPORT', 'export')):
value = None
rs = None
continue
if re.search(r'\b(?:_RS|const_value)\b', match.group(2)):
expression = match.group(2)
if '_RS' in expression:
if rs is None:
continue
expression = re.sub(r'\b_RS\b', rs, expression)
if 'const_value' in expression:
if value is None:
continue
expression = re.sub(r'\bconst_value\b', value, expression)
out[match.group(1)] = expression
return out
def number(token: str) -> int: def number(token: str) -> int:
token = token.strip() token = token.strip()
if token.startswith('$'): if token.startswith('$'):
@ -176,16 +318,27 @@ def size_of(expression: str, known: dict[str, int]) -> int:
return value * scale return value * scale
def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]: def macro_sizes(
"""Sizes of the RAM struct macros, counted from their own declarations.""" root: Path, known: dict[str, int], fields: dict[str, list[tuple[str, int]]] | None = None
) -> dict[str, int]:
"""Sizes of the RAM struct macros, counted from their own declarations.
With `fields`, also each macro's `\\1Name::` field labels and their offsets,
for a macro whose whole body was sized: `battle_struct wEnemyMon` declares
`wEnemyMonStatus` at the offset its own lines put it.
"""
out: dict[str, int] = {} out: dict[str, int] = {}
for path in sorted((root / 'macros').glob('*.asm')): for path in sorted((root / 'macros').glob('*.asm')):
text = path.read_text() text = path.read_text()
for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S): for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S):
name, body = match.group(1), match.group(2) name, body = match.group(1), match.group(2)
total = 0 total = 0
offsets: list[tuple[str, int]] = []
for line in body.splitlines(): for line in body.splitlines():
line = line.split(';')[0].strip() line = line.split(';')[0].strip()
field = re.match(r'^\\1(\w+)::', line)
if field is not None and total is not None:
offsets.append((field.group(1), total))
# A struct macro labels each field with its argument # A struct macro labels each field with its argument
# (`\\1YCoord:: db`), so the label is stripped and the # (`\\1YCoord:: db`), so the label is stripped and the
# declaration after it is what reserves the bytes. # declaration after it is what reserves the bytes.
@ -209,6 +362,8 @@ def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]:
break break
if total is not None: if total is not None:
out[name] = total out[name] = total
if fields is not None:
fields[name] = offsets
return out return out
@ -216,7 +371,8 @@ def walk(
root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False
) -> tuple[dict[str, int], list[str], int]: ) -> tuple[dict[str, int], list[str], int]:
"""Resolve every symbol of wram.asm the anchored cursor can reach exactly.""" """Resolve every symbol of wram.asm the anchored cursor can reach exactly."""
macros = macro_sizes(root, known) fields: dict[str, list[tuple[str, int]]] = {}
macros = macro_sizes(root, known, fields)
lines = (root / 'ram/wram.asm').read_text().splitlines() lines = (root / 'ram/wram.asm').read_text().splitlines()
cursor: int | None = None cursor: int | None = None
resolved: dict[str, int] = {} resolved: dict[str, int] = {}
@ -333,6 +489,21 @@ def walk(
continue continue
if line.endswith('::') or re.fullmatch(r'\.\w+', line): if line.endswith('::') or re.fullmatch(r'\.\w+', line):
continue continue
# A struct macro's own field labels, at the offsets its body puts them:
# held back like any other label until the next pinned address agrees.
invocation = re.fullmatch(r'(\w+)\s+(w\w+)', line)
if invocation is not None and cursor is not None:
for field, offset in fields.get(invocation.group(1), []):
name = invocation.group(2) + field
if name not in table:
pending_run[name] = cursor + offset
elif table[name] != cursor + offset:
problems.append(
f'{name}: wram.asm gives ${cursor + offset:04x}, '
f'symbols.rs pins ${table[name]:04x}'
)
else:
checked += 1
try: try:
if cursor is not None: if cursor is not None:
cursor += declaration_size(line, known, macros) cursor += declaration_size(line, known, macros)