Compare commits

...

26 commits
v0.6.0 ... 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
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
29 changed files with 3575 additions and 1219 deletions

View file

@ -1539,6 +1539,117 @@ Nothing is ranked, weighted or pressed for the fly: a button leaves the pad whil
anything and comes back when it can (a new battle resets the stages). The decoder, the reward 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. 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

@ -850,3 +850,31 @@ median; a two-fly transition near 10 to 12 ms at the median in every execution m
beside one that works, as a move out of PP is not. ROM test: 2,657 of 2,657 own-turn frames 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 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. 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

@ -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 \
@ -2842,3 +2851,203 @@ of the brain, which is the deviation.
648 bytes, sha256 `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68` 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 on `main` after the engagement rewards (adapter v7). Decoder, reward catalog, adapter version
and roles untouched. and roles untouched.
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`**, byte-identical to the
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

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

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

@ -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.
const SPLIT: &[Split] = &[Split {
map: maps::ROUTE_2, map: maps::ROUTE_2,
north_door: 11, pieces: &[
south_door: 43, Piece {
north: &[maps::PEWTER_CITY, maps::VIRIDIAN_FOREST_NORTH_GATE], doors: &[(1, 3, 11)],
south: &[maps::VIRIDIAN_CITY, maps::VIRIDIAN_FOREST_SOUTH_GATE], next: &[
}]; Region::whole(maps::PEWTER_CITY),
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);
} }

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

@ -854,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;
for _ in 0..super::executor::PUSH_SETTLE_FRAMES {
machine.observe_frame(world); machine.observe_frame(world);
}
settle(machine, world); settle(machine, world);
} }
@ -4160,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!(
@ -5427,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

@ -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;
@ -1841,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> {
@ -1910,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
@ -1923,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 {

View file

@ -1222,3 +1222,34 @@ fn a_refusal_that_cannot_be_read_is_not_reported() {
read.set(ram::wEnemyMonStatMods + 1, 1); read.set(ram::wEnemyMonStatMods + 1, 1);
assert!(PokeState::new(&mut read).move_without_effect(TAIL_WHIP.0)); 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

@ -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 } => {
emulator.import_state(state).expect("the booted state should import");
agent_config.warmup_ms = *warmup_ms; 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 {
Start::Fresh { state, map, warmup_ms } => {
emulator.import_state(state).expect("the starting state should import");
agent_config.warmup_ms = *warmup_ms; 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();
let start_map = match &start {
Start::Fresh { state, map, .. } => {
emulator.import_state(state).expect("the starting state should import");
frame.frame_buffer.copy_from_slice(emulator.framebuffer());
agent.warmup(Some(&frame.frame_buffer)).expect("warm-up");
*map
}
Start::Live { checkpoint, rng } => { Start::Live { checkpoint, rng } => {
let mut state = checkpoint.agent.clone(); let mut checkpoint = (*checkpoint).clone();
state.network.rng = *rng; checkpoint.agent.network.rng = *rng;
agent.import_state(&state).expect("the checkpoint's agent state should import"); frame
let (width, height) = (agent.frame.width, agent.frame.height); .restore(
agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height); &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 };
// The blocked-direction cooldown's input, computed the way `simloop.rs` computes it: the
// channel the readout is holding, once the adapter's location has stood still for a whole
// 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]; let location_before = frame.location;
for channel in DIRECTIONS.iter().skip(1) { let transition = frame.transition(&mut parts, &mut escape).expect("a frame");
if adjusted(channel) > adjusted(argmax) { let ms = transition.ms;
argmax = channel; let trace = &mut escape.trace;
} trace.reward += transition.evaluated.rewards.iter().map(|event| event.value).sum::<f64>();
} trace.tiles = transition.evaluated.progress.unique_locations;
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 let location = frame.location;
// the previous decision, for the direction that was held in between. if location != location_before {
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.
@ -1515,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;
@ -1557,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:?}")
} }
@ -1610,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))
@ -1680,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.");
@ -1771,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;
} }
@ -1801,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()]);
@ -1810,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
@ -1889,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

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)>);
@ -312,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(
@ -325,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();
@ -339,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();
@ -391,21 +519,23 @@ 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;
let mut hunt = Hunt {
stub,
stub_hold: 0,
stub_next_ms: f64::NEG_INFINITY,
hold_ms,
running: None,
// The scene of the frames in a row, for "stuck in a text box" against "in and out of one". // The scene of the frames in a row, for "stuck in a text box" against "in and out of one".
let mut scene_run: (&'static str, u64, f64) = ("", 0, began_ms); scene_run: ("", 0, began_ms),
let mut trace = Trace { dialog_map: None,
battle_sub: None,
trace: Trace {
steps: Vec::new(), steps: Vec::new(),
starts: Vec::new(), starts: Vec::new(),
episodes: Vec::new(), episodes: Vec::new(),
@ -438,192 +568,41 @@ fn run(
refusals: BTreeMap::new(), refusals: BTreeMap::new(),
refusal_run: (None, 0), refusal_run: (None, 0),
longest_refusal_run: (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));
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;
}
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(&mut 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(&mut emulator, id)
== Some(true)
}) {
trace.move_starts.1 += 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);
let (corners, border) =
flybrain_gb::pokemon_red::state::dialog_border(&mut 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) = macros.as_ref() {
let name = layer.scene_name();
*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;
}
}
}
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);
for payout in &payouts {
let entry = trace.payouts_by_kind.entry(payout.kind).or_insert((0, 0.0));
*entry = (entry.0 + 1, entry.1 + payout.value); *entry = (entry.0 + 1, entry.1 + payout.value);
} }
{ {
use flybrain_gb::MemoryReader; use flybrain_gb::MemoryReader;
let trace = &mut hunt.trace;
let fighting = let fighting =
emulator.read8(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle) != 0; parts.emulator.read8(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle) != 0;
let won = payouts.iter().any(|payout| matches!(payout.kind, "battle" | "trainer")); let won = transition
.evaluated
.rewards
.iter()
.any(|payout| matches!(payout.kind, "battle" | "trainer"));
match (fighting, trace.battle_now.as_mut()) { match (fighting, trace.battle_now.as_mut()) {
(true, Some(battle)) => { (true, Some(battle)) => {
battle.0 += 1; battle.0 += 1;
@ -638,19 +617,15 @@ fn run(
(false, None) => {} (false, None) => {}
} }
} }
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| {
@ -658,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);
@ -685,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(),

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

@ -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() {
self.profiler.absorb_brain(agent.network.timings());
agent.network.reset_timings();
} }
fn clear_eligibility(&mut self) {
let ms = self.agent.network.ms;
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)
.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 self.frame.restore(&mut parts, &checkpoint)?;
.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 {
None
}; };
// The stall window's second progress signal (`docs/design/ladder.md`, the 2026-09-17 self.frame.boundary(&mut parts, &progress, ms)?
// rule as amended 2026-09-22): coverage is ground never stood on, and a fly crossing a };
// town it has already covered to reach the rung's own door earns none of it while it is if let Some(rollback) = boundary.rollback {
// plainly getting somewhere. The macro layer answers with the map graph it already walks self.recovered(rollback.trigger, &rollback.events);
// (`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

@ -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

@ -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

@ -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

@ -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) {
@ -3446,3 +3452,357 @@ fn tail_whip_at_its_limit_is_not_dealt_and_a_route_one_battle_is_won() {
); );
assert!(won > 0, "no wild battle was won in {budget} frames: {battles:?}"); 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:?}");
}