Compare commits
26 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
862e343e1c | ||
|
|
d0ce10e3c9 | ||
|
|
9b117dc4b2 | ||
|
|
cff15804fb | ||
|
|
d6597b3e98 | ||
|
|
0117a85354 | ||
|
|
c78ca352f6 | ||
|
|
0e366cd4e4 | ||
|
|
16bc65b5a8 | ||
|
|
510727ccc0 | ||
|
|
f32352bd5e | ||
|
|
9ccc0592b1 | ||
|
|
437dfa84a9 | ||
|
|
d181f952a7 | ||
|
|
5147ab1a87 | ||
|
|
97a97a501d | ||
|
|
4108fec5ce | ||
|
|
d85ee5026f | ||
|
|
288e958d19 | ||
|
|
4d82f7db74 | ||
|
|
9301e395e8 | ||
|
|
9e2f90749f | ||
|
|
60f09b79a3 | ||
|
|
ad1c0e3693 | ||
|
|
94cc91369a | ||
|
|
ff74c07622 |
29 changed files with 3575 additions and 1219 deletions
|
|
@ -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.")
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
1
services/flysim/Cargo.lock
generated
1
services/flysim/Cargo.lock
generated
|
|
@ -508,6 +508,7 @@ dependencies = [
|
||||||
"jsonschema",
|
"jsonschema",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
|
||||||
use super::super::maps;
|
use super::super::maps;
|
||||||
use super::cartridge::Edge;
|
use super::cartridge::Edge;
|
||||||
|
use super::state::MapGrid;
|
||||||
|
|
||||||
/// A column with no connection on it.
|
/// A column with no connection on it.
|
||||||
const NONE: u8 = 0xff;
|
const NONE: u8 = 0xff;
|
||||||
|
|
@ -44,10 +45,13 @@ const EAST: usize = 3;
|
||||||
/// `wCurMapConnections`' four bits are loaded from. Kanto's overworld is one grid, so the table is
|
/// `wCurMapConnections`' four bits are loaded from. Kanto's overworld is one grid, so the table is
|
||||||
/// symmetric by construction and [`neighbours`] does not rely on that — it reads both directions.
|
/// symmetric by construction and [`neighbours`] does not rely on that — it reads both directions.
|
||||||
///
|
///
|
||||||
/// Two rows are worth a note. `ROUTE_3` and `ROUTE_4` are connected along the east-west axis even
|
/// Every row is the header's own, checked line by line against the disassembly at the pinned
|
||||||
/// though Mt. Moon stands between them, so the walkable path is the cave and not the edge; that
|
/// commit (row 59). Four pairs had the right neighbour in the wrong column, and a wrong column is
|
||||||
/// costs nothing, because an edge whose tiles are not walkable produces no exit at all
|
/// a wrong map on the other side of an edge: `ROUTE_3` / `ROUTE_4` (Route 4 is north of Route 3,
|
||||||
/// ([`super::path::exits`] filters on the walkable predicate) and the cave is in [`LINKS`].
|
/// not east, and Route 3's top edge is the road to Mt. Moon's Pokécenter), `ROUTE_14` /
|
||||||
|
/// `ROUTE_15` and `ROUTE_24` / `ROUTE_25` (west and east, not south and north), and `ROUTE_22` /
|
||||||
|
/// `ROUTE_23`, which the table had left out. A connection nobody can walk across is still the
|
||||||
|
/// header's, and [`NO_CROSSING`] says which.
|
||||||
const CONNECTIONS: &[(u8, [u8; 4])] = &[
|
const CONNECTIONS: &[(u8, [u8; 4])] = &[
|
||||||
(maps::PALLET_TOWN, [maps::ROUTE_1, maps::ROUTE_21, NONE, NONE]),
|
(maps::PALLET_TOWN, [maps::ROUTE_1, maps::ROUTE_21, NONE, NONE]),
|
||||||
(maps::VIRIDIAN_CITY, [maps::ROUTE_2, maps::ROUTE_1, maps::ROUTE_22, NONE]),
|
(maps::VIRIDIAN_CITY, [maps::ROUTE_2, maps::ROUTE_1, maps::ROUTE_22, NONE]),
|
||||||
|
|
@ -62,8 +66,8 @@ const CONNECTIONS: &[(u8, [u8; 4])] = &[
|
||||||
(maps::SAFFRON_CITY, [maps::ROUTE_5, maps::ROUTE_6, maps::ROUTE_7, maps::ROUTE_8]),
|
(maps::SAFFRON_CITY, [maps::ROUTE_5, maps::ROUTE_6, maps::ROUTE_7, maps::ROUTE_8]),
|
||||||
(maps::ROUTE_1, [maps::VIRIDIAN_CITY, maps::PALLET_TOWN, NONE, NONE]),
|
(maps::ROUTE_1, [maps::VIRIDIAN_CITY, maps::PALLET_TOWN, NONE, NONE]),
|
||||||
(maps::ROUTE_2, [maps::PEWTER_CITY, maps::VIRIDIAN_CITY, NONE, NONE]),
|
(maps::ROUTE_2, [maps::PEWTER_CITY, maps::VIRIDIAN_CITY, NONE, NONE]),
|
||||||
(maps::ROUTE_3, [NONE, NONE, maps::PEWTER_CITY, maps::ROUTE_4]),
|
(maps::ROUTE_3, [maps::ROUTE_4, NONE, maps::PEWTER_CITY, NONE]),
|
||||||
(maps::ROUTE_4, [NONE, NONE, maps::ROUTE_3, maps::CERULEAN_CITY]),
|
(maps::ROUTE_4, [NONE, maps::ROUTE_3, NONE, maps::CERULEAN_CITY]),
|
||||||
(maps::ROUTE_5, [maps::CERULEAN_CITY, maps::SAFFRON_CITY, NONE, NONE]),
|
(maps::ROUTE_5, [maps::CERULEAN_CITY, maps::SAFFRON_CITY, NONE, NONE]),
|
||||||
(maps::ROUTE_6, [maps::SAFFRON_CITY, maps::VERMILION_CITY, NONE, NONE]),
|
(maps::ROUTE_6, [maps::SAFFRON_CITY, maps::VERMILION_CITY, NONE, NONE]),
|
||||||
(maps::ROUTE_7, [NONE, NONE, maps::CELADON_CITY, maps::SAFFRON_CITY]),
|
(maps::ROUTE_7, [NONE, NONE, maps::CELADON_CITY, maps::SAFFRON_CITY]),
|
||||||
|
|
@ -73,28 +77,54 @@ const CONNECTIONS: &[(u8, [u8; 4])] = &[
|
||||||
(maps::ROUTE_11, [NONE, NONE, maps::VERMILION_CITY, maps::ROUTE_12]),
|
(maps::ROUTE_11, [NONE, NONE, maps::VERMILION_CITY, maps::ROUTE_12]),
|
||||||
(maps::ROUTE_12, [maps::LAVENDER_TOWN, maps::ROUTE_13, maps::ROUTE_11, NONE]),
|
(maps::ROUTE_12, [maps::LAVENDER_TOWN, maps::ROUTE_13, maps::ROUTE_11, NONE]),
|
||||||
(maps::ROUTE_13, [maps::ROUTE_12, NONE, maps::ROUTE_14, NONE]),
|
(maps::ROUTE_13, [maps::ROUTE_12, NONE, maps::ROUTE_14, NONE]),
|
||||||
(maps::ROUTE_14, [NONE, maps::ROUTE_15, NONE, maps::ROUTE_13]),
|
(maps::ROUTE_14, [NONE, NONE, maps::ROUTE_15, maps::ROUTE_13]),
|
||||||
(maps::ROUTE_15, [maps::ROUTE_14, NONE, maps::FUCHSIA_CITY, NONE]),
|
(maps::ROUTE_15, [NONE, NONE, maps::FUCHSIA_CITY, maps::ROUTE_14]),
|
||||||
(maps::ROUTE_16, [NONE, maps::ROUTE_17, NONE, maps::CELADON_CITY]),
|
(maps::ROUTE_16, [NONE, maps::ROUTE_17, NONE, maps::CELADON_CITY]),
|
||||||
(maps::ROUTE_17, [maps::ROUTE_16, maps::ROUTE_18, NONE, NONE]),
|
(maps::ROUTE_17, [maps::ROUTE_16, maps::ROUTE_18, NONE, NONE]),
|
||||||
(maps::ROUTE_18, [maps::ROUTE_17, NONE, NONE, maps::FUCHSIA_CITY]),
|
(maps::ROUTE_18, [maps::ROUTE_17, NONE, NONE, maps::FUCHSIA_CITY]),
|
||||||
(maps::ROUTE_19, [maps::FUCHSIA_CITY, NONE, maps::ROUTE_20, NONE]),
|
(maps::ROUTE_19, [maps::FUCHSIA_CITY, NONE, maps::ROUTE_20, NONE]),
|
||||||
(maps::ROUTE_20, [NONE, NONE, maps::CINNABAR_ISLAND, maps::ROUTE_19]),
|
(maps::ROUTE_20, [NONE, NONE, maps::CINNABAR_ISLAND, maps::ROUTE_19]),
|
||||||
(maps::ROUTE_21, [maps::PALLET_TOWN, maps::CINNABAR_ISLAND, NONE, NONE]),
|
(maps::ROUTE_21, [maps::PALLET_TOWN, maps::CINNABAR_ISLAND, NONE, NONE]),
|
||||||
// Route 22 ends at the League gate, which is a building rather than an edge, and this
|
(maps::ROUTE_22, [maps::ROUTE_23, NONE, NONE, maps::VIRIDIAN_CITY]),
|
||||||
// table has no id for it: Indigo Plateau is on the graph but not reachable from the south.
|
(maps::ROUTE_23, [maps::INDIGO_PLATEAU, maps::ROUTE_22, NONE, NONE]),
|
||||||
(maps::ROUTE_22, [NONE, NONE, NONE, maps::VIRIDIAN_CITY]),
|
(maps::ROUTE_24, [NONE, maps::CERULEAN_CITY, NONE, maps::ROUTE_25]),
|
||||||
(maps::ROUTE_23, [maps::INDIGO_PLATEAU, NONE, NONE, NONE]),
|
(maps::ROUTE_25, [NONE, NONE, maps::ROUTE_24, NONE]),
|
||||||
(maps::ROUTE_24, [maps::ROUTE_25, maps::CERULEAN_CITY, NONE, NONE]),
|
|
||||||
(maps::ROUTE_25, [NONE, maps::ROUTE_24, NONE, NONE]),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Connections in the headers that no step on foot crosses, from both sides.
|
||||||
|
///
|
||||||
|
/// Row 59, measured from the disassembly: for every connection, the tiles of this map's edge that
|
||||||
|
/// are walkable *and* land on a walkable tile of the other map's strip (the header's offset, the
|
||||||
|
/// other map's blocks and collision list). These eight have none. Pallet Town's south edge has
|
||||||
|
/// two walkable tiles and Route 21 is water under both; Cinnabar's east edge and Route 20's two
|
||||||
|
/// ends are sea; Route 22's north edge is the League's fence, and the road is its gate, a building
|
||||||
|
/// the graph has no row for. The map on the other side is still named ([`connected`]), and a
|
||||||
|
/// surfer's road is for a later row; on foot none of them is a way out
|
||||||
|
/// ([`super::path::exits`] offers no exit on them) or a road ([`neighbours`] leaves them out).
|
||||||
|
/// Without this the road from Pallet Town to Cerulean was by sea, and a fly that whited out in
|
||||||
|
/// Mt. Moon walked into Pallet's shore once every two seconds.
|
||||||
|
const NO_CROSSING: &[(u8, Edge)] = &[
|
||||||
|
(maps::PALLET_TOWN, Edge::South),
|
||||||
|
(maps::ROUTE_21, Edge::North),
|
||||||
|
(maps::CINNABAR_ISLAND, Edge::East),
|
||||||
|
(maps::ROUTE_20, Edge::West),
|
||||||
|
(maps::ROUTE_20, Edge::East),
|
||||||
|
(maps::ROUTE_19, Edge::West),
|
||||||
|
(maps::ROUTE_22, Edge::North),
|
||||||
|
(maps::ROUTE_23, Edge::South),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Whether a step off `map`'s `edge` can land on the other map on foot ([`NO_CROSSING`]).
|
||||||
|
pub fn crossable(map: u8, edge: Edge) -> bool {
|
||||||
|
!NO_CROSSING.contains(&(map, edge))
|
||||||
|
}
|
||||||
|
|
||||||
/// Doors and floor changes, as undirected pairs of maps.
|
/// Doors and floor changes, as undirected pairs of maps.
|
||||||
///
|
///
|
||||||
/// Only the ones a rung place needs a route through, because that is all [`next_hop`] is for: an
|
/// Only the ones a rung place needs a route through, because that is all [`next_hop`] is for: an
|
||||||
/// unlisted building is simply not on the graph, which makes it a place `GO OBJECTIVE` cannot aim
|
/// unlisted building is simply not on the graph, which makes it a place `GO OBJECTIVE` cannot aim
|
||||||
/// at from another map and changes nothing else. A cave with two mouths appears twice, which is
|
/// at from another map and changes nothing else. Every pair is two warp tables that name each
|
||||||
/// what makes Mt. Moon a way from Route 3 to Route 4.
|
/// other (a `LAST_MAP` door resolved to the one outdoor map whose warps lead in).
|
||||||
const LINKS: &[(u8, u8)] = &[
|
const LINKS: &[(u8, u8)] = &[
|
||||||
(maps::REDS_HOUSE_1F, maps::PALLET_TOWN),
|
(maps::REDS_HOUSE_1F, maps::PALLET_TOWN),
|
||||||
(maps::REDS_HOUSE_2F, maps::REDS_HOUSE_1F),
|
(maps::REDS_HOUSE_2F, maps::REDS_HOUSE_1F),
|
||||||
|
|
@ -122,10 +152,14 @@ const LINKS: &[(u8, u8)] = &[
|
||||||
(maps::PEWTER_MUSEUM_2F, maps::PEWTER_MUSEUM_1F),
|
(maps::PEWTER_MUSEUM_2F, maps::PEWTER_MUSEUM_1F),
|
||||||
(maps::PEWTER_MART, maps::PEWTER_CITY),
|
(maps::PEWTER_MART, maps::PEWTER_CITY),
|
||||||
(maps::PEWTER_POKECENTER, maps::PEWTER_CITY),
|
(maps::PEWTER_POKECENTER, maps::PEWTER_CITY),
|
||||||
(maps::MT_MOON_1F, maps::ROUTE_3),
|
// Mt. Moon has two mouths, and both are on Route 4 (`data/maps/objects/Route4.asm`): (18, 5)
|
||||||
|
// into the first floor, and (24, 5) into B1F, whose (27, 3) is the way back out on the far
|
||||||
|
// side of the mountain. Route 3 has no warps at all. Which chamber of B1F and B2F each ladder
|
||||||
|
// opens onto is [`SPLIT`]'s business.
|
||||||
(maps::MT_MOON_1F, maps::ROUTE_4),
|
(maps::MT_MOON_1F, maps::ROUTE_4),
|
||||||
(maps::MT_MOON_1F, maps::MT_MOON_B1F),
|
(maps::MT_MOON_1F, maps::MT_MOON_B1F),
|
||||||
(maps::MT_MOON_B1F, maps::MT_MOON_B2F),
|
(maps::MT_MOON_B1F, maps::MT_MOON_B2F),
|
||||||
|
(maps::MT_MOON_B1F, maps::ROUTE_4),
|
||||||
(maps::CERULEAN_GYM, maps::CERULEAN_CITY),
|
(maps::CERULEAN_GYM, maps::CERULEAN_CITY),
|
||||||
(maps::CERULEAN_MART, maps::CERULEAN_CITY),
|
(maps::CERULEAN_MART, maps::CERULEAN_CITY),
|
||||||
(maps::CERULEAN_POKECENTER, maps::CERULEAN_CITY),
|
(maps::CERULEAN_POKECENTER, maps::CERULEAN_CITY),
|
||||||
|
|
@ -141,11 +175,12 @@ const LINKS: &[(u8, u8)] = &[
|
||||||
/// the forest's south gate; the north half touches Pewter City and the forest's north gate; the
|
/// the forest's south gate; the north half touches Pewter City and the forest's north gate; the
|
||||||
/// belt of trees between them needs CUT. A graph with one node for it answered "Pewter is two
|
/// belt of trees between them needs CUT. A graph with one node for it answered "Pewter is two
|
||||||
/// hops from the south gate, south" — which is a road that does not exist — and sent the fly back
|
/// hops from the south gate, south" — which is a road that does not exist — and sent the fly back
|
||||||
/// out of the gate it had just walked into, once per hold, for four hours.
|
/// out of the gate it had just walked into, once per hold, for four hours. Row 59 found three more
|
||||||
|
/// on the road to Cerulean: Route 4, and Mt. Moon's two lower floors ([`SPLIT`]).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub struct Region {
|
pub struct Region {
|
||||||
pub map: u8,
|
pub map: u8,
|
||||||
/// Which piece, for a map [`SPLIT`] has a row for; 0 everywhere else.
|
/// Which piece, for a map [`SPLIT`] has a row for: its index in that row. 0 everywhere else.
|
||||||
pub part: u8,
|
pub part: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,108 +189,268 @@ impl Region {
|
||||||
pub const fn whole(map: u8) -> Self {
|
pub const fn whole(map: u8) -> Self {
|
||||||
Self { map, part: 0 }
|
Self { map, part: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Piece `part` of a map [`SPLIT`] has a row for.
|
||||||
|
pub const fn piece(map: u8, part: u8) -> Self {
|
||||||
|
Self { map, part }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`SPLIT`]'s two piece numbers.
|
/// One piece of a split map.
|
||||||
const NORTH_PIECE: u8 = 0;
|
struct Piece {
|
||||||
const SOUTH_PIECE: u8 = 1;
|
/// The map's own warps that stand on this piece's ground: the index into its warp table (as
|
||||||
|
/// `wWarpEntries` holds it, and as a warp elsewhere names it for its destination, 0-based) and
|
||||||
|
/// the tile. A warp that lands on one of these lands in this piece; the tiles are what the
|
||||||
|
/// fly's own piece is told apart by ([`region_on`]).
|
||||||
|
doors: &'static [(u8, u8, u8)],
|
||||||
|
/// Everything one step from this piece: a whole map, or a piece of another split map. Their
|
||||||
|
/// maps together are exactly [`neighbours`]'s answer for the map, and every step is listed
|
||||||
|
/// back from the other side; [`tests::a_split_maps_pieces_add_up_and_answer_each_other`] pins
|
||||||
|
/// both.
|
||||||
|
next: &'static [Region],
|
||||||
|
}
|
||||||
|
|
||||||
/// A map whose walkable ground is in two pieces, and which of its neighbours each piece touches.
|
/// A map whose walkable ground is in pieces the player cannot walk between.
|
||||||
struct Split {
|
struct Split {
|
||||||
map: u8,
|
map: u8,
|
||||||
/// The tile rows each piece's own doorway is on, measured from the cartridge: a tile belongs
|
pieces: &'static [Piece],
|
||||||
/// to the piece whose row it is nearer to. Anchoring on the doorways rather than on a row in
|
|
||||||
/// the middle means the number comes from the warp table rather than from a claim about where
|
|
||||||
/// the trees are, and a tile in the impassable belt between them — ground the fly cannot
|
|
||||||
/// stand on — is the only place the answer could be wrong.
|
|
||||||
north_door: u8,
|
|
||||||
south_door: u8,
|
|
||||||
/// The neighbours reachable from each piece. Together they are exactly [`neighbours`]'s answer
|
|
||||||
/// for the map, which [`tests::a_split_maps_pieces_divide_its_neighbours_between_them`] pins.
|
|
||||||
north: &'static [u8],
|
|
||||||
south: &'static [u8],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every map whose ground is in two pieces. One row, and it took four hours of stream to find.
|
/// Route 2's two halves, in [`SPLIT`]'s order.
|
||||||
|
#[cfg(test)]
|
||||||
|
const NORTH_PIECE: u8 = 0;
|
||||||
|
#[cfg(test)]
|
||||||
|
const SOUTH_PIECE: u8 = 1;
|
||||||
|
|
||||||
|
/// Route 4's two sides of the mountain.
|
||||||
|
#[cfg(test)]
|
||||||
|
const WEST_SIDE: u8 = 0;
|
||||||
|
const EAST_SIDE: u8 = 1;
|
||||||
|
|
||||||
|
/// Mt. Moon B1F's four chambers, named by what they hold.
|
||||||
|
const B1F_EXIT: u8 = 0;
|
||||||
|
const B1F_WEST: u8 = 1;
|
||||||
|
const B1F_MIDDLE: u8 = 2;
|
||||||
|
const B1F_SOUTH: u8 = 3;
|
||||||
|
|
||||||
|
/// Mt. Moon B2F's three pieces.
|
||||||
|
const B2F_MAIN: u8 = 0;
|
||||||
|
const B2F_NORTH: u8 = 1;
|
||||||
|
const B2F_SOUTH: u8 = 2;
|
||||||
|
|
||||||
|
/// Every map whose ground is in pieces, with each piece's doors and neighbours.
|
||||||
///
|
///
|
||||||
/// `ROUTE_2`, surveyed from the cartridge on 2026-09-17 (`docs/design/macros-wram.md`'s method,
|
/// Every row is measured from the disassembly at the pinned commit: the map's blocks, its
|
||||||
/// the run recorded in `infra/docs/macros-traps.md` row 33). The map is 20 by 72 and its warp
|
/// tileset's blockset and collision list, the tile-pair walls and the ledges, flooded tile by tile
|
||||||
/// table reads:
|
/// (`infra/docs/macros-traps.md` row 59 has the method). A map is listed here only when two of its
|
||||||
|
/// ways out are on different pieces, and the audit ran over every map on the graph.
|
||||||
///
|
///
|
||||||
/// | warp | tile | into |
|
/// - **`ROUTE_2`** (row 33): the forest's north gate at (3, 11) and Pewter's edge; the south gate
|
||||||
/// | ---: | --- | --- |
|
/// at (3, 43) and Viridian's. Maps 46, 48 and 49 stay off the graph, the module's standing rule
|
||||||
/// | 0 | (12, 9) | `DIGLETTS_CAVE_ROUTE_2` (46) |
|
/// for a building no rung place needs a route through: 49's two doors are both Route 2's own.
|
||||||
/// | 1 | (3, 11) | `VIRIDIAN_FOREST_NORTH_GATE` (47) |
|
/// - **`ROUTE_4`** (row 59): Mt. Moon stands across it. The west side holds the Pokécenter at
|
||||||
/// | 2 | (15, 19) | `ROUTE_2_TRADE_HOUSE` (48) |
|
/// (11, 5), the cave mouth at (18, 5) and the road down to Route 3; the east side holds B1F's
|
||||||
/// | 3 | (16, 35) | `ROUTE_2_GATE` (49) |
|
/// exit at (24, 5) and the ledges down to Cerulean. From the Pewter side the only way east is
|
||||||
/// | 4 | (15, 39) | `ROUTE_2_GATE` (49) |
|
/// through the mountain.
|
||||||
/// | 5 | (3, 43) | `VIRIDIAN_FOREST_SOUTH_GATE` (50) |
|
/// - **`MT_MOON_B1F`** (row 59): four chambers, each two ladders and nothing between them. The
|
||||||
///
|
/// one road through is 1F (5, 5) to the west chamber, (21, 17) down to B2F, B2F (5, 7) up to the
|
||||||
/// with `north: true` and `south: true` in `wCurMapConnections` — Pewter off the top row, Viridian
|
/// exit chamber, (27, 3) out onto Route 4's east side. The middle and south chambers are ladders
|
||||||
/// off the bottom one. The two forest gates at rows 11 and 43 are the doorways this splits on.
|
/// to dead ends on B2F.
|
||||||
///
|
/// - **`MT_MOON_B2F`** (row 59): the fossil floor, one large piece with the two ladders the road
|
||||||
/// Maps 46, 48 and 49 are deliberately *not* on the graph, which is this module's standing rule
|
/// uses, and two small pieces under the dead-end ladders.
|
||||||
/// for a building no rung place needs a route through: 49's two doors are both warps of `ROUTE_2`
|
const SPLIT: &[Split] = &[
|
||||||
/// itself, so it is a shortcut within one map rather than a way between two, and 46 and 48 are
|
Split {
|
||||||
/// ends of the line. A route the table does not carry is simply not offered; nothing is guessed.
|
map: maps::ROUTE_2,
|
||||||
const SPLIT: &[Split] = &[Split {
|
pieces: &[
|
||||||
map: maps::ROUTE_2,
|
Piece {
|
||||||
north_door: 11,
|
doors: &[(1, 3, 11)],
|
||||||
south_door: 43,
|
next: &[
|
||||||
north: &[maps::PEWTER_CITY, maps::VIRIDIAN_FOREST_NORTH_GATE],
|
Region::whole(maps::PEWTER_CITY),
|
||||||
south: &[maps::VIRIDIAN_CITY, maps::VIRIDIAN_FOREST_SOUTH_GATE],
|
Region::whole(maps::VIRIDIAN_FOREST_NORTH_GATE),
|
||||||
}];
|
],
|
||||||
|
},
|
||||||
|
Piece {
|
||||||
|
doors: &[(5, 3, 43)],
|
||||||
|
next: &[
|
||||||
|
Region::whole(maps::VIRIDIAN_CITY),
|
||||||
|
Region::whole(maps::VIRIDIAN_FOREST_SOUTH_GATE),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Split {
|
||||||
|
map: maps::ROUTE_4,
|
||||||
|
pieces: &[
|
||||||
|
Piece {
|
||||||
|
doors: &[(0, 11, 5), (1, 18, 5)],
|
||||||
|
next: &[Region::whole(maps::ROUTE_3), Region::whole(maps::MT_MOON_1F)],
|
||||||
|
},
|
||||||
|
Piece {
|
||||||
|
doors: &[(2, 24, 5)],
|
||||||
|
next: &[
|
||||||
|
Region::piece(maps::MT_MOON_B1F, B1F_EXIT),
|
||||||
|
Region::whole(maps::CERULEAN_CITY),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Split {
|
||||||
|
map: maps::MT_MOON_B1F,
|
||||||
|
pieces: &[
|
||||||
|
Piece {
|
||||||
|
doors: &[(6, 23, 3), (7, 27, 3)],
|
||||||
|
next: &[
|
||||||
|
Region::piece(maps::MT_MOON_B2F, B2F_MAIN),
|
||||||
|
Region::piece(maps::ROUTE_4, EAST_SIDE),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Piece {
|
||||||
|
doors: &[(0, 5, 5), (4, 21, 17)],
|
||||||
|
next: &[
|
||||||
|
Region::whole(maps::MT_MOON_1F),
|
||||||
|
Region::piece(maps::MT_MOON_B2F, B2F_MAIN),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Piece {
|
||||||
|
doors: &[(1, 17, 11), (2, 25, 9)],
|
||||||
|
next: &[
|
||||||
|
Region::whole(maps::MT_MOON_1F),
|
||||||
|
Region::piece(maps::MT_MOON_B2F, B2F_NORTH),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Piece {
|
||||||
|
doors: &[(3, 25, 15), (5, 13, 27)],
|
||||||
|
next: &[
|
||||||
|
Region::whole(maps::MT_MOON_1F),
|
||||||
|
Region::piece(maps::MT_MOON_B2F, B2F_SOUTH),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Split {
|
||||||
|
map: maps::MT_MOON_B2F,
|
||||||
|
pieces: &[
|
||||||
|
Piece {
|
||||||
|
doors: &[(1, 21, 17), (3, 5, 7)],
|
||||||
|
next: &[
|
||||||
|
Region::piece(maps::MT_MOON_B1F, B1F_EXIT),
|
||||||
|
Region::piece(maps::MT_MOON_B1F, B1F_WEST),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Piece {
|
||||||
|
doors: &[(0, 25, 9)],
|
||||||
|
next: &[Region::piece(maps::MT_MOON_B1F, B1F_MIDDLE)],
|
||||||
|
},
|
||||||
|
Piece {
|
||||||
|
doors: &[(2, 15, 27)],
|
||||||
|
next: &[Region::piece(maps::MT_MOON_B1F, B1F_SOUTH)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
fn split_of(map: u8) -> Option<&'static Split> {
|
fn split_of(map: u8) -> Option<&'static Split> {
|
||||||
SPLIT.iter().find(|split| split.map == map)
|
SPLIT.iter().find(|split| split.map == map)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The piece of `map` a tile on row `y` is in.
|
/// The pieces of a split map with their numbers, which are their [`Region::part`]s.
|
||||||
///
|
fn pieces(split: &'static Split) -> impl Iterator<Item = (u8, &'static Piece)> {
|
||||||
/// For every map but [`SPLIT`]'s rows this is [`Region::whole`]. Callers pass the player's own
|
split.pieces.iter().enumerate().filter_map(|(part, piece)| Some((u8::try_from(part).ok()?, piece)))
|
||||||
/// row, which is the only thing that can tell the two halves of `ROUTE_2` apart.
|
|
||||||
pub fn region_at(map: u8, y: u8) -> Region {
|
|
||||||
match split_of(map) {
|
|
||||||
None => Region::whole(map),
|
|
||||||
Some(split) => {
|
|
||||||
let north = y.abs_diff(split.north_door);
|
|
||||||
let south = y.abs_diff(split.south_door);
|
|
||||||
Region { map, part: if north <= south { NORTH_PIECE } else { SOUTH_PIECE } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The piece of `map` that `from` opens onto, or `None` when no piece of it touches `from`.
|
/// The piece of `map` the tile `(x, y)` is in, by the doors alone.
|
||||||
///
|
///
|
||||||
/// This is the reverse of [`region_at`] and it needs no tile: a door or an edge is listed under
|
/// For every map but [`SPLIT`]'s rows this is [`Region::whole`]. On a split map it is the piece
|
||||||
/// exactly one piece, so "which half of Route 2 does the north gate open onto" is a table lookup.
|
/// with the nearest door, counting tiles across and down, the first piece on a tie. That is exact
|
||||||
/// `None` is an edge the graph does not have -- the south half of Route 2 is not reachable from
|
/// for every tile of Route 2's and Route 4's ground, which is where the grid cannot answer
|
||||||
/// Pewter City, whatever the map ids alone would suggest.
|
/// ([`region_on`]: a ledge is a one-way step the grid does not model), and it is only the fallback
|
||||||
fn region_toward(map: u8, from: u8) -> Option<Region> {
|
/// on Mt. Moon's floors, whose chambers wrap round each other.
|
||||||
match split_of(map) {
|
pub fn region_at(map: u8, x: u8, y: u8) -> Region {
|
||||||
None => Some(Region::whole(map)),
|
let Some(split) = split_of(map) else { return Region::whole(map) };
|
||||||
Some(split) => {
|
let distance = |piece: &Piece| {
|
||||||
if split.north.contains(&from) {
|
piece
|
||||||
Some(Region { map, part: NORTH_PIECE })
|
.doors
|
||||||
} else if split.south.contains(&from) {
|
.iter()
|
||||||
Some(Region { map, part: SOUTH_PIECE })
|
.map(|(_, dx, dy)| u16::from(x.abs_diff(*dx)) + u16::from(y.abs_diff(*dy)))
|
||||||
} else {
|
.min()
|
||||||
None
|
.unwrap_or(u16::MAX)
|
||||||
}
|
};
|
||||||
|
let part = pieces(split).min_by_key(|(part, piece)| (distance(piece), *part)).map_or(0, |(part, _)| part);
|
||||||
|
Region { map, part }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The piece of `map` the fly standing on `(x, y)` is in.
|
||||||
|
///
|
||||||
|
/// The ground decides: with the decoded map grid (section 15) the piece is the one whose doors a
|
||||||
|
/// walk from here can reach, when exactly one piece's can. The grid has no ledges -- a ledge is a
|
||||||
|
/// step one way only, and the grid reads it as a wall -- so on the part of Route 4 below the
|
||||||
|
/// ledges no door is reachable and [`region_at`] answers from the doors. Row 59 flooded every tile
|
||||||
|
/// of every piece's ground in the disassembly under this rule and it names the right piece for
|
||||||
|
/// all of them.
|
||||||
|
pub fn region_on(map: u8, x: u8, y: u8, grid: Option<&MapGrid>) -> Region {
|
||||||
|
let Some(split) = split_of(map) else { return Region::whole(map) };
|
||||||
|
if let Some(grid) = grid.filter(|grid| grid.map() == map) {
|
||||||
|
let walk = grid.reachable(x, y);
|
||||||
|
// A door tile the collision list refuses is still stepped onto from beside it.
|
||||||
|
let reached = |dx: u8, dy: u8| {
|
||||||
|
walk.contains(dx, dy)
|
||||||
|
|| [(0i16, 1i16), (0, -1), (1, 0), (-1, 0)].iter().any(|(ox, oy)| {
|
||||||
|
match (u8::try_from(i16::from(dx) + ox), u8::try_from(i16::from(dy) + oy)) {
|
||||||
|
(Ok(nx), Ok(ny)) => walk.contains(nx, ny),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let mut hit =
|
||||||
|
pieces(split).filter(|(_, piece)| piece.doors.iter().any(|(_, dx, dy)| reached(*dx, *dy)));
|
||||||
|
if let (Some((part, _)), None) = (hit.next(), hit.next()) {
|
||||||
|
return Region { map, part };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
region_at(map, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The piece of `map` a warp lands in when it names `map`'s warp `index` as its destination.
|
||||||
|
///
|
||||||
|
/// `None` only for a split map whose table does not list that warp, which is not guessed at.
|
||||||
|
pub fn arrival_by_warp(map: u8, index: u8) -> Option<Region> {
|
||||||
|
let Some(split) = split_of(map) else { return Some(Region::whole(map)) };
|
||||||
|
pieces(split)
|
||||||
|
.find(|(_, piece)| piece.doors.iter().any(|(door, _, _)| *door == index))
|
||||||
|
.map(|(part, _)| Region { map, part })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The piece of `map` that stepping off an edge of `from` lands in.
|
||||||
|
///
|
||||||
|
/// An edge is listed under exactly one piece of a split map: Pewter's south edge opens onto Route
|
||||||
|
/// 2's north half, Route 3's north edge onto Route 4's west side and Cerulean's west edge onto its
|
||||||
|
/// east side. `None` is an edge the graph does not have.
|
||||||
|
pub fn arrival_by_edge(map: u8, from: u8) -> Option<Region> {
|
||||||
|
let Some(split) = split_of(map) else { return Some(Region::whole(map)) };
|
||||||
|
pieces(split)
|
||||||
|
.find(|(_, piece)| piece.next.iter().any(|next| next.map == from))
|
||||||
|
.map(|(part, _)| Region { map, part })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every piece of ground one step from `region`.
|
/// Every piece of ground one step from `region`.
|
||||||
fn region_neighbours(region: Region) -> Vec<Region> {
|
fn region_neighbours(region: Region) -> Vec<Region> {
|
||||||
let of = |map: u8| region_toward(map, region.map);
|
if let Some(split) = split_of(region.map) {
|
||||||
match split_of(region.map) {
|
return split.pieces.get(usize::from(region.part)).map_or_else(Vec::new, |piece| piece.next.to_vec());
|
||||||
None => neighbours(region.map).into_iter().filter_map(of).collect(),
|
}
|
||||||
Some(split) => {
|
// A whole map steps onto every piece of a split neighbour that lists it back: Mt. Moon's
|
||||||
let own = if region.part == NORTH_PIECE { split.north } else { split.south };
|
// first floor has a ladder into three of B1F's four chambers.
|
||||||
own.iter().copied().filter_map(of).collect()
|
let mut out = Vec::new();
|
||||||
|
for map in neighbours(region.map) {
|
||||||
|
match split_of(map) {
|
||||||
|
None => out.push(Region::whole(map)),
|
||||||
|
Some(split) => out.extend(
|
||||||
|
pieces(split)
|
||||||
|
.filter(|(_, piece)| piece.next.contains(®ion))
|
||||||
|
.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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
machine.observe_frame(world);
|
for _ in 0..super::executor::PUSH_SETTLE_FRAMES {
|
||||||
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -70,19 +70,20 @@ use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use flybrain_core::agent::{
|
use flybrain_core::agent::{
|
||||||
AgentConfig, NeuralAgent, RewardEvent as NeuralReward, TickOptions,
|
AgentConfig, NeuralAgent,
|
||||||
};
|
};
|
||||||
use flybrain_core::dataset::load_brain_dataset_from_dir;
|
use flybrain_core::dataset::load_brain_dataset_from_dir;
|
||||||
use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask};
|
use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
|
||||||
use flybrain_core::lif::SweepPlan;
|
use flybrain_core::lif::SweepPlan;
|
||||||
use flybrain_gb::adapter::GameAdapter;
|
use flybrain_gb::adapter::GameAdapter;
|
||||||
use flybrain_gb::pokemon_red::PokemonRedReward;
|
use flybrain_gb::pokemon_red::PokemonRedReward;
|
||||||
use flybrain_gb::ratchet::Ratchet;
|
use flybrain_gb::ratchet::Ratchet;
|
||||||
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
|
|
||||||
use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons};
|
use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons};
|
||||||
use flysim::config::Config;
|
use flysim::config::Config;
|
||||||
|
use flysim::frame::{Executed, FrameObserver, LegacyFrame, Parts};
|
||||||
use flysim::macros::{MacroLayer, OutcomeCounts, Silence, macro_layer};
|
use flysim::macros::{MacroLayer, OutcomeCounts, Silence, macro_layer};
|
||||||
use flysim::snapshot::MacroMode;
|
use flysim::snapshot::MacroMode;
|
||||||
|
use flysim::trace::FrameTrace;
|
||||||
|
|
||||||
/// `constants/map_constants.asm`: Red's bedroom, where a cold boot ends up.
|
/// `constants/map_constants.asm`: Red's bedroom, where a cold boot ends up.
|
||||||
const REDS_HOUSE_2F: u32 = 0x26;
|
const REDS_HOUSE_2F: u32 = 0x26;
|
||||||
|
|
@ -103,25 +104,12 @@ fn emulator(rom: &[u8]) -> Emulator {
|
||||||
.expect("binjgb should accept the cartridge")
|
.expect("binjgb should accept the cartridge")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The neural half of a ratchet recovery, exactly as `simloop.rs` wires it.
|
/// Whether a macro owned the buttons of a frame: read as the executor hands the mask over.
|
||||||
struct AgentRecovery<'a> {
|
struct MacroOwned(bool);
|
||||||
agent: &'a mut NeuralAgent,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NeuralRecovery for AgentRecovery<'_> {
|
impl FrameObserver for MacroOwned {
|
||||||
fn clear_decoder_holds(&mut self) {
|
fn executed(&mut self, _frame: &LegacyFrame, parts: &mut Parts<'_>, _executed: &Executed) {
|
||||||
let ms = self.agent.network.ms;
|
self.0 = parts.macros.as_deref().is_some_and(|layer| layer.running().is_some());
|
||||||
self.agent.decoder.clear_holds(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear_eligibility(&mut self) {
|
|
||||||
let ms = self.agent.network.ms;
|
|
||||||
self.agent.network.plasticity.clear_eligibility(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_visual_frame(&mut self, frame: &[u8]) {
|
|
||||||
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
|
|
||||||
self.agent.network.set_visual_frame(frame, width, height);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,11 +232,9 @@ fn boot_to_bedroom(rom: &[u8]) -> Vec<u8> {
|
||||||
|
|
||||||
/// One arm: `hours` brain hours of the sim loop's frame order, unthrottled.
|
/// One arm: `hours` brain hours of the sim loop's frame order, unthrottled.
|
||||||
///
|
///
|
||||||
/// The order is `simloop.rs`'s (steps 2 to 10), as `NeuralAgent::tick` expresses it: the frame and
|
/// The frame is `flysim::frame::LegacyFrame`, the one the stream runs, so the arm measures the
|
||||||
/// the payouts handed to a tick are the ones the previous tick's buttons produced. The macro layer
|
/// wiring under test rather than a second implementation of it. (Before FND-01 the arms ticked the
|
||||||
/// is consulted at exactly the two points the loop consults it — after the decode, before the
|
/// brain through `NeuralAgent::tick`, one frame behind the stream's order.)
|
||||||
/// buttons reach the emulator, and after the frame and its payouts — so the arm measures the
|
|
||||||
/// wiring under test rather than a second implementation of it.
|
|
||||||
fn run_arm(
|
fn run_arm(
|
||||||
rom: &[u8],
|
rom: &[u8],
|
||||||
data: &Arc<flybrain_core::dataset::BrainDataset>,
|
data: &Arc<flybrain_core::dataset::BrainDataset>,
|
||||||
|
|
@ -270,48 +256,39 @@ fn run_arm(
|
||||||
.or(preset.exclusive.as_ref())
|
.or(preset.exclusive.as_ref())
|
||||||
.expect("the preset has a group")
|
.expect("the preset has a group")
|
||||||
.hold_ms;
|
.hold_ms;
|
||||||
let blocked_ms = preset.exclusive.as_ref().expect("the preset has an exclusive group").blocked_ms;
|
|
||||||
let mut agent_config = AgentConfig::with_decoder(preset);
|
let mut agent_config = AgentConfig::with_decoder(preset);
|
||||||
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
|
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
|
||||||
let mut arm = Arm { mode: mode.as_str(), ..Arm::default() };
|
let mut arm = Arm { mode: mode.as_str(), ..Arm::default() };
|
||||||
|
|
||||||
match start {
|
if let Start::Fresh { warmup_ms, .. } = start {
|
||||||
Start::Fresh { state, warmup_ms } => {
|
agent_config.warmup_ms = *warmup_ms;
|
||||||
emulator.import_state(state).expect("the booted state should import");
|
|
||||||
agent_config.warmup_ms = *warmup_ms;
|
|
||||||
}
|
|
||||||
Start::Live { checkpoint } => {
|
|
||||||
emulator
|
|
||||||
.import_state(&checkpoint.runtime.emulator)
|
|
||||||
.expect("the checkpoint's emulator state should import");
|
|
||||||
adapter
|
|
||||||
.import_state(&checkpoint.runtime.reward)
|
|
||||||
.expect("the checkpoint's reward ledger should import");
|
|
||||||
let snapshot = (!checkpoint.runtime.ratchet_game.is_empty()).then(|| {
|
|
||||||
flybrain_gb::ratchet::Snapshot {
|
|
||||||
game: checkpoint.runtime.ratchet_game.clone(),
|
|
||||||
frame: checkpoint.runtime.ratchet_frame.clone(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ratchet
|
|
||||||
.import(Some(checkpoint.runtime.ratchet), snapshot, adapter.rank_ladder().len())
|
|
||||||
.expect("the checkpoint's ratchet state should import");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
|
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
|
||||||
if threads > 1 {
|
if threads > 1 {
|
||||||
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
|
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
|
||||||
}
|
}
|
||||||
|
let mut frame = LegacyFrame::new()
|
||||||
|
.with_trace(FrameTrace::from_env().expect("FLY_TRACE should name a writable file"));
|
||||||
match start {
|
match start {
|
||||||
Start::Fresh { .. } => {
|
Start::Fresh { state, warmup_ms: _ } => {
|
||||||
agent.warmup(Some(emulator.framebuffer())).expect("warm-up");
|
emulator.import_state(state).expect("the booted state should import");
|
||||||
}
|
frame.frame_buffer.copy_from_slice(emulator.framebuffer());
|
||||||
Start::Live { checkpoint } => {
|
agent.warmup(Some(&frame.frame_buffer)).expect("warm-up");
|
||||||
agent.import_state(&checkpoint.agent).expect("the checkpoint's agent should import");
|
|
||||||
let (width, height) = (agent.frame.width, agent.frame.height);
|
|
||||||
agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height);
|
|
||||||
}
|
}
|
||||||
|
// The stream's own restore, into the stream's own frame.
|
||||||
|
Start::Live { checkpoint } => frame
|
||||||
|
.restore(
|
||||||
|
&mut Parts {
|
||||||
|
agent: &mut agent,
|
||||||
|
emulator: &mut emulator,
|
||||||
|
adapter: &mut adapter,
|
||||||
|
ratchet: &mut ratchet,
|
||||||
|
macros: None,
|
||||||
|
},
|
||||||
|
checkpoint,
|
||||||
|
)
|
||||||
|
.expect("the checkpoint should restore"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// The layer under test, built the way the sim loop builds it: from the configuration, so raw
|
// The layer under test, built the way the sim loop builds it: from the configuration, so raw
|
||||||
|
|
@ -328,136 +305,62 @@ fn run_arm(
|
||||||
|
|
||||||
let began_ms = agent.network.ms;
|
let began_ms = agent.network.ms;
|
||||||
let until = began_ms + hours * HOUR_MS;
|
let until = began_ms + hours * HOUR_MS;
|
||||||
let mut frame = emulator.framebuffer().to_vec();
|
|
||||||
let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new();
|
|
||||||
let mut location = adapter.location();
|
|
||||||
let mut blocked_since_ms = began_ms;
|
|
||||||
let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string);
|
|
||||||
let mut rank = adapter.progress().rank;
|
let mut rank = adapter.progress().rank;
|
||||||
let tiles_at_start = adapter.progress().unique_locations;
|
let tiles_at_start = adapter.progress().unique_locations;
|
||||||
arm.rungs.push((rank, adapter.progress().rank_label, 0.0));
|
arm.rungs.push((rank, adapter.progress().rank_label, 0.0));
|
||||||
|
|
||||||
// The scene has not been observed yet, so the first frame is decided on an empty palette,
|
// One observation before the first frame, as the sim loop takes after a restore, so frame one
|
||||||
// which presses nothing. That is one frame, and it is the honest starting state.
|
// is decided on a real palette.
|
||||||
if let Some(layer) = macros.as_mut() {
|
if let Some(layer) = macros.as_mut() {
|
||||||
let ledger = AdapterLedger(&adapter);
|
let ledger = AdapterLedger(&adapter);
|
||||||
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
|
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
while agent.network.ms < until {
|
while agent.network.ms < until {
|
||||||
let rewards: Vec<NeuralReward> = payouts
|
let mut parts = Parts {
|
||||||
.iter()
|
agent: &mut agent,
|
||||||
.map(|event| {
|
emulator: &mut emulator,
|
||||||
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms))
|
adapter: &mut adapter,
|
||||||
})
|
ratchet: &mut ratchet,
|
||||||
.collect();
|
macros: macros.as_mut(),
|
||||||
let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true };
|
};
|
||||||
|
let mut owned = MacroOwned(false);
|
||||||
// The blocked-direction cooldown's input, as `simloop.rs` computes it.
|
let transition = frame.transition(&mut parts, &mut owned).expect("a frame");
|
||||||
let ms = agent.network.ms;
|
let ms = transition.ms;
|
||||||
let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms)
|
let executed = &transition.executed;
|
||||||
.then(|| agent.decoder.current().map(str::to_string))
|
if let Some(layer) = parts.macros.as_deref() {
|
||||||
.flatten();
|
if let Some(silence) = executed.silence {
|
||||||
// The scene's own macro buttons, from the palette the previous frame's `observe` dealt:
|
|
||||||
// the same mask the sim loop passes (`docs/design/macros.md` section 12). `None` in the
|
|
||||||
// raw arm, which has no layer and no macro group at all.
|
|
||||||
let bound = macros.as_ref().map(MacroLayer::bound_channels);
|
|
||||||
let result = agent
|
|
||||||
.tick_bound(&frame, &options, blocked.as_deref(), bound.as_deref())
|
|
||||||
.expect("a tick");
|
|
||||||
let held = agent.decoder.current().map(str::to_string);
|
|
||||||
if held != held_channel {
|
|
||||||
held_channel = held;
|
|
||||||
blocked_since_ms = ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4, with the layer in the middle of it in macros mode and absent in raw mode.
|
|
||||||
let ms = agent.network.ms;
|
|
||||||
let mut mask = to_button_mask(&result.active);
|
|
||||||
if let Some(layer) = macros.as_mut() {
|
|
||||||
let ledger = AdapterLedger(&adapter);
|
|
||||||
let decision = layer.decide(&result.active, mask, ms, &mut emulator, &ledger);
|
|
||||||
mask = decision.mask;
|
|
||||||
if let Some(silence) = decision.silence {
|
|
||||||
*arm.silence.entry(silence.label()).or_insert(0) += 1;
|
*arm.silence.entry(silence.label()).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
for event in decision.events.iter().filter(|event| event.outcome.is_none()) {
|
for event in executed.events.iter().filter(|event| event.outcome.is_none()) {
|
||||||
*arm.by_rank.entry(event.slot).or_insert(0) += 1;
|
*arm.by_rank.entry(event.slot).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
if layer.running().is_some() {
|
if owned.0 {
|
||||||
arm.macro_frames += 1;
|
arm.macro_frames += 1;
|
||||||
}
|
}
|
||||||
|
*arm.scenes.entry(layer.scene_name()).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
if mask == 0 {
|
if executed.mask == 0 {
|
||||||
arm.idle_frames += 1;
|
arm.idle_frames += 1;
|
||||||
}
|
}
|
||||||
emulator.set_buttons(mask as u8);
|
|
||||||
emulator.run_frame().expect("a frame should complete");
|
|
||||||
arm.frames += 1;
|
arm.frames += 1;
|
||||||
frame.copy_from_slice(emulator.framebuffer());
|
for event in &transition.evaluated.rewards {
|
||||||
|
|
||||||
// Steps 7 to 9, then the scene.
|
|
||||||
payouts = adapter.sample(&mut emulator, ms);
|
|
||||||
for event in &payouts {
|
|
||||||
arm.reward += event.value;
|
arm.reward += event.value;
|
||||||
*arm.payouts.entry(event.kind).or_insert(0) += 1;
|
*arm.payouts.entry(event.kind).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
if let Some(layer) = macros.as_mut() {
|
arm.digest = hash(arm.digest, u64::from(executed.mask));
|
||||||
let ledger = AdapterLedger(&adapter);
|
if let Some((map, x, y)) = frame.location {
|
||||||
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
|
|
||||||
*arm.scenes.entry(layer.scene_name()).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = adapter.location();
|
|
||||||
if now.is_some() && now != location {
|
|
||||||
location = now;
|
|
||||||
blocked_since_ms = ms;
|
|
||||||
}
|
|
||||||
arm.digest = hash(arm.digest, u64::from(mask));
|
|
||||||
if let Some((map, x, y)) = location {
|
|
||||||
arm.digest = hash(arm.digest, u64::from(map) << 32 | u64::from(x) << 16 | u64::from(y));
|
arm.digest = hash(arm.digest, u64::from(map) << 32 | u64::from(x) << 16 | u64::from(y));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 10: the ratchet, with the adapter's own policy.
|
let progress = transition.evaluated.progress;
|
||||||
let progress = adapter.progress();
|
|
||||||
if progress.rank != rank {
|
if progress.rank != rank {
|
||||||
rank = progress.rank;
|
rank = progress.rank;
|
||||||
arm.rungs.push((rank, progress.rank_label, ms - began_ms));
|
arm.rungs.push((rank, progress.rank_label, ms - began_ms));
|
||||||
}
|
}
|
||||||
let safe = adapter.safe_for_snapshot();
|
let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
|
||||||
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best;
|
if boundary.rollback.is_some() {
|
||||||
let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot {
|
|
||||||
game: emulator.export_state().expect("state export"),
|
|
||||||
frame: frame.clone(),
|
|
||||||
});
|
|
||||||
let recover = ratchet.observe_with_game_over(
|
|
||||||
safe,
|
|
||||||
u64::from(progress.rank),
|
|
||||||
progress.unique_locations as u64,
|
|
||||||
ms as u64,
|
|
||||||
adapter.game_over(),
|
|
||||||
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
|
|
||||||
);
|
|
||||||
if recover {
|
|
||||||
let snapshot = flybrain_gb::ratchet::Snapshot {
|
|
||||||
game: ratchet.game().expect("a recovery has a snapshot").to_vec(),
|
|
||||||
frame: ratchet.frame().expect("a recovery has a framebuffer").to_vec(),
|
|
||||||
};
|
|
||||||
let restored = {
|
|
||||||
let mut neural = AgentRecovery { agent: &mut agent };
|
|
||||||
recover_game(&mut emulator, &mut adapter, &mut neural, &snapshot)
|
|
||||||
.expect("recovering the game")
|
|
||||||
};
|
|
||||||
frame.copy_from_slice(&restored);
|
|
||||||
emulator.set_buttons(0);
|
|
||||||
arm.recoveries += 1;
|
arm.recoveries += 1;
|
||||||
location = adapter.location();
|
|
||||||
held_channel = None;
|
|
||||||
blocked_since_ms = ms;
|
|
||||||
// The sim loop abandons a running macro on a rollback, and so does this.
|
|
||||||
if let Some(layer) = macros.as_mut() {
|
|
||||||
layer.cancel(ms);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
use flybrain_core::agent::{
|
use flybrain_core::agent::{
|
||||||
AgentConfig, GAMEBOY_MS_PER_FRAME, NeuralAgent, RewardEvent as NeuralReward, TickOptions,
|
AgentConfig, GAMEBOY_MS_PER_FRAME, NeuralAgent,
|
||||||
};
|
};
|
||||||
use flybrain_core::dataset::load_brain_dataset_from_dir;
|
use flybrain_core::dataset::load_brain_dataset_from_dir;
|
||||||
use flybrain_core::decoder::gameboy::{GAMEBOY_BUTTONS, gameboy_decoder_config, to_button_mask};
|
use flybrain_core::decoder::gameboy::{GAMEBOY_BUTTONS, gameboy_decoder_config, to_button_mask};
|
||||||
|
|
@ -88,8 +88,8 @@ use flybrain_gb::adapter::{GameAdapter, MemoryReader};
|
||||||
use flybrain_gb::pokemon_red::symbols::ram;
|
use flybrain_gb::pokemon_red::symbols::ram;
|
||||||
use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM};
|
use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM};
|
||||||
use flybrain_gb::ratchet::Ratchet;
|
use flybrain_gb::ratchet::Ratchet;
|
||||||
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
|
|
||||||
use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons};
|
use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons};
|
||||||
|
use flysim::frame::{FrameObserver, FramePhase, LegacyFrame, Parts};
|
||||||
|
|
||||||
/// `constants/map_constants.asm`: Red's bedroom and the ground floor of his house.
|
/// `constants/map_constants.asm`: Red's bedroom and the ground floor of his house.
|
||||||
const REDS_HOUSE_2F: u32 = 0x26;
|
const REDS_HOUSE_2F: u32 = 0x26;
|
||||||
|
|
@ -496,28 +496,6 @@ fn print_table(title: &str, cells: &BTreeMap<(String, u64, u64, u64), Cell>, roo
|
||||||
// The real brain
|
// The real brain
|
||||||
// -------------------------------------------------------------------------------------------
|
// -------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
/// The neural half of a ratchet recovery, exactly as `flysim::simloop` wires it.
|
|
||||||
struct AgentRecovery<'a> {
|
|
||||||
agent: &'a mut NeuralAgent,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NeuralRecovery for AgentRecovery<'_> {
|
|
||||||
fn clear_decoder_holds(&mut self) {
|
|
||||||
let ms = self.agent.network.ms;
|
|
||||||
self.agent.decoder.clear_holds(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear_eligibility(&mut self) {
|
|
||||||
let ms = self.agent.network.ms;
|
|
||||||
self.agent.network.plasticity.clear_eligibility(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_visual_frame(&mut self, frame: &[u8]) {
|
|
||||||
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
|
|
||||||
self.agent.network.set_visual_frame(frame, width, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where one brain run starts.
|
/// Where one brain run starts.
|
||||||
enum Start<'a> {
|
enum Start<'a> {
|
||||||
/// A save state and the map it stands in: a fresh fly, warmed up here.
|
/// A save state and the map it stands in: a fresh fly, warmed up here.
|
||||||
|
|
@ -666,6 +644,97 @@ fn survey(rom: &[u8], state: &[u8]) -> Survey {
|
||||||
(reachable, exits)
|
(reachable, exits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The room-escape instrumentation inside the stream's frame: the decoder as it stood before
|
||||||
|
/// the decode and after it, read at the one point between the two.
|
||||||
|
struct Escape<'a> {
|
||||||
|
/// The readout before this frame's decode.
|
||||||
|
before: Option<flybrain_core::decoder::DecoderState>,
|
||||||
|
hold_start: (Option<(u32, u32, u32)>, f64),
|
||||||
|
run_winner: Option<String>,
|
||||||
|
run_length: f64,
|
||||||
|
start_map: u32,
|
||||||
|
exits: &'a BTreeMap<(u32, u32), Vec<&'static str>>,
|
||||||
|
hold_ms: f64,
|
||||||
|
trace: Trace,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameObserver for Escape<'_> {
|
||||||
|
fn after(&mut self, phase: FramePhase, agent: &mut NeuralAgent) {
|
||||||
|
if phase == FramePhase::Ticked {
|
||||||
|
self.before = Some(agent.decoder.export_state());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn before_execute(&mut self, frame: &LegacyFrame, parts: &mut Parts<'_>, _active: &[String]) {
|
||||||
|
let Some(before) = self.before.take() else { return };
|
||||||
|
let agent = &*parts.agent;
|
||||||
|
let after = agent.decoder.export_state();
|
||||||
|
if after.next_decision == before.next_decision {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ms = agent.network.ms;
|
||||||
|
let location = frame.location;
|
||||||
|
let trace = &mut self.trace;
|
||||||
|
trace.decisions += 1;
|
||||||
|
let winner = after.current.clone().expect("a decision names a winner");
|
||||||
|
// The raw argmax, recomputed from the decoder's own inputs: the rates the decode saw, the
|
||||||
|
// calibrated baseline, and the fatigue as it stood *before* the decision. Comparing it
|
||||||
|
// with the winner is what "the incumbent won by hysteresis" means.
|
||||||
|
let adjusted = |channel: &str| {
|
||||||
|
let index = DIRECTIONS.iter().position(|name| *name == channel).expect("a direction");
|
||||||
|
let role = ROLES[index];
|
||||||
|
let rate = agent.network.rates.get_or_zero(role);
|
||||||
|
let base = after.baseline.get_or_zero(role);
|
||||||
|
(rate + 1.0) / (base + 1.0) / (1.0 + before.fatigue.get_or_zero(channel))
|
||||||
|
};
|
||||||
|
let mut argmax = DIRECTIONS[0];
|
||||||
|
for channel in DIRECTIONS.iter().skip(1) {
|
||||||
|
if adjusted(channel) > adjusted(argmax) {
|
||||||
|
argmax = channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if argmax != winner {
|
||||||
|
trace.hysteresis_holds += 1;
|
||||||
|
}
|
||||||
|
if let Some(name) = DIRECTIONS.iter().find(|name| **name == winner) {
|
||||||
|
*trace.wins.entry(name).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
if self.run_winner.as_deref() == Some(winner.as_str()) {
|
||||||
|
self.run_length += 1.0;
|
||||||
|
} else {
|
||||||
|
if self.run_length > 0.0 {
|
||||||
|
trace.runs.push(self.run_length);
|
||||||
|
}
|
||||||
|
self.run_winner = Some(winner.clone());
|
||||||
|
self.run_length = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was the hold that just ended a wall bump? The location now against the location at the
|
||||||
|
// previous decision, for the direction that was held in between.
|
||||||
|
if let (Some(previous), Some(held)) = (self.hold_start.0, before.current.as_deref())
|
||||||
|
&& ms - self.hold_start.1 >= self.hold_ms
|
||||||
|
&& location == Some(previous)
|
||||||
|
&& let Some(name) = DIRECTIONS.iter().find(|name| **name == held)
|
||||||
|
{
|
||||||
|
*trace.blocked_holds.entry(name).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
self.hold_start = (location, ms);
|
||||||
|
|
||||||
|
// The decision this whole exercise is about: standing on a tile one press from leaving,
|
||||||
|
// did the readout choose that press? Only on the starting map: the bedroom has walkable
|
||||||
|
// tiles at the same coordinates and they are not these exits.
|
||||||
|
if let Some(leaving) = location
|
||||||
|
.filter(|(map, _, _)| *map == self.start_map)
|
||||||
|
.and_then(|(_, x, y)| self.exits.get(&(x, y)))
|
||||||
|
{
|
||||||
|
trace.exit_decisions += 1;
|
||||||
|
if leaving.iter().any(|direction| *direction == winner) {
|
||||||
|
trace.exit_decisions_taken += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One instrumented brain run: the real network, the real readout, the real adapter, and -- from a
|
/// One instrumented brain run: the real network, the real readout, the real adapter, and -- from a
|
||||||
/// live checkpoint -- the real reward ledger and the real ratchet.
|
/// live checkpoint -- the real reward ledger and the real ratchet.
|
||||||
///
|
///
|
||||||
|
|
@ -697,174 +766,81 @@ fn brain_trace(
|
||||||
let mut emulator = emulator(rom);
|
let mut emulator = emulator(rom);
|
||||||
let mut adapter = PokemonRedReward::new();
|
let mut adapter = PokemonRedReward::new();
|
||||||
let mut agent_config = AgentConfig::with_decoder(config.clone());
|
let mut agent_config = AgentConfig::with_decoder(config.clone());
|
||||||
let (hold_ms, blocked_ms) = {
|
let hold_ms =
|
||||||
let group = config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group");
|
config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group").hold_ms;
|
||||||
(group.hold_ms, group.blocked_ms)
|
|
||||||
};
|
|
||||||
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
|
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
|
||||||
let mut trace = Trace::default();
|
let mut trace = Trace::default();
|
||||||
|
if let Start::Fresh { warmup_ms, .. } = &start {
|
||||||
let start_map = match &start {
|
agent_config.warmup_ms = *warmup_ms;
|
||||||
Start::Fresh { state, map, warmup_ms } => {
|
}
|
||||||
emulator.import_state(state).expect("the starting state should import");
|
|
||||||
agent_config.warmup_ms = *warmup_ms;
|
|
||||||
*map
|
|
||||||
}
|
|
||||||
Start::Live { checkpoint, .. } => {
|
|
||||||
emulator
|
|
||||||
.import_state(&checkpoint.runtime.emulator)
|
|
||||||
.expect("the checkpoint's emulator state should import");
|
|
||||||
adapter
|
|
||||||
.import_state(&checkpoint.runtime.reward)
|
|
||||||
.expect("the checkpoint's reward ledger should import");
|
|
||||||
let snapshot = (!checkpoint.runtime.ratchet_game.is_empty()).then(|| {
|
|
||||||
flybrain_gb::ratchet::Snapshot {
|
|
||||||
game: checkpoint.runtime.ratchet_game.clone(),
|
|
||||||
frame: checkpoint.runtime.ratchet_frame.clone(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ratchet
|
|
||||||
.import(Some(checkpoint.runtime.ratchet), snapshot, adapter.rank_ladder().len())
|
|
||||||
.expect("the checkpoint's ratchet state should import");
|
|
||||||
u32::from(emulator.read8(ram::wCurMap))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
|
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
|
||||||
if threads > 1 {
|
if threads > 1 {
|
||||||
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
|
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
|
||||||
}
|
}
|
||||||
match &start {
|
// The stream's own frame (`flysim::frame::LegacyFrame`), in raw mode: no macro layer.
|
||||||
Start::Fresh { .. } => agent.warmup(Some(emulator.framebuffer())).expect("warm-up"),
|
let mut frame = LegacyFrame::new();
|
||||||
Start::Live { checkpoint, rng } => {
|
let start_map = match &start {
|
||||||
let mut state = checkpoint.agent.clone();
|
Start::Fresh { state, map, .. } => {
|
||||||
state.network.rng = *rng;
|
emulator.import_state(state).expect("the starting state should import");
|
||||||
agent.import_state(&state).expect("the checkpoint's agent state should import");
|
frame.frame_buffer.copy_from_slice(emulator.framebuffer());
|
||||||
let (width, height) = (agent.frame.width, agent.frame.height);
|
agent.warmup(Some(&frame.frame_buffer)).expect("warm-up");
|
||||||
agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height);
|
*map
|
||||||
}
|
}
|
||||||
}
|
Start::Live { checkpoint, rng } => {
|
||||||
|
let mut checkpoint = (*checkpoint).clone();
|
||||||
|
checkpoint.agent.network.rng = *rng;
|
||||||
|
frame
|
||||||
|
.restore(
|
||||||
|
&mut Parts {
|
||||||
|
agent: &mut agent,
|
||||||
|
emulator: &mut emulator,
|
||||||
|
adapter: &mut adapter,
|
||||||
|
ratchet: &mut ratchet,
|
||||||
|
macros: None,
|
||||||
|
},
|
||||||
|
&checkpoint,
|
||||||
|
)
|
||||||
|
.expect("the checkpoint should restore");
|
||||||
|
u32::from(emulator.read8(ram::wCurMap))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
trace.maps.push(start_map);
|
trace.maps.push(start_map);
|
||||||
let began_ms = agent.network.ms;
|
let began_ms = agent.network.ms;
|
||||||
let until = began_ms + minutes * 60_000.0;
|
let until = began_ms + minutes * 60_000.0;
|
||||||
// The sim loop's own order (`simloop.rs`, steps 2 to 10), as `NeuralAgent::tick` expresses it:
|
let mut escape = Escape {
|
||||||
// the frame and the payouts handed to a tick are the ones the previous tick's buttons produced.
|
before: None,
|
||||||
let mut frame = emulator.framebuffer().to_vec();
|
hold_start: (frame.location, began_ms),
|
||||||
let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new();
|
run_winner: None,
|
||||||
let mut location = adapter.location();
|
run_length: 0.0,
|
||||||
// The blocked-direction cooldown's window, restarted by a move *or* by a new winner, exactly
|
start_map,
|
||||||
// as `simloop.rs` restarts it: a direction that has just won has not had a hold to move in yet.
|
exits,
|
||||||
let mut blocked_since_ms = began_ms;
|
hold_ms,
|
||||||
let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string);
|
trace,
|
||||||
let mut hold_start = (location, began_ms);
|
};
|
||||||
let mut run_winner: Option<String> = None;
|
|
||||||
let mut run_length = 0.0f64;
|
|
||||||
// Reported on its own: the longest stretch with no movement at all, whatever was held.
|
// Reported on its own: the longest stretch with no movement at all, whatever was held.
|
||||||
let mut still_since_ms = began_ms;
|
let mut still_since_ms = began_ms;
|
||||||
let tiles_at_start = adapter.progress().unique_locations;
|
let tiles_at_start = adapter.progress().unique_locations;
|
||||||
trace.tiles = tiles_at_start;
|
escape.trace.tiles = tiles_at_start;
|
||||||
|
|
||||||
while agent.network.ms < until {
|
while agent.network.ms < until {
|
||||||
let rewards: Vec<NeuralReward> = payouts
|
let mut parts = Parts {
|
||||||
.iter()
|
agent: &mut agent,
|
||||||
.map(|event| {
|
emulator: &mut emulator,
|
||||||
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms))
|
adapter: &mut adapter,
|
||||||
})
|
ratchet: &mut ratchet,
|
||||||
.collect();
|
macros: None,
|
||||||
let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true };
|
};
|
||||||
|
let location_before = frame.location;
|
||||||
|
let transition = frame.transition(&mut parts, &mut escape).expect("a frame");
|
||||||
|
let ms = transition.ms;
|
||||||
|
let trace = &mut escape.trace;
|
||||||
|
trace.reward += transition.evaluated.rewards.iter().map(|event| event.value).sum::<f64>();
|
||||||
|
trace.tiles = transition.evaluated.progress.unique_locations;
|
||||||
|
|
||||||
// The blocked-direction cooldown's input, computed the way `simloop.rs` computes it: the
|
let location = frame.location;
|
||||||
// channel the readout is holding, once the adapter's location has stood still for a whole
|
if location != location_before {
|
||||||
// hold. `blocked_ms == 0` is the rule switched off, and reports nothing.
|
|
||||||
let ms = agent.network.ms;
|
|
||||||
let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms)
|
|
||||||
.then(|| agent.decoder.current().map(str::to_string))
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
let before = agent.decoder.export_state();
|
|
||||||
let result = agent.tick_blocked(&frame, &options, blocked.as_deref()).expect("a tick");
|
|
||||||
let after = agent.decoder.export_state();
|
|
||||||
let held = agent.decoder.current().map(str::to_string);
|
|
||||||
if held != held_channel {
|
|
||||||
held_channel = held;
|
|
||||||
blocked_since_ms = ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
if after.next_decision != before.next_decision {
|
|
||||||
trace.decisions += 1;
|
|
||||||
let winner = after.current.clone().expect("a decision names a winner");
|
|
||||||
// The raw argmax, recomputed from the decoder's own inputs: the rates the decode saw
|
|
||||||
// (`tick` decodes on the post-step rates and nothing changes them afterwards), the
|
|
||||||
// calibrated baseline, and the fatigue as it stood *before* the decision. Comparing it
|
|
||||||
// with the winner is what "the incumbent won by hysteresis" means.
|
|
||||||
let adjusted = |channel: &str| {
|
|
||||||
let index =
|
|
||||||
DIRECTIONS.iter().position(|name| *name == channel).expect("a direction");
|
|
||||||
let role = ROLES[index];
|
|
||||||
let rate = agent.network.rates.get_or_zero(role);
|
|
||||||
let base = after.baseline.get_or_zero(role);
|
|
||||||
(rate + 1.0) / (base + 1.0) / (1.0 + before.fatigue.get_or_zero(channel))
|
|
||||||
};
|
|
||||||
let mut argmax = DIRECTIONS[0];
|
|
||||||
for channel in DIRECTIONS.iter().skip(1) {
|
|
||||||
if adjusted(channel) > adjusted(argmax) {
|
|
||||||
argmax = channel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if argmax != winner {
|
|
||||||
trace.hysteresis_holds += 1;
|
|
||||||
}
|
|
||||||
if let Some(name) = DIRECTIONS.iter().find(|name| **name == winner) {
|
|
||||||
*trace.wins.entry(name).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
if run_winner.as_deref() == Some(winner.as_str()) {
|
|
||||||
run_length += 1.0;
|
|
||||||
} else {
|
|
||||||
if run_length > 0.0 {
|
|
||||||
trace.runs.push(run_length);
|
|
||||||
}
|
|
||||||
run_winner = Some(winner.clone());
|
|
||||||
run_length = 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Was the hold that just ended a wall bump? The location now against the location at
|
|
||||||
// the previous decision, for the direction that was held in between.
|
|
||||||
if let (Some(previous), Some(held)) = (hold_start.0, before.current.as_deref())
|
|
||||||
&& ms - hold_start.1 >= hold_ms
|
|
||||||
&& location == Some(previous)
|
|
||||||
&& let Some(name) = DIRECTIONS.iter().find(|name| **name == held)
|
|
||||||
{
|
|
||||||
*trace.blocked_holds.entry(name).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
hold_start = (location, ms);
|
|
||||||
|
|
||||||
// The decision this whole exercise is about: standing on a tile one press from
|
|
||||||
// leaving, did the readout choose that press? Only on the starting map: the bedroom
|
|
||||||
// has walkable tiles at the same coordinates and they are not these exits.
|
|
||||||
if let Some(leaving) = location
|
|
||||||
.filter(|(map, _, _)| *map == start_map)
|
|
||||||
.and_then(|(_, x, y)| exits.get(&(x, y)))
|
|
||||||
{
|
|
||||||
trace.exit_decisions += 1;
|
|
||||||
if leaving.iter().any(|direction| *direction == winner) {
|
|
||||||
trace.exit_decisions_taken += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emulator.set_buttons(to_button_mask(&result.active) as u8);
|
|
||||||
emulator.run_frame().expect("a frame should complete");
|
|
||||||
frame.copy_from_slice(emulator.framebuffer());
|
|
||||||
let ms = agent.network.ms;
|
|
||||||
payouts = adapter.sample(&mut emulator, ms);
|
|
||||||
trace.reward += payouts.iter().map(|event| event.value).sum::<f64>();
|
|
||||||
trace.tiles = adapter.progress().unique_locations;
|
|
||||||
|
|
||||||
let now = adapter.location();
|
|
||||||
if now.is_some() && now != location {
|
|
||||||
location = now;
|
|
||||||
blocked_since_ms = ms;
|
|
||||||
still_since_ms = ms;
|
still_since_ms = ms;
|
||||||
}
|
}
|
||||||
trace.longest_still_ms = trace.longest_still_ms.max(ms - still_since_ms);
|
trace.longest_still_ms = trace.longest_still_ms.max(ms - still_since_ms);
|
||||||
|
|
@ -876,40 +852,15 @@ fn brain_trace(
|
||||||
*trace.exit_tile_frames.entry(tile).or_insert(0) += 1;
|
*trace.exit_tile_frames.entry(tile).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 10: the ratchet, with the adapter's own policy and the checkpoint's own budget.
|
// The ratchet, with the adapter's own policy and the checkpoint's own budget.
|
||||||
let progress = adapter.progress();
|
let progress = transition.evaluated.progress;
|
||||||
let safe = adapter.safe_for_snapshot();
|
let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
|
||||||
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best;
|
if boundary.rollback.is_some() {
|
||||||
let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot {
|
escape.trace.recoveries += 1;
|
||||||
game: emulator.export_state().expect("state export"),
|
|
||||||
frame: frame.clone(),
|
|
||||||
});
|
|
||||||
let recover = ratchet.observe(
|
|
||||||
safe,
|
|
||||||
u64::from(progress.rank),
|
|
||||||
progress.unique_locations as u64,
|
|
||||||
ms as u64,
|
|
||||||
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
|
|
||||||
);
|
|
||||||
if recover {
|
|
||||||
let snapshot = flybrain_gb::ratchet::Snapshot {
|
|
||||||
game: ratchet.game().expect("a recovery has a snapshot").to_vec(),
|
|
||||||
frame: ratchet.frame().expect("a recovery has a framebuffer").to_vec(),
|
|
||||||
};
|
|
||||||
let restored = {
|
|
||||||
let mut neural = AgentRecovery { agent: &mut agent };
|
|
||||||
recover_game(&mut emulator, &mut adapter, &mut neural, &snapshot)
|
|
||||||
.expect("recovering the game")
|
|
||||||
};
|
|
||||||
frame.copy_from_slice(&restored);
|
|
||||||
emulator.set_buttons(0);
|
|
||||||
trace.recoveries += 1;
|
|
||||||
location = adapter.location();
|
|
||||||
held_channel = None;
|
|
||||||
blocked_since_ms = ms;
|
|
||||||
still_since_ms = ms;
|
still_since_ms = ms;
|
||||||
hold_start = (location, ms);
|
escape.hold_start = (frame.location, ms);
|
||||||
}
|
}
|
||||||
|
let trace = &mut escape.trace;
|
||||||
|
|
||||||
let Some(map) = adapter.map_id() else { continue };
|
let Some(map) = adapter.map_id() else { continue };
|
||||||
if trace.maps.last() != Some(&map) {
|
if trace.maps.last() != Some(&map) {
|
||||||
|
|
@ -925,6 +876,7 @@ fn brain_trace(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let Escape { mut trace, run_length, .. } = escape;
|
||||||
if run_length > 0.0 {
|
if run_length > 0.0 {
|
||||||
trace.runs.push(run_length);
|
trace.runs.push(run_length);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,239 +519,90 @@ fn run(
|
||||||
|
|
||||||
let began_ms = agent.network.ms;
|
let began_ms = agent.network.ms;
|
||||||
let until = began_ms + minutes * MINUTE_MS;
|
let until = began_ms + minutes * MINUTE_MS;
|
||||||
let mut frame = emulator.framebuffer().to_vec();
|
let rank = adapter.progress().rank;
|
||||||
let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new();
|
|
||||||
let mut location = adapter.location();
|
|
||||||
let mut blocked_since_ms = began_ms;
|
|
||||||
let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string);
|
|
||||||
let mut rank = adapter.progress().rank;
|
|
||||||
let mut running: Option<Running> = None;
|
|
||||||
// A periodic one-liner for a run that is going nowhere: what the fly is standing on, what it
|
// A periodic one-liner for a run that is going nowhere: what the fly is standing on, what it
|
||||||
// faces, and which text box the detector is looking at. Off unless asked for, because it is a
|
// faces, and which text box the detector is looking at. Off unless asked for, because it is a
|
||||||
// diagnostic and the tables above are the report.
|
// diagnostic and the tables above are the report.
|
||||||
let trace_every_ms = env_f64("FLY_TRAP_TRACE_SECONDS", 0.0) * 1000.0;
|
let trace_every_ms = env_f64("FLY_TRAP_TRACE_SECONDS", 0.0) * 1000.0;
|
||||||
let mut next_trace = began_ms;
|
let mut next_trace = began_ms;
|
||||||
// The scene of the frames in a row, for "stuck in a text box" against "in and out of one".
|
let mut hunt = Hunt {
|
||||||
let mut scene_run: (&'static str, u64, f64) = ("", 0, began_ms);
|
stub,
|
||||||
let mut trace = Trace {
|
stub_hold: 0,
|
||||||
steps: Vec::new(),
|
stub_next_ms: f64::NEG_INFINITY,
|
||||||
starts: Vec::new(),
|
hold_ms,
|
||||||
episodes: Vec::new(),
|
running: None,
|
||||||
began_ms,
|
// The scene of the frames in a row, for "stuck in a text box" against "in and out of one".
|
||||||
ended_ms: began_ms,
|
scene_run: ("", 0, began_ms),
|
||||||
frames: 0,
|
dialog_map: None,
|
||||||
recoveries: 0,
|
battle_sub: None,
|
||||||
rungs: vec![(rank, adapter.progress().rank_label, 0.0)],
|
trace: Trace {
|
||||||
outcomes: BTreeMap::new(),
|
steps: Vec::new(),
|
||||||
scenes: BTreeMap::new(),
|
starts: Vec::new(),
|
||||||
dialog_frames: BTreeMap::new(),
|
episodes: Vec::new(),
|
||||||
dialog_macros: BTreeMap::new(),
|
began_ms,
|
||||||
ended_in: ("", String::new()),
|
ended_ms: began_ms,
|
||||||
longest_scene: BTreeMap::new(),
|
frames: 0,
|
||||||
ended_why: String::new(),
|
recoveries: 0,
|
||||||
ended_grid: String::new(),
|
rungs: vec![(rank, adapter.progress().rank_label, 0.0)],
|
||||||
font_corners_border: 0,
|
outcomes: BTreeMap::new(),
|
||||||
font_corners_no_border: 0,
|
scenes: BTreeMap::new(),
|
||||||
font_no_corners: 0,
|
dialog_frames: BTreeMap::new(),
|
||||||
corners_no_font: 0,
|
dialog_macros: BTreeMap::new(),
|
||||||
battle_frames: BTreeMap::new(),
|
ended_in: ("", String::new()),
|
||||||
battle_starts: BTreeMap::new(),
|
longest_scene: BTreeMap::new(),
|
||||||
battle_pads: BTreeMap::new(),
|
ended_why: String::new(),
|
||||||
battles: Vec::new(),
|
ended_grid: String::new(),
|
||||||
battle_now: None,
|
font_corners_border: 0,
|
||||||
payouts_by_kind: BTreeMap::new(),
|
font_corners_no_border: 0,
|
||||||
move_starts: (0, 0),
|
font_no_corners: 0,
|
||||||
wall_seconds: 0.0,
|
corners_no_font: 0,
|
||||||
seeded: seeded_note,
|
battle_frames: BTreeMap::new(),
|
||||||
refusals: BTreeMap::new(),
|
battle_starts: BTreeMap::new(),
|
||||||
refusal_run: (None, 0),
|
battle_pads: BTreeMap::new(),
|
||||||
longest_refusal_run: (0, ""),
|
battles: Vec::new(),
|
||||||
|
battle_now: None,
|
||||||
|
payouts_by_kind: BTreeMap::new(),
|
||||||
|
move_starts: (0, 0),
|
||||||
|
wall_seconds: 0.0,
|
||||||
|
seeded: seeded_note,
|
||||||
|
refusals: BTreeMap::new(),
|
||||||
|
refusal_run: (None, 0),
|
||||||
|
longest_refusal_run: (0, ""),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
let mut rank = rank;
|
||||||
|
|
||||||
|
// One observation before the first frame, as the sim loop takes after a restore.
|
||||||
if let Some(layer) = macros.as_mut() {
|
if let Some(layer) = macros.as_mut() {
|
||||||
let ledger = AdapterLedger(&adapter);
|
let ledger = AdapterLedger(&adapter);
|
||||||
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
|
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
while agent.network.ms < until {
|
while agent.network.ms < until {
|
||||||
let rewards: Vec<NeuralReward> = payouts
|
let mut parts = Parts {
|
||||||
.iter()
|
agent: &mut agent,
|
||||||
.map(|event| {
|
emulator: &mut emulator,
|
||||||
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms))
|
adapter: &mut adapter,
|
||||||
})
|
ratchet: &mut ratchet,
|
||||||
.collect();
|
macros: macros.as_mut(),
|
||||||
let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true };
|
|
||||||
let ms = agent.network.ms;
|
|
||||||
let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms)
|
|
||||||
.then(|| agent.decoder.current().map(str::to_string))
|
|
||||||
.flatten();
|
|
||||||
let bound = macros.as_ref().map(MacroLayer::bound_channels);
|
|
||||||
let result = agent
|
|
||||||
.tick_bound(&frame, &options, blocked.as_deref(), bound.as_deref())
|
|
||||||
.expect("a tick");
|
|
||||||
// The brain is still ticked — the frame order, the plasticity and the cost are the run's —
|
|
||||||
// and only the *readout* is replaced, so a stub run and a brain run differ in who chooses
|
|
||||||
// and in nothing else.
|
|
||||||
let active: Vec<String> = if stub {
|
|
||||||
let hot = STUB_CHANNELS[(stub_hold / STUB_HOLDS_PER_CHANNEL) % STUB_CHANNELS.len()];
|
|
||||||
if ms >= stub_next_ms {
|
|
||||||
stub_next_ms = ms + hold_ms;
|
|
||||||
stub_hold += 1;
|
|
||||||
}
|
|
||||||
bound
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.iter()
|
|
||||||
.filter(|channel| *channel == hot)
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
} else {
|
|
||||||
result.active.clone()
|
|
||||||
};
|
};
|
||||||
let held = agent.decoder.current().map(str::to_string);
|
let transition = frame.transition(&mut parts, &mut hunt).expect("a frame");
|
||||||
if held != held_channel {
|
let ms = transition.ms;
|
||||||
held_channel = held;
|
hunt.trace.frames += 1;
|
||||||
blocked_since_ms = ms;
|
for payout in &transition.evaluated.rewards {
|
||||||
}
|
let entry = hunt.trace.payouts_by_kind.entry(payout.kind).or_insert((0, 0.0));
|
||||||
|
|
||||||
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(),
|
||||||
|
|
|
||||||
705
services/flysim/crates/flysim/src/frame.rs
Normal file
705
services/flysim/crates/flysim/src/frame.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
176
services/flysim/crates/flysim/src/journal.rs
Normal file
176
services/flysim/crates/flysim/src/journal.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
//!
|
//!
|
||||||
//! The per-frame order is the prototype worker's (`fly-plays-pokemon/src/simulation.worker.ts`,
|
//! The per-frame order is the prototype worker's (`fly-plays-pokemon/src/simulation.worker.ts`,
|
||||||
//! `tick()`), not `NeuralAgent::tick`'s argument order, because the prototype samples reward
|
//! `tick()`), not `NeuralAgent::tick`'s argument order, because the prototype samples reward
|
||||||
//! inside the same frame it produced:
|
//! inside the same frame it produced. It lives in `crate::frame::LegacyFrame`, the one copy every
|
||||||
|
//! harness runs too; in outline:
|
||||||
//!
|
//!
|
||||||
//! 1. drain commands
|
//! 1. drain commands
|
||||||
//! 2. step the brain 16 or 17 ms (the fractional remainder carries and is checkpointed)
|
//! 2. step the brain 16 or 17 ms (the fractional remainder carries and is checkpointed)
|
||||||
|
|
@ -30,14 +31,13 @@ use anyhow::{Context, Result, anyhow, bail};
|
||||||
use flybrain_core::agent::{AgentConfig, NeuralAgent};
|
use flybrain_core::agent::{AgentConfig, NeuralAgent};
|
||||||
use flybrain_core::dataset::load_brain_dataset_from_dir;
|
use flybrain_core::dataset::load_brain_dataset_from_dir;
|
||||||
use flybrain_core::decoder::DecoderConfig;
|
use flybrain_core::decoder::DecoderConfig;
|
||||||
use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask};
|
use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
|
||||||
use flybrain_core::decoder::platformer::platformer_decoder_config;
|
use flybrain_core::decoder::platformer::platformer_decoder_config;
|
||||||
use flybrain_core::lif::SweepPlan;
|
use flybrain_core::lif::SweepPlan;
|
||||||
use flybrain_gb::adapter::{DecoderPresetId, GameAdapter, ProgressSnapshot};
|
use flybrain_gb::adapter::{DecoderPresetId, GameAdapter, ProgressSnapshot};
|
||||||
use flybrain_gb::macros::AdapterLedger;
|
use flybrain_gb::macros::AdapterLedger;
|
||||||
use flybrain_gb::emulator::{DEFAULT_AUDIO_FRAMES, Emulator, FRAMEBUFFER_LEN};
|
use flybrain_gb::emulator::{DEFAULT_AUDIO_FRAMES, Emulator};
|
||||||
use flybrain_gb::ratchet::Ratchet;
|
use flybrain_gb::ratchet::Ratchet;
|
||||||
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use tokio::sync::{mpsc, oneshot, watch};
|
use tokio::sync::{mpsc, oneshot, watch};
|
||||||
|
|
@ -45,6 +45,8 @@ use tokio::sync::{mpsc, oneshot, watch};
|
||||||
use crate::chat::{ChatLimiter, ChatRefusal, ChatRing, DenyList, RejectReason};
|
use crate::chat::{ChatLimiter, ChatRefusal, ChatRing, DenyList, RejectReason};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::eventlog::{EventLog, EventRing, NewEvent, now_wall_ms, utc_day};
|
use crate::eventlog::{EventLog, EventRing, NewEvent, now_wall_ms, utc_day};
|
||||||
|
use crate::journal::{Input, SugarJournal};
|
||||||
|
use crate::frame::{FrameObserver, FramePhase, LegacyFrame, Parts, RollbackTrigger};
|
||||||
use crate::macros::{MacroEvent, MacroLayer, macro_layer};
|
use crate::macros::{MacroEvent, MacroLayer, macro_layer};
|
||||||
use crate::metrics::Metrics;
|
use crate::metrics::Metrics;
|
||||||
use crate::pacing::{Pacer, RealtimeWindow};
|
use crate::pacing::{Pacer, RealtimeWindow};
|
||||||
|
|
@ -56,6 +58,7 @@ use crate::snapshot::{
|
||||||
RewardCounts, RewardKind, Snapshot, f32_bytes, finite, spike_bitset,
|
RewardCounts, RewardKind, Snapshot, f32_bytes, finite, spike_bitset,
|
||||||
};
|
};
|
||||||
use crate::store::{self, RuntimeState, Store};
|
use crate::store::{self, RuntimeState, Store};
|
||||||
|
use crate::trace::FrameTrace;
|
||||||
|
|
||||||
/// Commands the control API queues for the sim thread.
|
/// Commands the control API queues for the sim thread.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -273,25 +276,26 @@ pub fn booting_snapshot(seq: u64, wall_ms: u64, mode: MacroMode) -> Snapshot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The neural half of a ratchet recovery, wired to `flybrain-core`.
|
/// The stream's only look inside the frame: the per-phase profile (`crate::profile`).
|
||||||
struct AgentRecovery<'a> {
|
struct Laps<'a> {
|
||||||
agent: &'a mut NeuralAgent,
|
profiler: &'a mut Profiler,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NeuralRecovery for AgentRecovery<'_> {
|
impl FrameObserver for Laps<'_> {
|
||||||
fn clear_decoder_holds(&mut self) {
|
fn after(&mut self, phase: FramePhase, agent: &mut NeuralAgent) {
|
||||||
let ms = self.agent.network.ms;
|
match phase {
|
||||||
self.agent.decoder.clear_holds(ms);
|
FramePhase::Ticked => {
|
||||||
}
|
self.profiler.lap(Phase::Step);
|
||||||
|
if self.profiler.enabled() {
|
||||||
fn clear_eligibility(&mut self) {
|
self.profiler.absorb_brain(agent.network.timings());
|
||||||
let ms = self.agent.network.ms;
|
agent.network.reset_timings();
|
||||||
self.agent.network.plasticity.clear_eligibility(ms);
|
}
|
||||||
}
|
}
|
||||||
|
FramePhase::Executed => self.profiler.lap(Phase::Decode),
|
||||||
fn set_visual_frame(&mut self, frame: &[u8]) {
|
FramePhase::Emulated => self.profiler.lap(Phase::Emulate),
|
||||||
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
|
FramePhase::Advanced => self.profiler.lap(Phase::Retina),
|
||||||
self.agent.network.set_visual_frame(frame, width, height);
|
FramePhase::Committed => self.profiler.lap(Phase::Rewards),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,24 +340,11 @@ pub struct Sim {
|
||||||
next_generation: u64,
|
next_generation: u64,
|
||||||
best_archived_rank: Option<u32>,
|
best_archived_rank: Option<u32>,
|
||||||
|
|
||||||
/// Fractional millisecond carried into the next frame, exactly as `NeuralAgent` keeps it.
|
/// The frame order and its state: the remainder, the frame counter, the frame on screen, the
|
||||||
remainder: f64,
|
/// mask and the readout's blocked-direction window (`crate::frame`).
|
||||||
frame_counter: u64,
|
frame: LegacyFrame,
|
||||||
frame_buffer: Vec<u8>,
|
|
||||||
pending_audio: Vec<f32>,
|
pending_audio: Vec<f32>,
|
||||||
dc_blocker: DcBlocker,
|
dc_blocker: DcBlocker,
|
||||||
buttons: u32,
|
|
||||||
/// The readout's blocked-direction cooldown (`docs/readout.md`), as the loop computes it: the
|
|
||||||
/// player's area and tile as of the last frame the adapter reported one, the channel the group is
|
|
||||||
/// holding, and the brain clock at which *either* of those last changed. A direction is only
|
|
||||||
/// blamed once it has been held for a whole `blocked_ms` with no movement, so a direction that
|
|
||||||
/// has just won is never blamed for a wall the previous one hit.
|
|
||||||
///
|
|
||||||
/// All three are transient and deliberately not checkpointed: one hold of a wall after a
|
|
||||||
/// restart is cheaper than a stale position surviving a restore.
|
|
||||||
location: Option<(u32, u32, u32)>,
|
|
||||||
held_channel: Option<String>,
|
|
||||||
blocked_since_ms: f64,
|
|
||||||
|
|
||||||
/// Palette mode (`docs/design/macros.md`), or `None` in raw mode — which is the default and
|
/// Palette mode (`docs/design/macros.md`), or `None` in raw mode — which is the default and
|
||||||
/// which is byte for byte the behaviour that predates it: every call site below is inside an
|
/// which is byte for byte the behaviour that predates it: every call site below is inside an
|
||||||
|
|
@ -385,6 +376,9 @@ pub struct Sim {
|
||||||
sugar_last_by: Option<String>,
|
sugar_last_by: Option<String>,
|
||||||
sugar_today: u64,
|
sugar_today: u64,
|
||||||
sugar_day: String,
|
sugar_day: String,
|
||||||
|
/// Every admitted sugar and operator pulse, frame-stamped, in the hot directory
|
||||||
|
/// (`crate::journal`): what a shadow run replays. Not checkpointed.
|
||||||
|
journal: SugarJournal,
|
||||||
|
|
||||||
/// The chat path. None of it is wired to the agent, the emulator or plasticity.
|
/// The chat path. None of it is wired to the agent, the emulator or plasticity.
|
||||||
chat_ring: ChatRing,
|
chat_ring: ChatRing,
|
||||||
|
|
@ -566,16 +560,14 @@ impl Sim {
|
||||||
writer_thread: None,
|
writer_thread: None,
|
||||||
next_generation: 1,
|
next_generation: 1,
|
||||||
best_archived_rank: None,
|
best_archived_rank: None,
|
||||||
remainder: 0.0,
|
// The per-frame trace, off unless `FLY_TRACE` names a file (`crate::trace`).
|
||||||
frame_counter: 0,
|
frame: LegacyFrame::new().with_trace(
|
||||||
frame_buffer: vec![0u8; FRAMEBUFFER_LEN],
|
FrameTrace::from_env()
|
||||||
|
.with_context(|| format!("creating the {} file", crate::trace::ENV))?,
|
||||||
|
),
|
||||||
pending_audio: Vec::new(),
|
pending_audio: Vec::new(),
|
||||||
dc_blocker: DcBlocker::default(),
|
dc_blocker: DcBlocker::default(),
|
||||||
buttons: 0,
|
|
||||||
status: FeedStatus::Booting,
|
status: FeedStatus::Booting,
|
||||||
location: None,
|
|
||||||
held_channel: None,
|
|
||||||
blocked_since_ms: 0.0,
|
|
||||||
pending_recovery: false,
|
pending_recovery: false,
|
||||||
seq: 0,
|
seq: 0,
|
||||||
started,
|
started,
|
||||||
|
|
@ -589,6 +581,7 @@ impl Sim {
|
||||||
sugar_last_by: None,
|
sugar_last_by: None,
|
||||||
sugar_today: 0,
|
sugar_today: 0,
|
||||||
sugar_day: utc_day(now_wall_ms()),
|
sugar_day: utc_day(now_wall_ms()),
|
||||||
|
journal: SugarJournal::new(&config.paths.hot_dir),
|
||||||
chat_ring: ChatRing::new(config.chat.ring),
|
chat_ring: ChatRing::new(config.chat.ring),
|
||||||
chat_limits: ChatLimiter::default(),
|
chat_limits: ChatLimiter::default(),
|
||||||
deny_list: if config.chat.enabled {
|
deny_list: if config.chat.enabled {
|
||||||
|
|
@ -686,7 +679,7 @@ impl Sim {
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
origin = %candidate.origin,
|
origin = %candidate.origin,
|
||||||
frame = self.frame_counter,
|
frame = self.frame.frame_counter,
|
||||||
brain_ms = self.agent.network.ms,
|
brain_ms = self.agent.network.ms,
|
||||||
rank = self.rank,
|
rank = self.rank,
|
||||||
"restored"
|
"restored"
|
||||||
|
|
@ -749,46 +742,21 @@ impl Sim {
|
||||||
self.compatibility
|
self.compatibility
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if runtime.framebuffer.len() != FRAMEBUFFER_LEN {
|
|
||||||
bail!("checkpoint framebuffer is {} bytes", runtime.framebuffer.len());
|
|
||||||
}
|
|
||||||
// `import_state` is self-validating and a no-op on failure, so a refused checkpoint
|
// `import_state` is self-validating and a no-op on failure, so a refused checkpoint
|
||||||
// leaves the agent exactly as it was and the next candidate starts clean.
|
// leaves the agent exactly as it was and the next candidate starts clean.
|
||||||
self.agent
|
{
|
||||||
.import_state(&checkpoint.agent)
|
let mut parts = Parts {
|
||||||
.map_err(|error| anyhow!("{error}"))?;
|
agent: &mut self.agent,
|
||||||
self.emulator
|
emulator: &mut self.emulator,
|
||||||
.import_state(&runtime.emulator)
|
adapter: self.adapter.as_mut(),
|
||||||
.map_err(|error| anyhow!("{error}"))?;
|
ratchet: &mut self.ratchet,
|
||||||
if !runtime.reward.is_null() {
|
macros: self.macros.as_mut(),
|
||||||
self.adapter
|
};
|
||||||
.import_state(&runtime.reward)
|
self.frame.restore(&mut parts, &checkpoint)?;
|
||||||
.map_err(|error| anyhow!("{error}"))?;
|
|
||||||
}
|
}
|
||||||
let snapshot = if runtime.ratchet_game.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(flybrain_gb::ratchet::Snapshot {
|
|
||||||
game: runtime.ratchet_game.clone(),
|
|
||||||
frame: runtime.ratchet_frame.clone(),
|
|
||||||
})
|
|
||||||
};
|
|
||||||
self.ratchet
|
|
||||||
.import(Some(runtime.ratchet), snapshot, self.adapter.rank_ladder().len())
|
|
||||||
.map_err(|error| anyhow!("{error}"))?;
|
|
||||||
|
|
||||||
self.remainder = checkpoint.agent.remainder;
|
|
||||||
self.frame_counter = runtime.emulator_frame;
|
|
||||||
self.buttons = runtime.buttons;
|
|
||||||
self.rank_since_ms = runtime.rank_since_ms;
|
self.rank_since_ms = runtime.rank_since_ms;
|
||||||
self.frame_buffer.copy_from_slice(&runtime.framebuffer);
|
|
||||||
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
|
|
||||||
self.agent
|
|
||||||
.network
|
|
||||||
.set_visual_frame(&self.frame_buffer, width, height);
|
|
||||||
self.rank = self.adapter.progress().rank;
|
self.rank = self.adapter.progress().rank;
|
||||||
self.log.resume_from(runtime.last_event_id);
|
self.log.resume_from(runtime.last_event_id);
|
||||||
self.emulator.set_buttons(self.buttons as u8);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -796,16 +764,8 @@ impl Sim {
|
||||||
/// readout on the settled rates. Never after a restore, which already carries a settled
|
/// readout on the settled rates. Never after a restore, which already carries a settled
|
||||||
/// network and a calibrated decoder.
|
/// network and a calibrated decoder.
|
||||||
fn fresh_start(&mut self) -> Result<()> {
|
fn fresh_start(&mut self) -> Result<()> {
|
||||||
self.emulator
|
let raw = self.frame.initialize(&mut self.emulator, &mut self.agent)?;
|
||||||
.run_frame()
|
|
||||||
.map_err(|error| anyhow!("running the first frame: {error}"))?;
|
|
||||||
self.frame_buffer.copy_from_slice(self.emulator.framebuffer());
|
|
||||||
self.frame_counter = 1;
|
|
||||||
let raw = self.emulator.take_audio_u8();
|
|
||||||
self.dc_blocker.process_into(&raw, &mut self.pending_audio);
|
self.dc_blocker.process_into(&raw, &mut self.pending_audio);
|
||||||
self.agent
|
|
||||||
.warmup(Some(&self.frame_buffer))
|
|
||||||
.map_err(|error| anyhow!("{error}"))?;
|
|
||||||
self.emit(NewEvent::new(FeedEventKind::System, "Fresh start: the fly woke up"));
|
self.emit(NewEvent::new(FeedEventKind::System, "Fresh start: the fly woke up"));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -927,117 +887,27 @@ impl Sim {
|
||||||
self.deny_list.maybe_reload(Instant::now(), forced);
|
self.deny_list.maybe_reload(Instant::now(), forced);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One frame, in the prototype's order.
|
/// One frame, in the prototype's order (`crate::frame`).
|
||||||
fn step_frame(&mut self) -> Result<()> {
|
fn step_frame(&mut self) -> Result<()> {
|
||||||
// 2. Step the brain: 16 or 17 integer ticks, the remainder carried and checkpointed.
|
// Prepare through commit: ticks, decode, the executor's mask, one emulator frame, rewards,
|
||||||
self.remainder += self.agent.ms_per_frame;
|
// the scene and the location, then the stimulations and the reinforcement.
|
||||||
let steps = self.remainder.floor();
|
let transition = {
|
||||||
self.remainder -= steps;
|
let mut parts = Parts {
|
||||||
self.agent.network.step(steps as u64);
|
agent: &mut self.agent,
|
||||||
self.profiler.lap(Phase::Step);
|
emulator: &mut self.emulator,
|
||||||
if self.profiler.enabled() {
|
adapter: self.adapter.as_mut(),
|
||||||
self.profiler.absorb_brain(self.agent.network.timings());
|
ratchet: &mut self.ratchet,
|
||||||
self.agent.network.reset_timings();
|
macros: self.macros.as_mut(),
|
||||||
}
|
};
|
||||||
|
let mut laps = Laps { profiler: &mut self.profiler };
|
||||||
// 3. Decode, 4. apply the buttons.
|
self.frame.transition(&mut parts, &mut laps)?
|
||||||
// Not the mode string: the adapter decides what counts as boot, because a platformer needs
|
|
||||||
// the permissive Start variant in four of its five modes (`GameAdapter::boot`).
|
|
||||||
let boot = self.adapter.boot();
|
|
||||||
let ms = self.agent.network.ms;
|
|
||||||
let rates = self.agent.network.rates.clone();
|
|
||||||
// The readout's blocked-direction cooldown (`docs/readout.md`): the direction the group is
|
|
||||||
// holding, once the adapter's position has stood still for a whole `blocked_ms`. The loop
|
|
||||||
// owns the clock and the position; the decoder only learns *which* channel did nothing.
|
|
||||||
// `blocked_ms == 0` -- the platformer preset, and the Game Boy preset before v0.1.1 --
|
|
||||||
// switches the rule off here, before the decoder is asked.
|
|
||||||
let blocked_ms = self.agent.decoder.blocked_ms();
|
|
||||||
let blocked = (blocked_ms > 0.0 && ms - self.blocked_since_ms >= blocked_ms)
|
|
||||||
.then(|| self.agent.decoder.current())
|
|
||||||
.flatten()
|
|
||||||
.map(str::to_string);
|
|
||||||
// The scene's own macro buttons, for the macro group's per-decision mask
|
|
||||||
// (`docs/design/macros.md` section 12: "unbound channels are masked from the decision").
|
|
||||||
// They are the bindings the previous frame's `observe` dealt, which is the palette the
|
|
||||||
// page is showing, so the fly is choosing among exactly the buttons the audience can see.
|
|
||||||
// `None` in raw mode, where the group has no channels to mask anyway.
|
|
||||||
let bound = self.macros.as_ref().map(MacroLayer::bound_channels);
|
|
||||||
let active = self.agent.decoder.decode_bound(
|
|
||||||
&rates,
|
|
||||||
ms,
|
|
||||||
boot,
|
|
||||||
blocked.as_deref(),
|
|
||||||
bound.as_deref(),
|
|
||||||
);
|
|
||||||
// A new winner starts its own window: it has not had a hold to move in yet.
|
|
||||||
let held = self.agent.decoder.current().map(str::to_string);
|
|
||||||
if held != self.held_channel {
|
|
||||||
self.held_channel = held;
|
|
||||||
self.blocked_since_ms = ms;
|
|
||||||
}
|
|
||||||
self.buttons = to_button_mask(&active);
|
|
||||||
// Macros mode (`docs/design/macros.md` sections 4 and 12): the same decode, plus the
|
|
||||||
// macro group whose winner is in `active` alongside the buttons. The mask that reaches
|
|
||||||
// the emulator is the running macro's, or nothing, or -- on the title screen alone -- the
|
|
||||||
// raw mask above. In raw mode `self.macros` is `None` and not one line of this runs.
|
|
||||||
let started_or_finished = match self.macros.as_mut() {
|
|
||||||
// Two disjoint fields of the same struct, so the layer can read the emulator while
|
|
||||||
// the loop still owns both. Taking the layer out and putting it back would leave
|
|
||||||
// raw mode running silently if anything in between ever panicked.
|
|
||||||
Some(layer) => {
|
|
||||||
// Three disjoint fields: the layer decides, the emulator is read, and the
|
|
||||||
// adapter's exploration ledger answers the ways out' "unvisited" — read-only, by
|
|
||||||
// `&dyn`, and the only thing the palette is told about the reward side.
|
|
||||||
let ledger = AdapterLedger(self.adapter.as_ref());
|
|
||||||
let decision = layer.decide(
|
|
||||||
&active,
|
|
||||||
self.buttons,
|
|
||||||
ms,
|
|
||||||
&mut self.emulator,
|
|
||||||
&ledger,
|
|
||||||
);
|
|
||||||
self.buttons = decision.mask;
|
|
||||||
decision.events
|
|
||||||
}
|
|
||||||
None => Vec::new(),
|
|
||||||
};
|
};
|
||||||
self.emit_macro_events(&started_or_finished);
|
|
||||||
self.emulator.set_buttons(self.buttons as u8);
|
|
||||||
self.profiler.lap(Phase::Decode);
|
|
||||||
|
|
||||||
// 5. Run one emulator frame, 6. set the visual frame from it.
|
|
||||||
self.emulator
|
|
||||||
.run_frame()
|
|
||||||
.map_err(|error| anyhow!("frame {}: {error}", self.frame_counter + 1))?;
|
|
||||||
self.frame_counter += 1;
|
|
||||||
Metrics::incr(&self.shared.metrics.sim_frames);
|
Metrics::incr(&self.shared.metrics.sim_frames);
|
||||||
self.profiler.lap(Phase::Emulate);
|
self.dc_blocker.process_into(&transition.audio, &mut self.pending_audio);
|
||||||
self.frame_buffer.copy_from_slice(self.emulator.framebuffer());
|
// The feed events, in the order the phases produced them: the executor's starts and
|
||||||
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
|
// finishes, the rewards, then the observation's abandonment.
|
||||||
self.agent
|
self.emit_macro_events(&transition.executed.events);
|
||||||
.network
|
for event in &transition.evaluated.rewards {
|
||||||
.set_visual_frame(&self.frame_buffer, width, height);
|
|
||||||
let raw = self.emulator.take_audio_u8();
|
|
||||||
self.dc_blocker.process_into(&raw, &mut self.pending_audio);
|
|
||||||
self.profiler.lap(Phase::Retina);
|
|
||||||
|
|
||||||
// 7. Sample rewards from the frame just produced.
|
|
||||||
let ms = self.agent.network.ms;
|
|
||||||
let events = {
|
|
||||||
let (adapter, emulator) = (&mut self.adapter, &mut self.emulator);
|
|
||||||
adapter.sample(emulator, ms)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 8. Stimulate once per event, 9. reinforce with the sum.
|
|
||||||
let mut total = 0.0;
|
|
||||||
for event in &events {
|
|
||||||
self.agent.network.stimulate(f64::from(event.stimulation_ms));
|
|
||||||
total += event.value;
|
|
||||||
}
|
|
||||||
if self.agent.network.plasticity.enabled {
|
|
||||||
self.agent.network.plasticity.reinforce(total, ms);
|
|
||||||
}
|
|
||||||
for event in &events {
|
|
||||||
let kind = RewardKind::from_adapter(event.kind);
|
let kind = RewardKind::from_adapter(event.kind);
|
||||||
let mut new = NewEvent::new(FeedEventKind::Reward, event.label.clone())
|
let mut new = NewEvent::new(FeedEventKind::Reward, event.label.clone())
|
||||||
.value(event.value);
|
.value(event.value);
|
||||||
|
|
@ -1046,68 +916,27 @@ impl Sim {
|
||||||
}
|
}
|
||||||
self.emit(new);
|
self.emit(new);
|
||||||
}
|
}
|
||||||
|
self.emit_macro_events(&transition.evaluated.abandoned);
|
||||||
|
|
||||||
self.profiler.lap(Phase::Rewards);
|
// The milestone archive sits here, after the commit and before the ratchet captures,
|
||||||
|
// which is the legacy order legacy-gameboy-v1 section 4 declares.
|
||||||
// `docs/design/macros.md` section 2: the scene is sampled once per game frame, after the
|
let ms = transition.ms;
|
||||||
// frame. So the palette the fly is offered on the next frame is the one for the frame it
|
let progress = transition.evaluated.progress;
|
||||||
// can actually see, and the feed's `game.scene` is never a frame ahead of the screen.
|
|
||||||
let abandoned = match self.macros.as_mut() {
|
|
||||||
Some(layer) => {
|
|
||||||
let ledger = AdapterLedger(self.adapter.as_ref());
|
|
||||||
layer.observe(&mut self.emulator, &ledger, ms)
|
|
||||||
}
|
|
||||||
None => Vec::new(),
|
|
||||||
};
|
|
||||||
// At most one: a macro that has run into a scene with no palette, abandoned before the
|
|
||||||
// header this frame publishes can show it beside that scene.
|
|
||||||
self.emit_macro_events(&abandoned);
|
|
||||||
|
|
||||||
// The cooldown's other reset: the player actually moved. `None` -- a battle, a script, a
|
|
||||||
// map transition -- is no information rather than "still", so the rule cannot fire while
|
|
||||||
// the fly has no control anyway.
|
|
||||||
let location = self.adapter.location();
|
|
||||||
if location.is_some() && location != self.location {
|
|
||||||
self.location = location;
|
|
||||||
self.blocked_since_ms = ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10. Ratchet: observe, and recover if it says so.
|
|
||||||
let progress = self.adapter.progress();
|
|
||||||
self.track_rank(&progress, ms);
|
self.track_rank(&progress, ms);
|
||||||
let safe = self.adapter.safe_for_snapshot();
|
|
||||||
let capture_due = safe && u64::from(progress.rank) > self.ratchet.state.best;
|
// `Ready(k+1)`: the ratchet captures, observes, and rolls the game back if it says so.
|
||||||
let captured = if capture_due {
|
let boundary = {
|
||||||
Some(flybrain_gb::ratchet::Snapshot {
|
let mut parts = Parts {
|
||||||
game: self
|
agent: &mut self.agent,
|
||||||
.emulator
|
emulator: &mut self.emulator,
|
||||||
.export_state()
|
adapter: self.adapter.as_mut(),
|
||||||
.map_err(|error| anyhow!("capturing a ratchet snapshot: {error}"))?,
|
ratchet: &mut self.ratchet,
|
||||||
frame: self.frame_buffer.clone(),
|
macros: self.macros.as_mut(),
|
||||||
})
|
};
|
||||||
} else {
|
self.frame.boundary(&mut parts, &progress, ms)?
|
||||||
None
|
|
||||||
};
|
};
|
||||||
// The stall window's second progress signal (`docs/design/ladder.md`, the 2026-09-17
|
if let Some(rollback) = boundary.rollback {
|
||||||
// rule as amended 2026-09-22): coverage is ground never stood on, and a fly crossing a
|
self.recovered(rollback.trigger, &rollback.events);
|
||||||
// town it has already covered to reach the rung's own door earns none of it while it is
|
|
||||||
// plainly getting somewhere. The macro layer answers with the map graph it already walks
|
|
||||||
// (`docs/design/macros.md` section 12.15); in raw mode there is no layer and no
|
|
||||||
// objective, and the answer is false.
|
|
||||||
let nearer = self.macros.as_ref().is_some_and(MacroLayer::nearer_the_objective);
|
|
||||||
let recover = self.ratchet.observe_with_progress(
|
|
||||||
safe,
|
|
||||||
u64::from(progress.rank),
|
|
||||||
progress.unique_locations as u64,
|
|
||||||
ms as u64,
|
|
||||||
self.adapter.game_over(),
|
|
||||||
nearer,
|
|
||||||
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
|
|
||||||
);
|
|
||||||
if recover {
|
|
||||||
// Two triggers, two stories on the ticker: a game over ended the run, a stall did not.
|
|
||||||
let reason = if self.adapter.game_over() { "Game over" } else { "Stuck" };
|
|
||||||
self.recover(reason)?;
|
|
||||||
}
|
}
|
||||||
self.profiler.lap(Phase::Ratchet);
|
self.profiler.lap(Phase::Ratchet);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -1140,32 +969,14 @@ impl Sim {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn recover(&mut self, reason: &str) -> Result<()> {
|
/// The host's half of a rollback the frame has already applied: the ticker, the metric, the
|
||||||
let snapshot = flybrain_gb::ratchet::Snapshot {
|
/// `recovering` status and a durable checkpoint.
|
||||||
game: self
|
fn recovered(&mut self, trigger: RollbackTrigger, events: &[crate::macros::MacroEvent]) {
|
||||||
.ratchet
|
// Two triggers, two stories on the ticker: a game over ended the run, a stall did not.
|
||||||
.game()
|
let reason = match trigger {
|
||||||
.ok_or_else(|| anyhow!("the ratchet asked to recover with no snapshot"))?
|
RollbackTrigger::GameOver => "Game over",
|
||||||
.to_vec(),
|
RollbackTrigger::Stall => "Stuck",
|
||||||
frame: self
|
|
||||||
.ratchet
|
|
||||||
.frame()
|
|
||||||
.ok_or_else(|| anyhow!("the ratchet snapshot has no framebuffer"))?
|
|
||||||
.to_vec(),
|
|
||||||
};
|
};
|
||||||
let frame = {
|
|
||||||
let mut neural = AgentRecovery { agent: &mut self.agent };
|
|
||||||
recover_game(
|
|
||||||
&mut self.emulator,
|
|
||||||
self.adapter.as_mut(),
|
|
||||||
&mut neural,
|
|
||||||
&snapshot,
|
|
||||||
)
|
|
||||||
.map_err(|error| anyhow!("recovering the game: {error}"))?
|
|
||||||
};
|
|
||||||
self.frame_buffer.copy_from_slice(&frame);
|
|
||||||
self.buttons = 0;
|
|
||||||
self.emulator.set_buttons(0);
|
|
||||||
Metrics::incr(&self.shared.metrics.recoveries_total);
|
Metrics::incr(&self.shared.metrics.recoveries_total);
|
||||||
let attempts = self.ratchet.state.attempts;
|
let attempts = self.ratchet.state.attempts;
|
||||||
self.emit(
|
self.emit(
|
||||||
|
|
@ -1178,31 +989,11 @@ impl Sim {
|
||||||
)
|
)
|
||||||
.value(f64::from(self.rank)),
|
.value(f64::from(self.rank)),
|
||||||
);
|
);
|
||||||
self.location = self.adapter.location();
|
self.emit_macro_events(events);
|
||||||
self.held_channel = None;
|
|
||||||
self.blocked_since_ms = self.agent.network.ms;
|
|
||||||
// A rollback restores a game the running macro's plan was never made for, so the macro is
|
|
||||||
// abandoned here rather than carried over a map change it cannot see.
|
|
||||||
let abandoned = match self.macros.as_mut() {
|
|
||||||
Some(layer) => {
|
|
||||||
let events = layer.cancel(self.agent.network.ms);
|
|
||||||
// The frame's `observe` ran before the ratchet decided to roll back, so the scene
|
|
||||||
// and the palette describe the run that was just thrown away. The restored game
|
|
||||||
// is in WRAM now, so re-detect here rather than let the next frame's decision be
|
|
||||||
// made against a map the fly is no longer standing on.
|
|
||||||
let ledger = AdapterLedger(self.adapter.as_ref());
|
|
||||||
let mut events = events;
|
|
||||||
events.extend(layer.observe(&mut self.emulator, &ledger, self.agent.network.ms));
|
|
||||||
events
|
|
||||||
}
|
|
||||||
None => Vec::new(),
|
|
||||||
};
|
|
||||||
self.emit_macro_events(&abandoned);
|
|
||||||
self.pending_recovery = true;
|
self.pending_recovery = true;
|
||||||
if let Err(error) = self.checkpoint(true, None) {
|
if let Err(error) = self.checkpoint(true, None) {
|
||||||
tracing::error!(%error, "could not checkpoint after a recovery");
|
tracing::error!(%error, "could not checkpoint after a recovery");
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- commands ------------------------------------------------------------------------
|
// -- commands ------------------------------------------------------------------------
|
||||||
|
|
@ -1292,6 +1083,9 @@ impl Sim {
|
||||||
.clamp(1.0, self.shared.config.control.sugar_max_ms)
|
.clamp(1.0, self.shared.config.control.sugar_max_ms)
|
||||||
.min(self.shared.config.control.sugar_max_ms);
|
.min(self.shared.config.control.sugar_max_ms);
|
||||||
self.agent.network.stimulate(duration);
|
self.agent.network.stimulate(duration);
|
||||||
|
if let Some(trace) = self.frame.trace_mut() {
|
||||||
|
trace.sugar(duration);
|
||||||
|
}
|
||||||
|
|
||||||
let day = utc_day(now_ms);
|
let day = utc_day(now_ms);
|
||||||
if day != self.sugar_day {
|
if day != self.sugar_day {
|
||||||
|
|
@ -1304,6 +1098,15 @@ impl Sim {
|
||||||
let event = self.emit(NewEvent::new(FeedEventKind::Sugar, sugar_label(by))
|
let event = self.emit(NewEvent::new(FeedEventKind::Sugar, sugar_label(by))
|
||||||
.by(by)
|
.by(by)
|
||||||
.value(duration));
|
.value(duration));
|
||||||
|
self.journal.record(&crate::journal::Entry {
|
||||||
|
frame: self.frame.frame_counter,
|
||||||
|
brain_ms: self.agent.network.ms,
|
||||||
|
input: Input::Sugar { duration_ms: duration },
|
||||||
|
by,
|
||||||
|
source,
|
||||||
|
event_id: event.id,
|
||||||
|
wall_ms: event.wall_ms,
|
||||||
|
});
|
||||||
tracing::info!(by, source, duration_ms = duration, "sugar accepted");
|
tracing::info!(by, source, duration_ms = duration, "sugar accepted");
|
||||||
Ok(event.id)
|
Ok(event.id)
|
||||||
}
|
}
|
||||||
|
|
@ -1313,13 +1116,25 @@ impl Sim {
|
||||||
fn reward(&mut self, value: f64, by: &str, source: &str) -> u64 {
|
fn reward(&mut self, value: f64, by: &str, source: &str) -> u64 {
|
||||||
let ms = self.agent.network.ms;
|
let ms = self.agent.network.ms;
|
||||||
self.agent.network.plasticity.reinforce(value, ms);
|
self.agent.network.plasticity.reinforce(value, ms);
|
||||||
|
if let Some(trace) = self.frame.trace_mut() {
|
||||||
|
trace.reward_pulse(value);
|
||||||
|
}
|
||||||
tracing::info!(by, source, value, "reward pulse applied");
|
tracing::info!(by, source, value, "reward pulse applied");
|
||||||
self.emit(
|
let event = self.emit(
|
||||||
NewEvent::new(FeedEventKind::Reward, format!("{by} sent a reward pulse ({value})"))
|
NewEvent::new(FeedEventKind::Reward, format!("{by} sent a reward pulse ({value})"))
|
||||||
.by(by)
|
.by(by)
|
||||||
.value(value),
|
.value(value),
|
||||||
)
|
);
|
||||||
.id
|
self.journal.record(&crate::journal::Entry {
|
||||||
|
frame: self.frame.frame_counter,
|
||||||
|
brain_ms: ms,
|
||||||
|
input: Input::Reward { value },
|
||||||
|
by,
|
||||||
|
source,
|
||||||
|
event_id: event.id,
|
||||||
|
wall_ms: event.wall_ms,
|
||||||
|
});
|
||||||
|
event.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /chat`: the on-screen chat path, enforced here rather than trusted from the bridge.
|
/// `POST /chat`: the on-screen chat path, enforced here rather than trusted from the bridge.
|
||||||
|
|
@ -1390,6 +1205,7 @@ impl Sim {
|
||||||
if let Err(error) = self.checkpoint_blocking(true, None) {
|
if let Err(error) = self.checkpoint_blocking(true, None) {
|
||||||
tracing::error!(%error, "the final checkpoint failed");
|
tracing::error!(%error, "the final checkpoint failed");
|
||||||
}
|
}
|
||||||
|
self.frame.finish_trace();
|
||||||
if let Err(error) = self.log.flush() {
|
if let Err(error) = self.log.flush() {
|
||||||
tracing::error!(%error, "the final event log flush failed");
|
tracing::error!(%error, "the final event log flush failed");
|
||||||
}
|
}
|
||||||
|
|
@ -1499,6 +1315,10 @@ impl Sim {
|
||||||
durable && self.best_archived_rank.is_none_or(|best| *rank > best)
|
durable && self.best_archived_rank.is_none_or(|best| *rank > best)
|
||||||
});
|
});
|
||||||
let (generation, agent, runtime) = self.snapshot_state()?;
|
let (generation, agent, runtime) = self.snapshot_state()?;
|
||||||
|
let step = self.frame.frame_counter;
|
||||||
|
if let Some(trace) = self.frame.trace_mut() {
|
||||||
|
trace.capture(generation, step);
|
||||||
|
}
|
||||||
if let Some(rank) = archive_rank {
|
if let Some(rank) = archive_rank {
|
||||||
self.best_archived_rank = Some(rank);
|
self.best_archived_rank = Some(rank);
|
||||||
}
|
}
|
||||||
|
|
@ -1547,15 +1367,15 @@ impl Sim {
|
||||||
let mut agent_state = self.agent.export_state();
|
let mut agent_state = self.agent.export_state();
|
||||||
// The sim loop owns the frame remainder, not `NeuralAgent::tick`, so the exported state
|
// The sim loop owns the frame remainder, not `NeuralAgent::tick`, so the exported state
|
||||||
// carries the loop's value.
|
// carries the loop's value.
|
||||||
agent_state.remainder = self.remainder;
|
agent_state.remainder = self.frame.remainder;
|
||||||
let runtime = RuntimeState {
|
let runtime = RuntimeState {
|
||||||
generation,
|
generation,
|
||||||
wall_ms: now_wall_ms(),
|
wall_ms: now_wall_ms(),
|
||||||
rom_sha256: self.rom_sha256.clone(),
|
rom_sha256: self.rom_sha256.clone(),
|
||||||
emulator_frame: self.frame_counter,
|
emulator_frame: self.frame.frame_counter,
|
||||||
compatibility: self.compatibility.clone(),
|
compatibility: self.compatibility.clone(),
|
||||||
speed: self.shared.config.loop_.speed,
|
speed: self.shared.config.loop_.speed,
|
||||||
buttons: self.buttons,
|
buttons: self.frame.buttons,
|
||||||
rank_since_ms: self.rank_since_ms,
|
rank_since_ms: self.rank_since_ms,
|
||||||
last_event_id: self.log.next_id().saturating_sub(1),
|
last_event_id: self.log.next_id().saturating_sub(1),
|
||||||
reward: self.adapter.export_state(),
|
reward: self.adapter.export_state(),
|
||||||
|
|
@ -1564,7 +1384,7 @@ impl Sim {
|
||||||
.emulator
|
.emulator
|
||||||
.export_state()
|
.export_state()
|
||||||
.map_err(|error| anyhow!("exporting the Game Boy: {error}"))?,
|
.map_err(|error| anyhow!("exporting the Game Boy: {error}"))?,
|
||||||
framebuffer: self.frame_buffer.clone(),
|
framebuffer: self.frame.frame_buffer.clone(),
|
||||||
ratchet_game: self.ratchet.game().map(<[u8]>::to_vec).unwrap_or_default(),
|
ratchet_game: self.ratchet.game().map(<[u8]>::to_vec).unwrap_or_default(),
|
||||||
ratchet_frame: self.ratchet.frame().map(<[u8]>::to_vec).unwrap_or_default(),
|
ratchet_frame: self.ratchet.frame().map(<[u8]>::to_vec).unwrap_or_default(),
|
||||||
};
|
};
|
||||||
|
|
@ -1637,7 +1457,7 @@ impl Sim {
|
||||||
let audio = f32_bytes(&self.pending_audio);
|
let audio = f32_bytes(&self.pending_audio);
|
||||||
self.pending_audio.clear();
|
self.pending_audio.clear();
|
||||||
(
|
(
|
||||||
Arc::new(self.frame_buffer.clone()),
|
Arc::new(self.frame.frame_buffer.clone()),
|
||||||
Arc::new(audio),
|
Arc::new(audio),
|
||||||
Arc::new(bitset),
|
Arc::new(bitset),
|
||||||
count,
|
count,
|
||||||
|
|
@ -1663,8 +1483,8 @@ impl Sim {
|
||||||
uptime_seconds: self.started.elapsed().as_secs_f64(),
|
uptime_seconds: self.started.elapsed().as_secs_f64(),
|
||||||
run_seconds: finite(ms / 1000.0).max(0.0),
|
run_seconds: finite(ms / 1000.0).max(0.0),
|
||||||
brain_ms: finite(ms).max(0.0),
|
brain_ms: finite(ms).max(0.0),
|
||||||
frame: self.frame_counter,
|
frame: self.frame.frame_counter,
|
||||||
buttons: self.buttons & 0xff,
|
buttons: self.frame.buttons & 0xff,
|
||||||
rates,
|
rates,
|
||||||
population_rate: finite(self.agent.network.population_rate).max(0.0),
|
population_rate: finite(self.agent.network.population_rate).max(0.0),
|
||||||
spike_count,
|
spike_count,
|
||||||
|
|
|
||||||
385
services/flysim/crates/flysim/src/trace.rs
Normal file
385
services/flysim/crates/flysim/src/trace.rs
Normal 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" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
services/flysim/crates/flysim/tests/frame_trace.rs
Normal file
93
services/flysim/crates/flysim/tests/frame_trace.rs
Normal 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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:?}");
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue