Compare commits
No commits in common. "main" and "v0.5.5" have entirely different histories.
58 changed files with 1787 additions and 7008 deletions
|
|
@ -89,9 +89,7 @@ decoder preset to use.
|
||||||
disassembly at commit 0cd19d3 (`symbols.rs`), gates rewards on a playable state, baselines already
|
disassembly at commit 0cd19d3 (`symbols.rs`), gates rewards on a playable state, baselines already
|
||||||
achieved flags on the first sample so a restore never replays them, and pays only positive
|
achieved flags on the first sample so a restore never replays them, and pays only positive
|
||||||
rewards: story flags, exploration coverage (capped per map), new areas, Pokédex entries, trainer
|
rewards: story flags, exploration coverage (capped per map), new areas, Pokédex entries, trainer
|
||||||
flags, decaying wild wins, badges, exits found outdoors, catches, conversations indoors and items
|
flags, decaying wild wins, badges. Version `pokered-unique8-v5`.
|
||||||
picked up (`docs/rewards-learning.md`). Version `pokered-unique8-v7`; a deploy that names the
|
|
||||||
previous version in `FLY_ACCEPT_ADAPTERS` migrates its checkpoints instead of refusing them.
|
|
||||||
- Ratchet (`ratchet.rs`): a 38-rung ladder (boot, bedroom, Pallet Town, Oak's lab, starter, parcel,
|
- Ratchet (`ratchet.rs`): a 38-rung ladder (boot, bedroom, Pallet Town, Oak's lab, starter, parcel,
|
||||||
Pokédex, each town, each badge, the Elite Four, Champion). On first reaching a higher rung in a
|
Pokédex, each town, each badge, the Elite Four, Champion). On first reaching a higher rung in a
|
||||||
safe state it archives the emulator snapshot; on a stall (120 s without new exploration) or a game
|
safe state it archives the emulator snapshot; on a stall (120 s without new exploration) or a game
|
||||||
|
|
|
||||||
|
|
@ -387,17 +387,6 @@ on-screen ticker cannot disagree with what the sim did.
|
||||||
absent from a `v5` state and restores empty, which is the truth about a run that was never paid
|
absent from a `v5` state and restores empty, which is the truth about a run that was never paid
|
||||||
for a catch. `STATE_VERSION` does not move, because the schema did not.
|
for a catch. `STATE_VERSION` does not move, because the schema did not.
|
||||||
|
|
||||||
**`v6` -> `v7`** (2026-09-23, the engagement rewards) is the same migration for the next pair,
|
|
||||||
and `pokered-unique8-v7`'s `migrates_from()` is `["pokered-unique8-v6"]` and nothing else -- `v5`
|
|
||||||
is no longer migrated, because the live run is `v6`. The deploy that ships it sets
|
|
||||||
`FLY_ACCEPT_ADAPTERS=pokered-unique8-v6`. No field is added this time: `talk` and `item` key
|
|
||||||
their ledgers into the existing `seen` array, as `boundary` did, so a `v6` state restores
|
|
||||||
unchanged with no `talk:` keys. The one step is at the first sample after the restore, not in
|
|
||||||
`import_state`: a ledger without the `items:seeded` key writes an `item:`/`hidden:` key for every
|
|
||||||
item the cartridge already shows as taken, pays for none, and marks the seed, so a rollback to a
|
|
||||||
slot from before a `v6`-era pickup cannot pay for it (`docs/rewards-learning.md`, "The seed").
|
|
||||||
`STATE_VERSION` stays 4.
|
|
||||||
|
|
||||||
- **Restarting a run from an earlier rung** (2026-09-22). `FLY_RESET_STATE=1` throws the run away;
|
- **Restarting a run from an earlier rung** (2026-09-22). `FLY_RESET_STATE=1` throws the run away;
|
||||||
`infra/bin/fly-reset-to-milestone <N>` keeps it and rewinds it. It archives both stores to a
|
`infra/bin/fly-reset-to-milestone <N>` keeps it and rewinds it. It archives both stores to a
|
||||||
dated directory, rewrites `milestone-<N>.checkpoint` with the ratchet's `attempts` and
|
dated directory, rewrites `milestone-<N>.checkpoint` with the ratchet's `attempts` and
|
||||||
|
|
|
||||||
|
|
@ -133,28 +133,6 @@ a trade and a Pokémon withdrawn from the PC. Reading a catch off it would need
|
||||||
tell those apart. The cartridge's own flag needs none, which is why the row above is the one the
|
tell those apart. The cartridge's own flag needs none, which is why the row above is the one the
|
||||||
adapter reads.
|
adapter reads.
|
||||||
|
|
||||||
### The engagement rewards' reads (2026-09-23)
|
|
||||||
|
|
||||||
**New 2026-09-23** (`talk` and `item`, `docs/rewards-learning.md`, "Engagement rewards"). All six
|
|
||||||
were resolved by `services/flysim/tools/resolve_wram.py` from `ram/wram.asm` at the pinned commit
|
|
||||||
and are bracketed by addresses `symbols.rs` already carried; two of the brackets needed the tool
|
|
||||||
to count `NUM_STATS` and `NUM_CITY_MAPS`, which the decomp defines as `const_value` over an
|
|
||||||
enumeration.
|
|
||||||
|
|
||||||
| what | symbol | address | notes | verified |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| the text's subject | `wSpriteIndex` | `$cf13` | `DisplayTextID` copies its argument here: a sprite slot up to `wNumSprites`, else a text id. It arrives **about twenty frames after** `wFontLoaded` bit 0 rises, because `DisplayTextIDInit` loads the font's tiles first; until then it still holds the previous text's subject. | survey (`tests/rom_engage.rs`: the Viridian Forest north gate, the old man at slot 2, font bit on frame 820 and the argument on frame 840) |
|
|
||||||
| mid-step | `wWalkCounter` | `$cfc5` | non-zero for the frames of a step; the overworld only reads A at zero. Right after `wFontLoaded` in `ram/wram.asm`. | ROM, trace |
|
|
||||||
| an item ball's item | `wMapSpriteExtraData` | `$d504` | two bytes per sprite slot (slot 1 first): `(item id, 0)` for an `ITEM` `object_event`, `(trainer class, trainer number)` for a `TRAINER` one, zeroes otherwise -- `LoadMapHeader`'s `.itemBallSprite` / `.trainerSprite` / `.regularSprite` | survey (the forest's Antidote ball read `(11, 0)`) |
|
|
||||||
| taken or hidden, per object | `wToggleableObjectFlags` | `$d5a6` | `flag_array $100`, one bit per global toggleable index (`constants/toggle_constants.asm`); `PickUpItem`'s `HideObject` sets an item ball's bit after `GiveItem` succeeded | survey (the forest's Antidote ball's bit rose on the pickup frame) |
|
|
||||||
| this map's toggleables | `wToggleableObjectList` | `$d5ce` | up to sixteen `(sprite slot, global index)` pairs, `$ff`-terminated, written by `MarkTownVisitedAndLoadToggleableObjects` | survey |
|
|
||||||
| hidden items found | `wObtainedHiddenItemsFlags` | `$d6f0` | `flag_array MAX_HIDDEN_ITEMS` (112); `FoundHiddenItemText` sets the bit after `GiveItem` succeeded, and nothing else writes it | ROM (disassembly), trace |
|
|
||||||
|
|
||||||
Not used, and why: `hJoyPressed`/`hJoyHeld` would say "A was pressed" directly, but they are HRAM,
|
|
||||||
which neither `gen_symbols.py` nor `resolve_wram.py` resolves, and a hand-written address is the one
|
|
||||||
thing those tools exist to refuse. "The fly had the joypad and was standing still on the frame
|
|
||||||
before the box opened, and the box is about the thing it faces" is the same fact read out of WRAM.
|
|
||||||
|
|
||||||
### Battle menu and cursor, own turn against forced switch
|
### Battle menu and cursor, own turn against forced switch
|
||||||
|
|
||||||
`HandleMenuInput` is shared by every menu in the game, so which menu is up is read from where it
|
`HandleMenuInput` is shared by every menu in the game, so which menu is up is read from where it
|
||||||
|
|
@ -953,45 +931,3 @@ where that layout puts them, so the byte is `wBattleType - 1` = `$d059`, asserte
|
||||||
neighbours in `scene/tests.rs`. Measured on the cartridge in the Pewter Gym: `$00` in the
|
neighbours in `scene/tests.rs`. Measured on the cartridge in the Pewter Gym: `$00` in the
|
||||||
overworld, `$cd` (`OPP_JR_TRAINER_M`) from the last box of the trainer's challenge through the
|
overworld, `$cd` (`OPP_JR_TRAINER_M`) from the last box of the trainer's challenge through the
|
||||||
219-frame transition and the battle. `controllable` reads it as zero.
|
219-frame transition and the battle. `controllable` reads it as zero.
|
||||||
|
|
||||||
## 13. What a move will do: the move table and the bytes its effect reads (2026-09-23, `docs/design/macros.md` 12.23)
|
|
||||||
|
|
||||||
Row 60. Seven addresses, resolved by `tools/resolve_wram.py` and emitted into `symbols.rs`; the
|
|
||||||
tool now follows the decomp's `const` and `_RS` counters and a struct macro's field labels, which
|
|
||||||
is what reaches the `battle_struct` fields, and it re-derives 77 of 81 pinned addresses with no
|
|
||||||
disagreement.
|
|
||||||
|
|
||||||
| symbol | address | what reads it |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `wPlayerMonStatMods` | `$cd1a` | six stages, ATTACK DEFENSE SPEED SPECIAL ACCURACY EVASION; 1 is -6, 7 normal, 13 is +6 |
|
|
||||||
| `wEnemyMonStatMods` | `$cd2e` | the same for the enemy |
|
|
||||||
| `wEnemyMonStatus` | `$cfe9` | the enemy's status byte (`battle_struct` +4) |
|
|
||||||
| `wEnemyMonType1` | `$cfea` | and `Type2` after it |
|
|
||||||
| `wEnemyMonAttack` | `$cff6` | the enemy's modified ATTACK DEFENSE SPEED SPECIAL, big-endian words |
|
|
||||||
| `wBattleMonAttack` | `$d025` | the same for the fly's Pokémon |
|
|
||||||
| `wEnemyBattleStatus2` | `$d068` | bit 1 Mist, 4 substitute, 5 must recharge |
|
|
||||||
|
|
||||||
**The move table is ROM.** `Moves` opens `SECTION "Battle Engine 7"`, which `layout.link` places
|
|
||||||
first in bank `$0E`, so it is `$0E:$4000`, six bytes a row in move-id order from `POUND`:
|
|
||||||
animation (the move id itself), effect, power, type, accuracy, PP. `state::move_data` reads a row
|
|
||||||
through `MemoryReader::read_rom`, the cartridge image, and refuses a row whose first byte is not the
|
|
||||||
id asked for. Measured from the row-60 checkpoint: TACKLE (`$21`) effect `$00` power 35, TAIL
|
|
||||||
WHIP (`$27`) effect `$13` (`DEFENSE_DOWN1_EFFECT`) power 0.
|
|
||||||
|
|
||||||
**`state::move_without_effect`** answers only refusals decided before the roll, for a move with no
|
|
||||||
power, from `engine/battle/effects.asm` and `MoveHitTest`:
|
|
||||||
|
|
||||||
- `*_UP1` / `*_UP2`: the user's stage is 13, or (ATTACK..SPECIAL) the stat is 999;
|
|
||||||
- `*_DOWN1` / `*_DOWN2`: the target has Mist or a substitute, its stage is 1, or (ATTACK..SPECIAL)
|
|
||||||
the stat is 1 -- `StatModifierDownEffect` restores the stage and prints "Nothing happened!"
|
|
||||||
then, so a low-level target reaches it before -6;
|
|
||||||
- `SLEEP_EFFECT`: any status, unless the target must recharge;
|
|
||||||
- `POISON_EFFECT`: a substitute, any status, or a Poison type;
|
|
||||||
- `PARALYZE_EFFECT`: any status, or an Electric move against a Ground type.
|
|
||||||
|
|
||||||
`None` outside a battle, without a cartridge, or with a stage byte outside 1..13.
|
|
||||||
|
|
||||||
**Not covered** (the reading would be the same kind, and nothing early in the game reaches it):
|
|
||||||
Confuse Ray and Supersonic on a confused target, Leech Seed on a seeded or Grass target, Focus
|
|
||||||
Energy, Mist, Reflect and Light Screen already up, Disable on a disabled target, and a damaging
|
|
||||||
move the type chart makes "doesn't affect" (the chart is another ROM table).
|
|
||||||
|
|
|
||||||
|
|
@ -1505,151 +1505,6 @@ has a person to walk to where it had none, `GO OUT` is withheld by 12.5's own ru
|
||||||
frames that were never the fly's deal nothing. The decoder, the reward catalog, the adapter
|
frames that were never the fly's deal nothing. The decoder, the reward catalog, the adapter
|
||||||
version, the roles and the compatibility string are untouched.
|
version, the roles and the compatibility string are untouched.
|
||||||
|
|
||||||
### 12.23 A move the cartridge answers with nothing is not dealt beside one it does not (2026-09-23, row 60)
|
|
||||||
|
|
||||||
Live on v0.5.5, early game after the reset to milestone 1: Route 1, Squirtle L5 (TACKLE, TAIL
|
|
||||||
WHIP) against a wild Pidgey, "Nothing happened!" on the screen. Since the reset `MOVE 2` 183
|
|
||||||
times and `MOVE 1` once; the last reward 25 brain minutes before the checkpoint, one wild win in
|
|
||||||
the whole run. Check 10 flagged `unrewarded` (1,600 decisions, no reward event, no new ground on
|
|
||||||
two probes), which is right, and it is unchanged.
|
|
||||||
|
|
||||||
- **The pad was at fault, not only the choice.** `MOVE n`'s precondition was "the slot holds a
|
|
||||||
move with PP", so TAIL WHIP stayed on the pad after it had walked the Pidgey's DEFENSE to the
|
|
||||||
point the cartridge refuses it. `StatModifierDownEffect` answers "Nothing happened!" when the
|
|
||||||
stage is already -6 **or the stat itself is already 1**, restoring the stage. The checkpoint is
|
|
||||||
the frame Squirtle fainted to a Pidgey L3 at DEFENSE -6 (stat 2); in the next battle the stat
|
|
||||||
reached 1 at -5. From there the pad dealt `MOVE 1, MOVE 2, RUN` and the fly pressed `MOVE 2`
|
|
||||||
until Squirtle fainted, woke at home and walked back: every battle lost, one wild win in the
|
|
||||||
run. The readout's favourite being `MOVE 2` is the fly's; a button that can do nothing at all
|
|
||||||
being on the pad is a macro that knows nothing about its own effect, section 12.2's trap.
|
|
||||||
- **What a move does is the cartridge's, read the same way for every move.** `state::move_data`
|
|
||||||
reads the move's row of `Moves` from the cartridge image (`$0E:$4000`, each row checked against
|
|
||||||
its own id) and `state::move_without_effect` answers the refusals the effect routines make on
|
|
||||||
bytes already in WRAM: a stat stage at its limit or a stat at 1 or 999, Mist or a substitute in
|
|
||||||
front of a stat-lowering move, a sleep, poison or paralysis move against a target that already has
|
|
||||||
a status, is Poison type, or is Ground type to an Electric move. No move is named; a miss is a
|
|
||||||
roll and is not answered. `macros-wram.md` section 13 has the bytes.
|
|
||||||
- **It is PP's rule.** A move the cartridge answers with nothing is not dealt beside one it does
|
|
||||||
not, exactly as a spent move is not (12.6, 12.8), and `MOVE 1` over the menu stops being FIGHT's
|
|
||||||
backstop only in that case. When no move would do anything the moves stay as PP deals them:
|
|
||||||
taking the last ones away would leave an open list whose only button is `BACK`, 12.11's pair,
|
|
||||||
and a turn that ends on "Nothing happened!" still ends. RUN, ITEM and SWITCH are untouched.
|
|
||||||
|
|
||||||
Nothing is ranked, weighted or pressed for the fly: a button leaves the pad while it cannot change
|
|
||||||
anything and comes back when it can (a new battle resets the stages). The decoder, the reward
|
|
||||||
catalog, the adapter version, the roles and the compatibility string are untouched.
|
|
||||||
|
|
||||||
### 12.24 The map graph is the disassembly's, piece by piece (2026-09-23, row 59)
|
|
||||||
|
|
||||||
Opened pre-emptively: the row-58 review carried the route survey past the Boulder Badge and the
|
|
||||||
fly walked Pewter City (39, 17) to Route 3 (0, 9) and back from about frame 68,000, `GO OBJECTIVE`
|
|
||||||
done on Route 3 538 times and `GO ROUTE` done on Pewter 537. Reproduced from a rank-11 checkpoint
|
|
||||||
the survey writes (`FLY_PROBE_SAVE_RANK=11`): 509 and 508, never on Route 4. The live fly got
|
|
||||||
through Route 3 anyway and met the next half on v0.6.0 at 22:20 UTC: rank 12, MT. MOON, the
|
|
||||||
objective Cerulean, on Route 4 per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103,
|
|
||||||
two new tiles, in and out of the Pokécenter and the cave mouth. Route 4 was one node with Cerulean
|
|
||||||
off its east edge, which the mountain cuts off from the cave mouth's side. From the live
|
|
||||||
checkpoint the route survey on `main` walks it 930 times in 72,000 frames.
|
|
||||||
|
|
||||||
**The geography table disagreed with the headers.** Checked row by row against
|
|
||||||
`data/maps/headers/*.asm` and `data/maps/objects/*.asm` at the pinned commit:
|
|
||||||
|
|
||||||
- Route 4 is **north** of Route 3, not east (`Route3.asm`: `connection north, Route4`); Route 3's
|
|
||||||
top edge is the road to Mt. Moon's Pokécenter. Mt. Moon's doors are both **on Route 4**:
|
|
||||||
(18, 5) into the first floor and (24, 5) into B1F, whose (27, 3) is the way out. Route 3 has no
|
|
||||||
warps. So Route 3's north edge named no map, and nothing on Route 3 was the way to the rung.
|
|
||||||
- Route 14 / 15 and Route 24 / 25 had the right neighbour in the wrong column (west and east,
|
|
||||||
not south and north). Route 24's east edge is Nugget Bridge's far end, rung 16's road.
|
|
||||||
|
|
||||||
**Four maps are pieces the player cannot walk between** (12.7's rule, measured by flooding every
|
|
||||||
tile of every map on the graph from the blocks, blockset, collision list, tile-pair walls and
|
|
||||||
ledges): Route 2 as before; **Route 4**, cut by the mountain into the cave mouth's side and
|
|
||||||
Cerulean's side; **Mt. Moon B1F**, four chambers of two ladders each; **B2F**, one large piece and
|
|
||||||
two small ones. The one road through is 1F (5, 5), B1F (21, 17), B2F (5, 7), B1F (27, 3); the
|
|
||||||
other two ladders on 1F lead to dead ends.
|
|
||||||
|
|
||||||
A split row is now any number of pieces, each with its doors (the warp index and tile) and what is
|
|
||||||
one step from it, a whole map or another map's piece. Three rules keep it honest:
|
|
||||||
|
|
||||||
- **Which piece the fly is in** is what its walk can reach on the decoded grid (section 15): the
|
|
||||||
piece whose doors it reaches, when exactly one piece's are. The grid has no ledges, so where it
|
|
||||||
reaches none (Route 4 below the ledges) the nearest door answers. Flooded over every tile of
|
|
||||||
the four maps' ground in the disassembly, the rule names the right piece for all of them.
|
|
||||||
- **Which piece a door lands in** is the cartridge's own answer: a warp names the destination
|
|
||||||
warp it arrives at (`wWarpEntries` byte 2), and each piece lists its warps. An edge lands in
|
|
||||||
the piece that lists the map it is stepped off.
|
|
||||||
- **The hop is a piece**, and an exit is toward the objective only if it lands in that piece. On
|
|
||||||
1F three ladders go down to B1F and one of them is the road.
|
|
||||||
|
|
||||||
**A connection nobody can walk across is not a road.** Four header connections have no tile where
|
|
||||||
both sides are land: Pallet Town / Route 21, Cinnabar / Route 20, Route 20 / 19 (sea) and Route 22 /
|
|
||||||
23 (the League's fence). They keep their name and offer no exit and no hop. Without this the road
|
|
||||||
from Pallet Town to Cerulean was by sea, and a fly that whited out in Mt. Moon, which the survey's
|
|
||||||
did, walked into Pallet's shore every two seconds.
|
|
||||||
|
|
||||||
**One seam frame, found on the way.** Route 3's first trainer closes his challenge onto five frames
|
|
||||||
of plain overworld before `StartTrainerBattle` decides the battle (`home/trainers.asm`: it runs
|
|
||||||
after `DisplayTextID`'s close-down). Row 58's pending push-back was decided on the first of them
|
|
||||||
and walled (11, 6), the one gap between Route 3's west end and the rest of the road, for the
|
|
||||||
session. A push-back is now a refusal only once the overworld has been the fly's for thirty frames
|
|
||||||
running; a battle inside them drops it.
|
|
||||||
|
|
||||||
From the badge, the route survey reaches Route 4 at frame 70,356 and Mt. Moon at 70,707 (rung 12),
|
|
||||||
no pushed tile; after whiting out in the cave it walks the land road back from Pallet Town. The
|
|
||||||
ROM test on the stub rotation reaches Mt. Moon in 56.7 brain minutes with 4 Pewter / Route 3
|
|
||||||
crossings; the base makes 3,391 in 80.4 and never stands on Route 4. From the live 2026-09-22
|
|
||||||
rank-11 checkpoint the branch reaches Mt. Moon too, where the base ends fenced on Route 3. From
|
|
||||||
the live Route 4 checkpoint the branch is in the cave on frame 279 and stays on the road; on the
|
|
||||||
stub rotation Route 4's west doors are crossed 13 times in twenty brain minutes (whiteouts and
|
|
||||||
walks back included) against 56 on `main`.
|
|
||||||
|
|
||||||
Nothing is ranked and nothing presses for the fly: a table of maps says what the headers say, a
|
|
||||||
split map has the pieces its ground has, and a frame that was the cartridge's is not read as the
|
|
||||||
fly's. The decoder, the reward catalog, the adapter version, the roles and the compatibility
|
|
||||||
string are untouched.
|
|
||||||
|
|
||||||
### 12.25 A trainer's challenge is the cartridge's until its battle is over (2026-09-23, row 61)
|
|
||||||
|
|
||||||
Live on v0.5.5, rung 9, for twenty minutes: `GO OBJECTIVE` into Viridian Forest's south gate (map
|
|
||||||
50, `$32`), `GO OUT` straight back onto Route 2, `GO WARP` back from the forest, `GO OBJECTIVE
|
|
||||||
blocked` in the forest, no reward. Reproduced with the route survey from the live checkpoint
|
|
||||||
(uniform choice per hold, xorshift seed 7), which walks the same ring for twenty brain minutes.
|
|
||||||
|
|
||||||
- **The ring's cause was a wall, not the gate.** The forest's only road to its north gate is a
|
|
||||||
two-wide corridor at x = 1-2; a Bug Catcher stands on (2, 18) facing west. A walk up the
|
|
||||||
corridor steps onto (1, 18), the trainer takes the joypad, and row 58's held push-back waits for
|
|
||||||
the joypad to come back. `DisplayEnemyTrainerTextAndStartBattle` clears `wJoyIgnore` before the
|
|
||||||
challenge text and `StartTrainerBattle` writes `wCurOpponent` only after that text's close-down:
|
|
||||||
**five frames** with no box, no script bit and `wCurOpponent` zero, which the seam read as the
|
|
||||||
fly's overworld. The push was written there; (1, 18) went into the pushed ledger, which has no
|
|
||||||
window, and from then on every walk to the north gate had no road. `GO OBJECTIVE` walked to the
|
|
||||||
nearest reachable tile, a dead end at (6, 1), and was blocked; `GO WARP`'s last tier took the
|
|
||||||
south gate, whose `GO OUT` is Route 2, whose `GO OBJECTIVE` is the gate.
|
|
||||||
- **The fact is `wStatusFlags7` bit 3, `BIT_TRAINER_BATTLE`**: set by `CheckFightingMapTrainers`
|
|
||||||
on the "!", cleared at `.battleOccurred` after every battle (before the blackout check). It
|
|
||||||
covers more than the five frames: the "!" bubble runs about sixty frames before `wJoyIgnore` is
|
|
||||||
set, and they read as the fly's overworld too -- about sixty-six free-looking frames per
|
|
||||||
engagement, measured. An overworld frame with the bit set is `Unknown` in the macros' own
|
|
||||||
scene, with no text box, so the pad is empty, no ground is recorded and no held entry is
|
|
||||||
decided on it. A trainer talked to by the fly never sets it; its `wCurOpponent` is written
|
|
||||||
inside the text.
|
|
||||||
- **On main the five frames are already row 59's** (12.24: a held push-back is written only after
|
|
||||||
thirty frames of overworld), and that alone keeps (1, 18) clear. This row is the cartridge-fact
|
|
||||||
layer under it: the pad is empty through the bubble as well, no ground is recorded, and it does
|
|
||||||
not depend on the gap staying under thirty frames.
|
|
||||||
- **The macros' reading only.** `controllable` and `scene::detect` are shared with the reward
|
|
||||||
adapter and do not change; `PokeState`'s `scene` and `scripted` read the bit beside them. In
|
|
||||||
macros mode the feed's `game.scene` is the palette's, so it reads `unknown` on those frames,
|
|
||||||
which is what the contract says of a frame the cartridge is driving.
|
|
||||||
- **The gates were modelled right.** Both forest gates are on the graph and `next_hop` answers
|
|
||||||
the forest from the south gate and Route 2 from the north one. The south gate's `GO OUT` is the
|
|
||||||
"a room has to be leavable" tier, a way back that is the fly's choice: with the corridor open,
|
|
||||||
the survey seed that walked into the gate twelve times still earned the badge, on both arms.
|
|
||||||
|
|
||||||
Nothing is ranked or pressed for the fly: frames that were never the fly's deal nothing. The
|
|
||||||
decoder, the reward catalog, the adapter version, the roles and the compatibility string are
|
|
||||||
untouched.
|
|
||||||
|
|
||||||
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
|
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
|
||||||
## priority to visit the shop at least once per area; make shop macros item purchases. same
|
## priority to visit the shop at least once per area; make shop macros item purchases. same
|
||||||
## for the Pokécenter. heal should be a macro.")
|
## for the Pokécenter. heal should be a macro.")
|
||||||
|
|
|
||||||
|
|
@ -177,10 +177,6 @@ achievements.
|
||||||
| Wild win | +0.1, +0.05, +0.0333 | at most three observed wild KOs per `(map,species,level)` |
|
| Wild win | +0.1, +0.05, +0.0333 | at most three observed wild KOs per `(map,species,level)` |
|
||||||
| Badge | +3 | each newly set badge bit |
|
| Badge | +3 | each newly set badge bit |
|
||||||
|
|
||||||
That is the prototype's catalog as it shipped, kept here as history. The live one is
|
|
||||||
`docs/rewards-learning.md`: `pokered-unique8-v7` adds `boundary` (v5), `catch` (v6), and `talk` and
|
|
||||||
`item` with no `boundary` payout indoors (v7, the operator's decision of 2026-09-23).
|
|
||||||
|
|
||||||
Every value is positive; there were no loss or blackout penalties. Values in a frame summed into
|
Every value is positive; there were no loss or blackout penalties. Values in a frame summed into
|
||||||
`R`, then `m = tanh(R)`. PAM stimulation ran 80 to 400 ms depending on reward kind, with
|
`R`, then `m = tanh(R)`. PAM stimulation ran 80 to 400 ms depending on reward kind, with
|
||||||
overlapping pulses taking their maximum.
|
overlapping pulses taking their maximum.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Rewards and learning
|
# Rewards and learning
|
||||||
|
|
||||||
The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v7`. The code of record is
|
The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v6`. The code of record is
|
||||||
`services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the
|
`services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the
|
||||||
gates and the rules); this page says what each rule pays for and why it is allowed to. The
|
gates and the rules); this page says what each rule pays for and why it is allowed to. The
|
||||||
prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules
|
prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules
|
||||||
|
|
@ -23,10 +23,8 @@ change what the fly can do.
|
||||||
| `trainer` | `trainer` | +0.5 | 200 ms | Each named `EVENT_BEAT_*` flag once, except the flags classified as story milestones |
|
| `trainer` | `trainer` | +0.5 | 200 ms | Each named `EVENT_BEAT_*` flag once, except the flags classified as story milestones |
|
||||||
| `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` |
|
| `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` |
|
||||||
| `badge` | `badge` | +3 | 400 ms | Each newly set badge bit |
|
| `badge` | `badge` | +3 | 400 ms | Each newly set badge bit |
|
||||||
| `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger. **Nothing on an indoor map** (since v7): the exit is still recorded, and pays 0 |
|
| `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger |
|
||||||
| `catch` | `wildwin` | +0.30, +0.10 | 150 ms | A wild Pokémon kept by a ball: +0.30 for a species this run had never owned, +0.10 for a repeat; at most three payouts per species for the lifetime of the ledger |
|
| `catch` | `wildwin` | +0.30, +0.10 | 150 ms | A wild Pokémon kept by a ball: +0.30 for a species this run had never owned, +0.10 for a repeat; at most three payouts per species for the lifetime of the ledger |
|
||||||
| `talk` | `explore` | +0.10 | 100 ms | A conversation the fly opened with a person or a sign **indoors**, paid when its box closes; once per `(map, sprite slot or sign text id)` for the lifetime of the ledger |
|
|
||||||
| `item` | `explore` | +0.15 | 120 ms | An item ball or a hidden item picked up, on any map; once per item for the lifetime of the ledger |
|
|
||||||
|
|
||||||
Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")`
|
Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")`
|
||||||
is `None` by test. The values in one frame sum into `R`, and the network reinforces once with
|
is `None` by test. The values in one frame sum into `R`, and the network reinforces once with
|
||||||
|
|
@ -45,18 +43,6 @@ currently reads "wild win". The event's own label, `CAUGHT #<species>`, is what
|
||||||
log, `/status` and the checkpoint. Changing the ticker copy means opening the feed's closed kind
|
log, `/status` and the checkpoint. Changing the ticker copy means opening the feed's closed kind
|
||||||
set, which this rule deliberately did not do.
|
set, which this rule deliberately did not do.
|
||||||
|
|
||||||
`talk` and `item` publish on `explore`, for the same reason `boundary` does: each is the fly
|
|
||||||
finding what is in a place -- new ground, a door, a person or sign it opened, an item it picked up
|
|
||||||
-- at the same quiet scale (0.05 to 0.15). Not `area`, which counts maps and is a notable row; not
|
|
||||||
`story`, which is the plot; not `wildwin`, which is a battle. No feed kind was added, so
|
|
||||||
`docs/feed-protocol.md` and the stage's switch statements did not move. The Pokémon Red ticker's
|
|
||||||
`explore` row still says "new place" ("3 new places" collapsed) for all four: "new find" was
|
|
||||||
proposed and the operator kept "new place" (2026-09-23), so a conversation or an item reads as a
|
|
||||||
new place on screen. The event labels -- `TALKED TO #<slot> IN AREA <map>`, `READ SIGN #<id> IN
|
|
||||||
AREA <map>`, `FOUND ITEM #<item>`, `FOUND A HIDDEN ITEM` -- reach the event log, `/status` and the
|
|
||||||
checkpoint. Both also reset the stage's stall meter, which counts `explore`: engaging with a
|
|
||||||
building is progress in the sense the operator asked for.
|
|
||||||
|
|
||||||
## Catch rewards
|
## Catch rewards
|
||||||
|
|
||||||
The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not only for
|
The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not only for
|
||||||
|
|
@ -96,87 +82,6 @@ well below a badge (3.0). A catch of a new species pays 0.80 across two kinds, w
|
||||||
a story flag and a badge -- deliberately, because it is the one event that is both a discovery and
|
a story flag and a badge -- deliberately, because it is the one event that is both a discovery and
|
||||||
a thing the fly had to do on purpose.
|
a thing the fly had to do on purpose.
|
||||||
|
|
||||||
## Engagement rewards
|
|
||||||
|
|
||||||
The operator's decision of 2026-09-23, recorded with the port decisions: reward the fly for
|
|
||||||
engaging *inside* buildings and stop paying it for leaving them. It was chosen over a pad rule and
|
|
||||||
over weighting the choice, and it is a catalog change -- an operator decision, like the catch
|
|
||||||
reward -- not a loop-review fix (`docs/loop-review.md`). It answers a shape the loop reviews kept
|
|
||||||
finding in Pewter: `GO OBJECTIVE` into a building and `GO OUT` straight back, paid for the door on
|
|
||||||
the way out and for nothing inside.
|
|
||||||
|
|
||||||
**Indoors** is two of the cartridge's own tables, and nothing hand-classified
|
|
||||||
(`pokemon_red/engage.rs`, `indoor`). `CheckIfInOutsideMap` (`home/overworld.asm`) is the game's
|
|
||||||
outdoor test -- tileset `OVERWORLD` or `PLATEAU` -- and `WarpFound2` labels its other branch
|
|
||||||
`.indoorMaps`; on its own that would call Viridian Forest and every cave indoors, and their exits
|
|
||||||
are how the fly gets anywhere. `BikeRidingTilesets` (`data/tilesets/bike_riding_tilesets.asm`) is
|
|
||||||
the list of places the bicycle may be ridden -- `OVERWORLD`, `FOREST`, `UNDERGROUND`, `SHIP_PORT`,
|
|
||||||
`CAVERN` -- and the bike is the one thing the cartridge refuses inside a building by rule. A map is
|
|
||||||
indoors when its `wCurMapTileset` is in neither: every house, mart, Pokémon Center, gym, gate, lab
|
|
||||||
and museum, the S.S. Anne, Silph Co., the Pokémon Tower, the Mansion, the Rocket Hideout and the
|
|
||||||
Indigo Plateau's rooms. Not the forest, a cave, the Underground Path or Vermilion's dock.
|
|
||||||
|
|
||||||
**`talk`, +0.10.** Paid on the sample the text box closes, for a conversation that
|
|
||||||
|
|
||||||
1. *the fly opened*: on the last sample before the font bit (`wFontLoaded` bit 0) rose, the fly had
|
|
||||||
the joypad -- no `wJoyIgnore`, no simulated input, no scripted movement -- was standing still
|
|
||||||
(`wWalkCounter` zero, the only state the overworld reads A in) and stood where it stands now. A
|
|
||||||
script's text usually opens with the joypad taken; one opened by a map script the frame after a
|
|
||||||
step ends can still look opened by the fly, and is then held to rule 2 (below);
|
|
||||||
2. *is with the thing in front of it*: `DisplayTextID` copies its argument into `wSpriteIndex` --
|
|
||||||
a sprite slot up to `wNumSprites`, or a text id -- and the sprite must stand on the tile the
|
|
||||||
player faces (or one further, across a counter, on a tileset that has counter tiles, which is
|
|
||||||
`IsSpriteOrSignInFrontOfPlayer`'s own long reach), or the text id must be the sign's on that
|
|
||||||
tile. The byte arrives about **twenty frames after** the font bit (measured on the cartridge:
|
|
||||||
`DisplayTextIDInit` loads the font's tiles first) and until then still names the previous
|
|
||||||
text's subject, so it is read once it has changed or 45 samples have passed, and only while the
|
|
||||||
bottom dialogue box is drawn -- the start menu draws its own box elsewhere. An item ball is a
|
|
||||||
sprite but not a person, and pays `item`;
|
|
||||||
3. *opened indoors*, on the map the box opened on;
|
|
||||||
4. *finished*: the box closed on the same map. A conversation that ends in a warp, a rollback or a
|
|
||||||
restore pays nothing.
|
|
||||||
|
|
||||||
The ledger is the adapter's lifetime `seen` set, keyed `talk:<map>:sprite:<slot>` or
|
|
||||||
`talk:<map>:sign:<text id>` -- the same "map and object index" the macros' `talked` ledger uses,
|
|
||||||
but **not** that ledger: the macros' ledger is session state and is thrown away on a restore; this
|
|
||||||
one is checkpointed and survives a rollback, so talking to the same person again, after a restore
|
|
||||||
or not, pays nothing. The trainer the fly speaks to before a battle is a person and pays once; the
|
|
||||||
nurse, a clerk and a sign each pay once per map.
|
|
||||||
|
|
||||||
**`item`, +0.15.** Read from the cartridge's own "this one has been taken" bits, on any map.
|
|
||||||
An item ball is one of the map's toggleable sprites (`wToggleableObjectList`, sprite slot and
|
|
||||||
global index) whose `wMapSpriteExtraData` is `(item id, 0)` -- the shape `LoadMapHeader` writes for
|
|
||||||
an `ITEM` `object_event` and for nothing else (a trainer's is `(class, number)` with numbers from
|
|
||||||
1, a person's two zeroes); `PickUpItem` sets its global bit in `wToggleableObjectFlags` through
|
|
||||||
`HideObject`, and only after `GiveItem` succeeded, so a full bag pays nothing. A hidden item is a
|
|
||||||
bit of `wObtainedHiddenItemsFlags`, set by `FoundHiddenItemText` after `GiveItem` and by nothing
|
|
||||||
else; hidden *coins* have their own bitset and are not items. Either pays when its bit rises
|
|
||||||
between two playable samples, keyed `item:<global index>` or `hidden:<index>`, once for the life
|
|
||||||
of the ledger. A gift item from a script (the Old Amber, a TM from a person) is not an item ball:
|
|
||||||
the conversation pays `talk`, and the item nothing.
|
|
||||||
|
|
||||||
**The seed.** The first playable sample that finds the key `items:seeded` absent -- a fresh
|
|
||||||
adapter, or a `v6` ledger restored under `v7` -- writes a key for every bit the game already shows
|
|
||||||
as taken and pays for none of them, so a rollback to a slot from before a `v6`-era pickup cannot
|
|
||||||
pay for taking it again. The two item balls a script *reveals* are left out of the seed, because
|
|
||||||
their bits are set from a new game until Giovanni's defeat clears them: the Rocket Hideout's Silph
|
|
||||||
Scope and Lift Key (`$87`, `$88`), the only `ITEM` entries `data/maps/toggleable_objects.asm` starts
|
|
||||||
`OFF`.
|
|
||||||
|
|
||||||
**`boundary` indoors.** Every exit on an indoor map is still written to the ledger, so
|
|
||||||
`exit_visited` answers exactly what it did and the macros see no change, but nothing is paid. A
|
|
||||||
town's doors, a route's edges, the forest's gates and a cave's ladders pay as before. One
|
|
||||||
consequence, measured on the cartridge (`tests/rom_engage.rs`): for the thirty-odd frames of
|
|
||||||
`PlayMapChangeSound` the cartridge has written the destination into `wCurMap` while the tileset and
|
|
||||||
the warp table are still the map being left, so the exit the fly is standing on is classified by
|
|
||||||
the map it belongs to. Walking into a building through a town door still pays that door's on-exit
|
|
||||||
half, 0.10, once, keyed under the building's id as it always was; walking out pays nothing.
|
|
||||||
|
|
||||||
**The scale.** A building's worth of engagement -- a few people, a sign, perhaps a ball -- is
|
|
||||||
0.3 to 0.6: more than the 0.15 its door paid for being left, less than a new Pokédex entry per
|
|
||||||
person, far below a badge. Everything is once per thing for the lifetime of the ledger, so no
|
|
||||||
building can be farmed.
|
|
||||||
|
|
||||||
## Gates
|
## Gates
|
||||||
|
|
||||||
Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other
|
Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other
|
||||||
|
|
@ -205,10 +110,6 @@ therefore replays none of it.
|
||||||
|
|
||||||
## Boundary rewards
|
## Boundary rewards
|
||||||
|
|
||||||
Since `pokered-unique8-v7` everything below holds **outdoors** -- towns, routes, the forest,
|
|
||||||
caves -- and on an indoor map the same keys are written and nothing is paid ("Engagement rewards"
|
|
||||||
above has the definition of indoors and the one transition frame worth knowing about).
|
|
||||||
|
|
||||||
`docs/design/room-escape.md` section 2. The rule pays 0.05 the first time the fly stands on a tile
|
`docs/design/room-escape.md` section 2. The rule pays 0.05 the first time the fly stands on a tile
|
||||||
orthogonally adjacent to one of the current map's exits, and 0.10 the first time it stands on the
|
orthogonally adjacent to one of the current map's exits, and 0.10 the first time it stands on the
|
||||||
exit tile itself. Both are keyed into the adapter's lifetime `seen` ledger as
|
exit tile itself. Both are keyed into the adapter's lifetime `seen` ledger as
|
||||||
|
|
@ -276,20 +177,7 @@ body picks the macro; the descending neurons press the buttons.**
|
||||||
|
|
||||||
## Honesty
|
## Honesty
|
||||||
|
|
||||||
The catalog now includes conversations and items (v7). Paying for a conversation is the closest
|
The catalog now includes catches. The honesty panel's copy is not data-driven from the catalog --
|
||||||
the catalog has come to paying for a *button*: A is what opens one. It is still a reward, not a
|
|
||||||
press. Nothing in the adapter presses A, chooses when, or tells the fly who is there; the payout is
|
|
||||||
read out of WRAM after a conversation the fly's own buttons -- or the macro the mushroom body chose
|
|
||||||
-- opened and finished, and it is once per person or sign for the life of the run, so the thing
|
|
||||||
that is learned is "the people in a building are worth a visit", not "press A". It is also why the
|
|
||||||
rule demands evidence that the fly opened the box. That evidence is not proof: a map script runs
|
|
||||||
one frame after a step ends and may open text while the fly is still, controllable and on the same
|
|
||||||
tile. Such text pays only if it names the person or sign the fly is facing, once per key; in the
|
|
||||||
early game none can (checked: the museum ticket man, the Route 22 and Route 5 guards, Viridian
|
|
||||||
Mart, Oak's Lab), and a few late ones can once each (the Fighting Dojo master, the Elite Four
|
|
||||||
after their battles). Review of 2026-09-23.
|
|
||||||
|
|
||||||
The catalog also includes catches. The honesty panel's copy is not data-driven from the catalog --
|
|
||||||
`apps/stage/src/lib/schedule.ts`'s rotating card is four written lines and lists no kinds -- so
|
`apps/stage/src/lib/schedule.ts`'s rotating card is four written lines and lists no kinds -- so
|
||||||
there was nothing to regenerate and the copy is unchanged. The sentences below are where the
|
there was nothing to regenerate and the copy is unchanged. The sentences below are where the
|
||||||
argument lives.
|
argument lives.
|
||||||
|
|
|
||||||
|
|
@ -832,49 +832,3 @@ median; a two-fly transition near 10 to 12 ms at the median in every execution m
|
||||||
10 gains `unrewarded` (100+ decisions, no reward, no new ground, two probes), still never
|
10 gains `unrewarded` (100+ decisions, no reward, no new ground, two probes), still never
|
||||||
acting. Also shipped: EDGE-01, the feed over flybus behind FLY_FEED_VIA, off (`direct`)
|
acting. Also shipped: EDGE-01, the feed over flybus behind FLY_FEED_VIA, off (`direct`)
|
||||||
everywhere. Next: row 59, Route 3's neighbours and Mt. Moon's doors in the geography table.
|
everywhere. Next: row 59, Route 3's neighbours and Mt. Moon's doors in the geography table.
|
||||||
- 2026-09-23 (the operator's reset): the live run restarted from milestone 1, the bedroom, with the
|
|
||||||
brain as it was then (65 brain seconds), to watch the macros in the early game. The v5 archive
|
|
||||||
migrated to v6. It reached rung 9, Viridian Forest, 31 minutes later.
|
|
||||||
- 2026-09-23 (v0.6.0, the operator's decision, plus loop review row 60): the fly is paid for
|
|
||||||
engaging inside buildings and not for leaving them. New catalog kinds `talk` (+0.10, the first
|
|
||||||
conversation the fly opens with each person or sign on an indoor map, once per key for the run)
|
|
||||||
and `item` (+0.15, each item ball or hidden item, once); `boundary` pays nothing for an indoor
|
|
||||||
exit. Indoor is the cartridge's own tables (not CheckIfInOutsideMap's outdoor tilesets and not a
|
|
||||||
bike tileset). Both publish on `explore`; the ticker still reads "new place" (the operator kept
|
|
||||||
the wording). Adapter `pokered-unique8-v7`; a deploy with FLY_ACCEPT_ADAPTERS=pokered-unique8-v6
|
|
||||||
migrates the run and seeds every item the game already shows taken without paying it. Row 60:
|
|
||||||
from the fresh run, TAIL WHIP (MOVE 2) was dealt beside TACKLE after the cartridge had begun
|
|
||||||
refusing it ("Nothing happened!": the stage at -6 or the stat at 1), and the brain's MOVE 2
|
|
||||||
preference drew battles out until Squirtle fainted. A move the cartridge would refuse -- read
|
|
||||||
from its move table and the target's stages, status, types, Mist and substitute -- is not dealt
|
|
||||||
beside one that works, as a move out of PP is not. ROM test: 2,657 of 2,657 own-turn frames dealt
|
|
||||||
the refused move before, 0 of 568 after; battles won 0 -> 3 of 12 -> 14. The real-brain hunt did
|
|
||||||
not finish at this load; recorded as a deviation. Ethos check held.
|
|
||||||
- 2026-09-23 (v0.6.1, loop review, auto): row 59, Mt. Moon. With the Boulder Badge the live fly
|
|
||||||
reached rung 12 and rang on Route 4 (GO ROUTE 233, GO OBJECTIVE 122, GO OUT 111 per ten minutes,
|
|
||||||
one new tile), and restarts did not hold: the map graph was wrong, not a ledger. The geography
|
|
||||||
table is now the cartridge's own headers and warps: Route 3 connects north to Route 4, Mt.
|
|
||||||
Moon's doors are on Route 4 (1F at (18,5), B1F's exit at (24,5)); Routes 14/15 and 24/25 were
|
|
||||||
in the wrong compass columns and Routes 22/23 were missing; a map can be several pieces (Route
|
|
||||||
4 two, Mt. Moon B1F four chambers, B2F three), the fly's piece read from the decoded grid and a
|
|
||||||
warp landing in the piece that holds its destination; sea and fence connections (Pallet/21,
|
|
||||||
Cinnabar/20, 20/19, 22/23) are no longer exits. A trainer's challenge leaves five frames of
|
|
||||||
plain overworld before the battle, and a push-back is now written only after thirty frames of
|
|
||||||
the fly's own. From the live Route 4 checkpoint: crossings of the west doors 56 -> 13, into Mt.
|
|
||||||
Moon at frame 278; from the badge checkpoint the fly reaches Mt. Moon and rung 12 where the base
|
|
||||||
rang at Pewter. The reviewer checked all 36 connection rows and every piece against the
|
|
||||||
disassembly. The trap hunt was not run at this load (recorded deviation). Also shipped: FND-01,
|
|
||||||
one LegacyFrame for the service and every harness (no live behaviour change), FLY_TRACE and the
|
|
||||||
sugar journal. Ethos check held.
|
|
||||||
- 2026-09-24 (v0.6.2, loop review, auto): row 61, the Viridian Forest corridor. In the fresh run the
|
|
||||||
fly rang between the forest, its south gate and Route 2 (GO OBJECTIVE / GO OUT / GO ROUTE) for
|
|
||||||
twenty minutes at rung 9. A Bug Catcher stands on the forest's only corridor north; when he saw
|
|
||||||
the fly, the frames of his "!" bubble and the five frames between his text and the battle read as
|
|
||||||
the fly's own overworld, and a held push-back walled (1,18) for good, so GO OBJECTIVE had no road
|
|
||||||
north. The macros now read the cartridge's BIT_TRAINER_BATTLE (wStatusFlags7 bit 3, set at the
|
|
||||||
"!", cleared after every battle, a lost one too) and treat those frames as the cartridge's: no
|
|
||||||
pad, no ground recorded, no push decided. Row 59's 30-frame debounce already closed the five-frame
|
|
||||||
gap; this is the cartridge-fact layer under it. ROM test: base walls (1,18) and stays at rung 9;
|
|
||||||
branch reaches Pewter on frame 23,755, and offers no button on any of the 67 challenge frames
|
|
||||||
(67 of 67 before). Survey seed 7: rung 9 -> BOULDER BADGE. The trap hunt's stub cannot see this
|
|
||||||
trap; the ROM test and survey are the proof (recorded deviation). Ethos check held.
|
|
||||||
|
|
|
||||||
|
|
@ -300,9 +300,8 @@ if [ -n "$RELEASE_TARBALL" ]; then
|
||||||
#
|
#
|
||||||
# FLY_ACCEPT_ADAPTERS is the *other* override, and the opposite one: it keeps
|
# FLY_ACCEPT_ADAPTERS is the *other* override, and the opposite one: it keeps
|
||||||
# the run. It names adapter version strings whose checkpoints the new build
|
# the run. It names adapter version strings whose checkpoints the new build
|
||||||
# may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 for the deploy
|
# may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 for the deploy
|
||||||
# that adds the engagement rewards (v7; v5 -> v6 was the catch reward's).
|
# that adds the catch reward. It only applies when the adapter segment is the
|
||||||
# It only applies when the adapter segment is the
|
|
||||||
# ONLY difference between the two strings and the new build's adapter says it
|
# ONLY difference between the two strings and the new build's adapter says it
|
||||||
# can read that one; a dataset, kernel, emulator or state-format change is
|
# can read that one; a dataset, kernel, emulator or state-format change is
|
||||||
# still a refusal, because none of those has a migration. The same variable is
|
# still a refusal, because none of those has a migration. The same variable is
|
||||||
|
|
@ -355,7 +354,7 @@ The difference is usually an adapter/ladder or dataset version bump. Three ways
|
||||||
* deploy a build whose string matches (check out the commit the running release was built from), or
|
* deploy a build whose string matches (check out the commit the running release was built from), or
|
||||||
* if the ADAPTER VERSION is the only segment that differs and the new build documents a
|
* if the ADAPTER VERSION is the only segment that differs and the new build documents a
|
||||||
migration from the old one, re-run with FLY_ACCEPT_ADAPTERS set to the adapter id in the live
|
migration from the old one, re-run with FLY_ACCEPT_ADAPTERS set to the adapter id in the live
|
||||||
string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v6). The run is kept; flysim applies the same
|
string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5). The run is kept; flysim applies the same
|
||||||
rule at restore. See docs/design/flysim.md, \"Restoring across an adapter version\", or
|
rule at restore. See docs/design/flysim.md, \"Restoring across an adapter version\", or
|
||||||
* accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which
|
* accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which
|
||||||
archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and
|
archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,7 @@ 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 (`flysim::frame::LegacyFrame` since 2026-09-23;
|
Both arms are the sim loop's own frame order over the real connectome (`data/fafb-v783`), the real
|
||||||
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,15 +151,6 @@ 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 \
|
||||||
|
|
@ -2766,288 +2757,5 @@ route survey above is the reproduction; the hunt is reported, not smoothed.
|
||||||
- `cargo clippy --all-targets`: **0 warnings**.
|
- `cargo clippy --all-targets`: **0 warnings**.
|
||||||
- `npm test` 663 passed; `npm run typecheck` clean.
|
- `npm test` 663 passed; `npm run typecheck` clean.
|
||||||
- `infra/tests/lint.sh`: ALL CHECKS PASSED, check 10's two new cases and the de-PII guard included.
|
- `infra/tests/lint.sh`: ALL CHECKS PASSED, check 10's two new cases and the de-PII guard included.
|
||||||
- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had:
|
|
||||||
648 bytes, sha256 `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68`
|
|
||||||
on `main` after the engagement rewards (adapter v7). Decoder, reward catalog, adapter version
|
|
||||||
and roles untouched.
|
|
||||||
|
|
||||||
## 2026-09-23, row 60: TAIL WHIP after it stopped working
|
|
||||||
|
|
||||||
### What was live
|
|
||||||
|
|
||||||
Map 12 (Route 1), rank 9, v0.5.5, after the operator reset the run to milestone 1: Squirtle L5
|
|
||||||
(TACKLE, TAIL WHIP) in wild battles with Pidgey, "Nothing happened!" on the screen. Since the reset
|
|
||||||
`MOVE 2` 183 starts and `MOVE 1` one; the last reward 25 brain minutes before the checkpoint,
|
|
||||||
1,600 decisions since, one wild win in the whole run. Check 10 flagged `unrewarded` -- correctly:
|
|
||||||
100+ decisions, no reward event, no new ground on two probes. It is unchanged.
|
|
||||||
|
|
||||||
### The survey: the pad on the fly's own turn
|
|
||||||
|
|
||||||
The route probe from the checkpoint (`FLY_PROBE_CATCH=route`, `FLY_PROBE_PREFER="MOVE 2"`, the
|
|
||||||
live readout's favourite; 72,000 frames) now prints the battle bytes on every pad change. The
|
|
||||||
checkpoint is the frame Squirtle fainted: Pidgey L3, DEFENSE stage 1 (-6), DEFENSE 2, TAIL WHIP's
|
|
||||||
row `$27`: effect `$13` power 0. In the next battle DEFENSE reached **1 at stage -5**, which
|
|
||||||
`StatModifierDownEffect` refuses as well. From then on the own-turn pad was `MOVE 1, MOVE 2, RUN`
|
|
||||||
over the menu and `BACK, MOVE 1, MOVE 2` over the list: **every own-turn pad with TAIL WHIP
|
|
||||||
refused dealt `MOVE 2`** (72 of 72; a 73rd frame was a list with no placeable cursor, `NEXT`
|
|
||||||
alone), TACKLE beside it every time. `MOVE 1` was never missing; `MOVE 2` was a
|
|
||||||
button that could change nothing.
|
|
||||||
|
|
||||||
| # | trap | trigger | test | fix, or why it is left |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| 60 | `MOVE n` is bound by PP alone, so a move the cartridge refuses ("Nothing happened!", "didn't affect") stays on the pad beside one that works | any stat move at its stage or stat limit, any status move at a target it cannot affect; Route 1, TAIL WHIP at a Pidgey's DEFENSE 1, every battle lost | `a_move_the_cartridge_answers_with_nothing_is_off_the_pad_beside_one_it_does_not`, `a_spent_move_and_a_move_without_effect_leave_the_one_that_works`, `with_no_move_that_does_anything_the_moves_stay_as_pp_deals_them`, six `state` tests, `tail_whip_at_its_limit_is_not_dealt_and_a_route_one_battle_is_won` (ROM) | **fixed**: `state::move_data` reads the move's row of `Moves` (`$0E:$4000`) from the cartridge image; `state::move_without_effect` answers the refusals decided before the roll (stage 1/13, stat 1/999, Mist or substitute, a statused, Poison or Ground target); the palette treats such a move as it treats a spent one. `docs/design/macros.md` 12.23, `macros-wram.md` section 13 |
|
|
||||||
|
|
||||||
### Before and after
|
|
||||||
|
|
||||||
The ROM-gated run, 72,000 frames (20.1 brain minutes) from the checkpoint, the real palette
|
|
||||||
driven with `MOVE 2` preferred and a uniform choice otherwise; base is this branch with the
|
|
||||||
palette commit reverted:
|
|
||||||
|
|
||||||
| measure | base | branch |
|
|
||||||
| --- | ---: | ---: |
|
|
||||||
| own-turn frames with a refused move beside a useful one | 2,657 | 568 |
|
|
||||||
| ... of them dealing the refused move | **2,657** | **0** |
|
|
||||||
| battles ended / won | 12 / **0** | 14 / **3** |
|
|
||||||
| battles ended with the fly's Pokémon fainted | 12 | 8 |
|
|
||||||
| battle length, frames, median / max | 4,970 / 8,994 | 3,594 / 4,793 |
|
|
||||||
| `MOVE 2` / `MOVE 1` / `RUN` starts | 94 / 0 / 0 | 56 / 5 / 4 |
|
|
||||||
|
|
||||||
The route survey, same driver and checkpoint: base never leaves Pallet Town, Red's house and
|
|
||||||
Route 1 (637 tiles at the end); the branch reaches Viridian City, its Pokémon Center and mart and
|
|
||||||
Route 2 (748).
|
|
||||||
|
|
||||||
The survey itself, same driver: own-turn pads dealing a refused TAIL WHIP 72 -> **0** (of 11 at
|
|
||||||
the limit); `MOVE 2` / `MOVE 1` / `RUN` done 94 / 0 / 0 -> 56 / 4 / 4; maps with a macro done 4 -> 10.
|
|
||||||
|
|
||||||
**The real-brain trap hunt was not run to the end.** Both 20-minute arms (`trap_hunt` now reports
|
|
||||||
payouts by kind, battle lengths and wins, and `MOVE n` starts on a move without effect) were
|
|
||||||
started from the checkpoint and stopped after 1 h 48 min wall at about 19 CPU-minutes each: the box
|
|
||||||
sat at load 25-40 and a stub arm ticks the same brain. The ROM-gated run and the survey above are
|
|
||||||
the before/after; they drive the real palette with the live readout's measured preference instead
|
|
||||||
of the brain, which is the deviation.
|
|
||||||
|
|
||||||
### Residuals, named rather than worked around
|
|
||||||
|
|
||||||
- **The fly still spends TAIL WHIP while it works.** Six presses at stage 7 to 1 are the fly's
|
|
||||||
choice, and a Squirtle at 8/20 can faint doing it; eight of fourteen battles on the branch
|
|
||||||
still ended that way. What changed is that the seventh is not on the pad.
|
|
||||||
- **Not covered, same kind, nothing early reaches it:** Confuse Ray and Supersonic on a confused
|
|
||||||
target, Leech Seed on a seeded or Grass target, Focus Energy, Mist, Reflect and Light Screen
|
|
||||||
already up, Disable on a disabled target, and a damaging move the type chart makes "doesn't
|
|
||||||
affect" (the chart is another ROM table). `macros-wram.md` section 13.
|
|
||||||
- **With no move that would do anything, the moves stay as PP deals them.** Taking them away would
|
|
||||||
leave an open list with `BACK` alone (12.11); a turn that ends on "Nothing happened!" still ends.
|
|
||||||
|
|
||||||
### Gates
|
|
||||||
|
|
||||||
- `cargo test --release -p flybrain-gb` with `FLY_ROM`: 426 + 27 passed, 0 failed.
|
|
||||||
- `cargo test --release -p flysim --no-fail-fast` with `FLY_ROM` and the row-60 checkpoint:
|
|
||||||
all passed but `integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
|
|
||||||
the known load failure (`total` 1 against 38, or the feed at 14-18 Hz, on a box at load 30),
|
|
||||||
failing the same two ways on the base.
|
|
||||||
- `cargo clippy --workspace --all-targets -- -D warnings`: clean.
|
|
||||||
- `npm test` 663 passed; `npm run typecheck` clean; `infra/tests/lint.sh` ALL CHECKS PASSED.
|
|
||||||
- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had:
|
|
||||||
648 bytes, sha256 `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68`
|
|
||||||
on `main` after the engagement rewards (adapter v7). Decoder, reward catalog, adapter version
|
|
||||||
and roles untouched.
|
|
||||||
|
|
||||||
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`**, byte-identical to the
|
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`**, byte-identical to the
|
||||||
base. Decoder, reward catalog, adapter version and roles untouched.
|
base. Decoder, reward catalog, adapter version and roles untouched.
|
||||||
|
|
||||||
## 2026-09-23, row 59: the road to Mt. Moon, and Route 4 in two
|
|
||||||
|
|
||||||
### What was coming, and what came
|
|
||||||
|
|
||||||
Opened pre-emptively. The row-58 review carried the route survey past the Boulder Badge (v0.5.5's fix):
|
|
||||||
from about frame 68,000, Pewter City (39, 17) and Route 3 (0, 9) in a ring, `GO OBJECTIVE` done
|
|
||||||
on Route 3 538 times, `GO ROUTE` done on Pewter 537. Route 3's pad was `GO ROUTE` alone, because
|
|
||||||
`GO OBJECTIVE` had nothing on Route 3 to aim at. The live fly was due there with the badge.
|
|
||||||
|
|
||||||
It got through Route 3 and met the other half on v0.6.0 at 22:20 UTC: rank 12 (MT. MOON), map 15,
|
|
||||||
per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103, five distinct macros, two new
|
|
||||||
tiles; the coordinator restarted flysim. The live checkpoint from inside the ring is v7. From it the
|
|
||||||
route survey on `main` (`4d82f7d`, 72,000 frames) walks between Route 4 and the Pokécenter (map
|
|
||||||
68): 930 map changes, `GO OBJECTIVE` done on Route 4 465 and `GO ROUTE` done on the Pokécenter
|
|
||||||
462 with `GO OBJECTIVE` preferred; with a uniform choice 706 changes, `GO ROUTE` 350,
|
|
||||||
`GO OBJECTIVE` 183, `GO OUT` 170, the live shape. That is 59c.
|
|
||||||
|
|
||||||
### Reproduction
|
|
||||||
|
|
||||||
`examples/scene_probe.rs` gains `FLY_PROBE_SAVE_RANK`: the route survey writes the first safe
|
|
||||||
overworld frame at a rung as a checkpoint. From the row-58 checkpoint, `FLY_PROBE_CATCH=route
|
|
||||||
FLY_PROBE_PREFER="GO OBJECTIVE,TALK" FLY_PROBE_FRAMES=120000`, the survey is byte-identical to the
|
|
||||||
review's and writes rank 11 on frame 19,620, in the gym beside BROCK
|
|
||||||
(`.local/checkpoints/survey-rank11-row59.checkpoint`, untracked). From that checkpoint, with empty
|
|
||||||
ledgers, the base walks the ring again: `GO OBJECTIVE` done on Route 3 509, `GO ROUTE` done on
|
|
||||||
Pewter 508, never on Route 4.
|
|
||||||
|
|
||||||
### The audit: every row of the map graph against the disassembly
|
|
||||||
|
|
||||||
`geography.rs` against `data/maps/headers/*.asm` and `data/maps/objects/*.asm` at `0cd19d3`, all
|
|
||||||
outdoor rows and every `LINKS` pair; then every map on the graph flooded tile by tile (blocks,
|
|
||||||
blockset, collision list, `TilePairCollisionsLand`, `LedgeTiles`, lower-left quadrant as section
|
|
||||||
15 reads it) for pieces, and every connection for a strip that lands on land.
|
|
||||||
|
|
||||||
| # | trap | trigger | test | fix, or why it is left |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| 59 | Route 3 / Route 4 in the wrong columns (`[-,-,PEWTER,ROUTE_4]` / `[-,-,ROUTE_3,CERULEAN]`; the headers say Route 4 is **north** of Route 3), and Mt. Moon 1F linked to **Route 3**, which has no warps; B1F's exit to Route 4 missing | any objective past Pewter: Route 3's north edge named no map, so it was nobody's hop | `the_rows_the_disassembly_corrected_say_what_its_headers_say`, `the_road_to_mt_moon_is_not_a_ring_at_the_pewter_end_from_the_badge_checkpoint` (ROM) | **fixed**: rows as `Route3.asm` / `Route4.asm`; `(MT_MOON_1F, ROUTE_4)`, `(MT_MOON_B1F, ROUTE_4)` per `Route4.asm`'s warps (18, 5) and (24, 5) and B1F's (27, 3) |
|
|
||||||
| 59b | Route 14 / 15 and Route 24 / 25 in the wrong columns (south/north for west/east) | Nugget Bridge's far end (rung 16) named no map; Route 14's west edge likewise | the same unit test | **fixed**: `Route14.asm`, `Route15.asm`, `Route24.asm`, `Route25.asm` |
|
|
||||||
| 59c | Route 4 is one node, though Mt. Moon cuts it in two: the cave mouth's side (Pokécenter (11, 5), 1F (18, 5), Route 3) and Cerulean's side (B1F's exit (24, 5), Cerulean) | every hop across Route 4 | `route_4s_sides_are_told_apart_by_the_doors_where_the_ground_cannot`, `route_4s_doors_and_sides_are_the_cartridges_from_the_badge_checkpoint` (ROM) | **fixed**: a `SPLIT` row |
|
|
||||||
| 59d | Mt. Moon B1F and B2F are one node each, though B1F is four chambers of two ladders and B2F three pieces; 1F has three ladders to B1F and only (5, 5) is the road, so a map-level hop sends the fly up dead ends and back | rung 13 (Cerulean) from the first floor | `the_road_from_pewter_to_cerulean_is_through_mt_moon_one_chamber_at_a_time`, `a_door_or_an_edge_lands_in_the_piece_it_opens_onto` | **fixed**: `SPLIT` rows; a piece lists its warps by index, a warp lands in the piece its destination warp is in (`wWarpEntries` byte 2), and the hop is a piece. The fly's own piece is what its walk reaches on the decoded grid, the nearest door where the grid (no ledges) reaches none; flooded over all four maps' ground, exact for every tile |
|
|
||||||
| 59e | four header connections have no tile where both sides are land -- Pallet / Route 21, Cinnabar / Route 20, Route 20 / 19 (sea), Route 22 / 23 (the League's fence) -- and the graph routed along them: from Pallet the road to Cerulean was by sea | whiting out in Mt. Moon, which the survey's fly did: `GO ROUTE` walked into Pallet's shore every 108 frames | `a_connection_nobody_can_walk_across_is_named_and_is_not_a_road` | **fixed**: `NO_CROSSING`: named, no exit, no hop. Route 22 / 23 go back into the table from the same list |
|
|
||||||
| 59f | a trainer's challenge closes onto five frames of plain overworld before `StartTrainerBattle` decides the battle; row 58's pending push-back was written on the first of them | Route 3's first trainer: (11, 6), the one gap between the road's west end and the rest of it, walled for the session | `a_challenge_closing_onto_a_few_frames_of_overworld_is_still_a_challenge` | **fixed**: a push-back is a refusal only after thirty frames running of the fly's overworld; a battle inside them drops it |
|
|
||||||
|
|
||||||
Measured and not the same kind, so left: Cerulean's south and east (Route 5, Route 9, the trashed
|
|
||||||
house's back door) are reached from the town only through the trashed house, which is off the
|
|
||||||
graph, and back over a ledge -- rung 17's road, with the Saffron gates and the Underground Path
|
|
||||||
behind it. Routes 5-8, 10-12, 15, 16, 18 and 23 are pieces joined by gate buildings off the graph,
|
|
||||||
and Rock Tunnel 1F is four pieces (rung 20). And many edges have walkable tiles past the strip
|
|
||||||
that lands on land: a walk aimed at
|
|
||||||
one is blocked and rests the whole edge for the window. On the road through rung 16 these are
|
|
||||||
Route 3's (62, 0) and (63, 0), Route 4's (6, 17) and Cerulean's (0, 13) and (25, 0), all beside the
|
|
||||||
landing tiles a walk from the road reaches first.
|
|
||||||
|
|
||||||
### Before and after
|
|
||||||
|
|
||||||
Route survey from the rank-11 checkpoint, 120,000 frames, `GO OBJECTIVE` and `TALK` preferred:
|
|
||||||
|
|
||||||
| measure | base `7784a9d` | branch |
|
|
||||||
| --- | ---: | ---: |
|
|
||||||
| `GO OBJECTIVE` done on Route 3 | **509** | 9 |
|
|
||||||
| `GO ROUTE` done on Pewter | **508** | 0 |
|
|
||||||
| Route 4 / Mt. Moon first | never / never | frame 70,356 / 70,707 |
|
|
||||||
| rung at the end | 11 | **12, MT. MOON** |
|
|
||||||
| pushed tiles at the end | (11, 6), (14, 6), (14, 9) on Route 3 | none |
|
|
||||||
|
|
||||||
From the live Route 4 checkpoint, 72,000 frames, rebased on `main` `4d82f7d` (v7):
|
|
||||||
|
|
||||||
| measure | base `4d82f7d` | branch |
|
|
||||||
| --- | ---: | ---: |
|
|
||||||
| map changes, `GO OBJECTIVE` preferred / uniform | **930 / 706** | 29 / 50 |
|
|
||||||
| `GO OBJECTIVE` done on Route 4, preferred / uniform | **465 / 183** | 3 / 2 |
|
|
||||||
| `GO ROUTE` done on the Pokécenter, preferred / uniform | **462 / 350** | 0 / 0 |
|
|
||||||
| into Mt. Moon, preferred / uniform | frame 541 / 826, and back out | frame 279 / 1,910 |
|
|
||||||
| rung at the end | 12 | 12 |
|
|
||||||
|
|
||||||
`the_fly_goes_into_mt_moon_from_the_live_route_4_checkpoint`, twenty brain minutes on the stub
|
|
||||||
rotation: the base crosses Route 4's west doors 56 times, 18 through the Pokécenter's and 38
|
|
||||||
through the cave's (fails); the branch 13, over three whiteouts and walks back (passes), in the
|
|
||||||
cave on frame 278.
|
|
||||||
|
|
||||||
The same survey from the live rank-11 checkpoint of 2026-09-22 (Pewter, the badge won):
|
|
||||||
the base stays on Route 3 with five pushed tiles fencing it, rank 11; the branch reaches Route 4 at
|
|
||||||
frame 118,621 and Mt. Moon at 118,972, rank 12, nothing pushed.
|
|
||||||
|
|
||||||
The ROM-gated run, `the_road_to_mt_moon_is_not_a_ring_at_the_pewter_end_from_the_badge_checkpoint`,
|
|
||||||
on the stub rotation: the base crosses between Pewter City and Route 3 **3,391** times in 80.4 brain
|
|
||||||
minutes and never stands on Route 4 (fails); the branch crosses 4 times, whites out twice on the
|
|
||||||
way, and is on Route 4 at frame 202,734 and at Mt. Moon's door at 203,232, 56.7 brain minutes
|
|
||||||
(passes). `route_4s_doors_and_sides_are_the_cartridges_from_the_badge_checkpoint` reads Route 4's
|
|
||||||
warp table off the cartridge -- (11, 5) to `$44`, (18, 5) to `$3b`, (24, 5) to `$3c`, the table's
|
|
||||||
doors in its order -- and the decoded grid puts the fly, arrived from Route 3 at (9, 17), on the cave
|
|
||||||
mouth's side.
|
|
||||||
|
|
||||||
**No trap hunt numbers.** Two 30-brain-minute stub arms were started from the badge checkpoint on
|
|
||||||
v0.5.5 with the box at load 35-40, had written nothing after two hours, and were stopped when the
|
|
||||||
live fly reached Route 4 and this row became the live priority; FND-01 has since changed the
|
|
||||||
hunt's frame, so hunts across the rebase would not compare (`9301e39`). The proof is the route
|
|
||||||
survey and the three ROM tests, which is a deviation from the ethos check's letter, recorded.
|
|
||||||
|
|
||||||
### Gates
|
|
||||||
|
|
||||||
- `cargo test --workspace --no-fail-fast` in release with `FLY_ROM`, `FLY_DATASET` and
|
|
||||||
`FLY_BADGE_CHECKPOINT`: 1,285 passed, 1 failed --
|
|
||||||
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
|
|
||||||
the known load-sensitive test (feed at 11.28 Hz with the box at load 35-40); the base fails it
|
|
||||||
too at the same load (the boot-time `total` assertion).
|
|
||||||
- `cargo clippy --workspace --all-targets`: **0 warnings**.
|
|
||||||
- `npm test` 663 passed; `npm run typecheck` clean.
|
|
||||||
- `infra/tests/lint.sh`: ALL CHECKS PASSED, the de-PII guard included.
|
|
||||||
- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had:
|
|
||||||
648 bytes, `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68` on
|
|
||||||
`4d82f7d` (v7). Decoder, reward catalog, adapter version and roles untouched.
|
|
||||||
|
|
||||||
## 2026-09-23, row 61: the forest's south gate, in and out
|
|
||||||
|
|
||||||
### What was live
|
|
||||||
|
|
||||||
Map 50 (`VIRIDIAN_FOREST_SOUTH_GATE`, `$32`) and Route 2's south half, rung 9, v0.5.5, about 20:45
|
|
||||||
to 21:05 UTC: `GO OBJECTIVE` into the gate (0.3 s), `GO OUT` back onto Route 2 (0.2 s), `GO WARP`
|
|
||||||
back out of the forest, `GO OBJECTIVE blocked` in the forest, `GO ROUTE` refused now and then; no
|
|
||||||
reward and about eight new tiles in twenty minutes. The checkpoint is the fly at (5, 1) in the
|
|
||||||
gate, under the forest's doorway, objective Pewter City; `next_hop` answers the forest, correctly.
|
|
||||||
|
|
||||||
### The survey: what walled the road
|
|
||||||
|
|
||||||
The route survey from the checkpoint (`FLY_PROBE_CATCH=route`, uniform choice per hold,
|
|
||||||
`FLY_PROBE_RNG=7`, 72,000 frames) walks the live ring and never leaves rung 9. Caught at the
|
|
||||||
refusal (`FLY_PROBE_CATCH_FRAME`, `FLY_PROBE_WHOLE=1`): the forest's only road to the north gate
|
|
||||||
is the corridor at x = 1-2 from row 22 up to row 0; the Bug Catcher of
|
|
||||||
`EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2` stands on (2, 18) facing west, and **(1, 18) was in the
|
|
||||||
pushed ledger**. It was written at frame 16,389: a `GO ITEM` walk stepped onto (1, 18), the trainer
|
|
||||||
took the joypad, and his text closed onto five frames with no box, no script bit and
|
|
||||||
`wCurOpponent` zero (`StartTrainerBattle` runs after `DisplayTextID`'s close-down). Row 58's held
|
|
||||||
entry was decided on the first of them. The "!" bubble before it, about sixty frames before
|
|
||||||
`wJoyIgnore` is set, reads the same way: about sixty-six free-looking frames per engagement. The pushed ledger has no window, so the north gate had no
|
|
||||||
road for the session; `GO OBJECTIVE` walked to the nearest reachable tile, (6, 1), a dead end, and
|
|
||||||
was blocked, and the last tiers walked the fly back to the south gate and Route 2. Every base
|
|
||||||
survey arm walls (1, 18); only a fly that has to come back up the corridor is trapped by it.
|
|
||||||
|
|
||||||
| # | trap | trigger | test | fix, or why it is left |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| 61 | the "!" bubble and the frames between a sighted trainer's challenge text and `StartTrainerBattle` (about 66 per engagement) read as the fly's overworld: a pad is dealt, ground recorded, and row 58's held push-back written, walling the tile the trainer fired on for the session | any trainer whose line of sight a walk crosses; Viridian Forest's (1, 18), the one free tile of the corridor to the north gate | `a_trainers_challenge_is_the_cartridges_until_its_battle_is_over`, `the_frames_between_a_trainers_text_and_its_battle_deal_no_pad_and_record_no_ground`, `a_trainers_challenge_does_not_wall_the_road_to_the_forests_north_gate` (ROM) | **fixed** in the macro seam: `state::trainer_engaged` reads `wStatusFlags7` bit 3 (`BIT_TRAINER_BATTLE`, set by `CheckFightingMapTrainers`, cleared at `.battleOccurred`); `PokeState`'s `scene` is `Unknown` on an overworld frame with it set and `scripted` is true. `controllable` and `scene::detect` unchanged; in macros mode `game.scene` reads `unknown` on those frames. `docs/design/macros.md` 12.25 |
|
|
||||||
| 61b | the south gate deals `GO OUT` (back to Route 2) beside the forest door the objective's road takes | the "a room has to be leavable" tier in a gate whose way on is a passage | -- | **left**: a way back is the fly's choice; with the corridor open no survey stays on it |
|
|
||||||
|
|
||||||
### Before and after
|
|
||||||
|
|
||||||
The route survey, 72,000 frames (20.1 brain minutes), base `main` vs branch:
|
|
||||||
|
|
||||||
| driver | base | branch |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| uniform, seed 7 (the live ring) | rung 9, 1,202 tiles, north gate never, (1, 18) walled | **BOULDER BADGE**, 1,647 tiles, north gate f20273, Pewter f23756 |
|
|
||||||
| uniform, default seed | rung 10, 1,356 | rung 11, 1,476 |
|
|
||||||
| `GO OBJECTIVE` preferred | rung 11, 1,529 | rung 12, 1,492 |
|
|
||||||
| `GO OUT`, `GO OBJECTIVE` preferred | rung 11, 1,376 | rung 11, 1,446 |
|
|
||||||
| uniform, seed 12345 | rung 11, 1,765 | identical |
|
|
||||||
|
|
||||||
(1, 18) is walled on every base arm and on no branch arm. The ROM-gated run (the seed-7 driver,
|
|
||||||
40,000 frames): base walled at frame 16,389, rung 9, **fails**; branch: north gate f20272, Route 2
|
|
||||||
f21209, Pewter f23755, passes.
|
|
||||||
|
|
||||||
The stub-readout trap hunt (30 brain minutes, service frame since FND-01; `main` `4d82f7d` vs
|
|
||||||
branch): both arms rung 10, 362 distinct tiles, 147 macros all done, 11 of 113 windows flagged.
|
|
||||||
**The stub does not walk the ring**: it passes the corridor northward and never comes back, so
|
|
||||||
the only difference is 134 overworld frames the branch reads `Unknown`, the trainer gaps. The
|
|
||||||
survey and the ROM test are the reproduction.
|
|
||||||
|
|
||||||
### Other gates
|
|
||||||
|
|
||||||
The north gate's `GO OUT` is toward Pewter (tier 2) and its forest door is withheld; the Route 2
|
|
||||||
gate, Diglett's Cave's Route 2 house and the Route 22 gate are off the graph (every door `LAST_MAP`
|
|
||||||
but Diglett's passage). The Route 22 gate is the one building with `LAST_MAP` on both sides of two
|
|
||||||
different maps (its script sets `wLastMap` by row: under 4 is Route 23); `outdoor_of` cannot name
|
|
||||||
both and will need a per-door answer when a rung routes through it. The trainer gap is not a gate
|
|
||||||
fact: every sighted trainer has it.
|
|
||||||
|
|
||||||
### Overlap
|
|
||||||
|
|
||||||
Row 59 (59f, merged in v0.6.1) found the same five frames on Route 3 and holds the push until
|
|
||||||
thirty frames of overworld; on main that alone keeps (1, 18) clear. This row is the cartridge-fact
|
|
||||||
layer under it: the pad is empty through the bubble too, no ground is recorded, and it does not
|
|
||||||
depend on the gap staying under thirty frames. The ROM test asserts no pad on an overworld frame
|
|
||||||
with the bit set, which row 59 alone does not meet.
|
|
||||||
|
|
||||||
### Gates
|
|
||||||
|
|
||||||
On `main` `510727c` (v0.6.1, row 59 merged):
|
|
||||||
|
|
||||||
- `cargo test --release -p flybrain-gb` with `FLY_ROM`: 461 passed, 0 failed.
|
|
||||||
- `rom_macros_mode` with the row-61 and row-59 checkpoints: row 61's test and row 59's three pass.
|
|
||||||
Row 61's test on the branch with `trainer_engaged` neutered (main's behaviour, row 59's settle
|
|
||||||
alone): the wall stays clear, and a pad is dealt on 67 of 67 challenge frames -- **fails**; on
|
|
||||||
the branch 0 of 67 -- passes.
|
|
||||||
- `cargo clippy --workspace --all-targets -- -D warnings`: clean.
|
|
||||||
- `npm test` 663 passed; `npm run typecheck` clean; `infra/tests/lint.sh` ALL CHECKS PASSED.
|
|
||||||
- `flysim --print-compatibility`, raw and macros: 648 bytes, sha256 `8ce67b97...a8f68`, the same
|
|
||||||
as `main`. Decoder, reward catalog, adapter version and roles untouched.
|
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,7 @@ pct exec $CTID -- /opt/fly/bin/fly-reset-to-milestone $N
|
||||||
# (a) the running release already wrote that checkpoint -> nothing to deploy, skip to 5.
|
# (a) the running release already wrote that checkpoint -> nothing to deploy, skip to 5.
|
||||||
# (b) the new build bumps the ADAPTER VERSION and nothing else -> name the checkpoint's
|
# (b) the new build bumps the ADAPTER VERSION and nothing else -> name the checkpoint's
|
||||||
# adapter so the gate and flysim both migrate instead of refusing:
|
# adapter so the gate and flysim both migrate instead of refusing:
|
||||||
FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 infra/05-deploy.sh <release-env> <release-tarball>
|
FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 infra/05-deploy.sh <release-env> <release-tarball>
|
||||||
# The gate logs "the adapter version is the only difference, and it is named; the run is KEPT
|
# The gate logs "the adapter version is the only difference, and it is named; the run is KEPT
|
||||||
# and migrated", and writes FLY_ACCEPT_ADAPTERS into /etc/fly/fly.env so flysim applies the
|
# and migrated", and writes FLY_ACCEPT_ADAPTERS into /etc/fly/fly.env so flysim applies the
|
||||||
# same rule at restore. Anything else about the string differing is still a refusal.
|
# same rule at restore. Anything else about the string differing is still a refusal.
|
||||||
|
|
|
||||||
5
infra/env/example.env
vendored
5
infra/env/example.env
vendored
|
|
@ -335,9 +335,8 @@ FLY_FEED_VIA=direct
|
||||||
# two strings AND the new build's adapter declares a migration from that one. A
|
# two strings AND the new build's adapter declares a migration from that one. A
|
||||||
# dataset, kernel, plasticity, emulator or state-format difference is still a
|
# dataset, kernel, plasticity, emulator or state-format difference is still a
|
||||||
# refusal. Set it for the one deploy that needs it and leave it out afterwards;
|
# refusal. Set it for the one deploy that needs it and leave it out afterwards;
|
||||||
# 05-deploy writes it into /etc/fly/fly.env only while it is set. The v0.6.0
|
# 05-deploy writes it into /etc/fly/fly.env only while it is set.
|
||||||
# deploy (pokered-unique8-v7, the engagement rewards) is the one that needs:
|
# FLY_ACCEPT_ADAPTERS=pokered-unique8-v5
|
||||||
# FLY_ACCEPT_ADAPTERS=pokered-unique8-v6
|
|
||||||
|
|
||||||
# --- push mode --------------------------------------------------------------
|
# --- push mode --------------------------------------------------------------
|
||||||
# local: flypush.service stays disabled, everything else identical to prod.
|
# local: flypush.service stays disabled, everything else identical to prod.
|
||||||
|
|
|
||||||
1
services/flysim/Cargo.lock
generated
1
services/flysim/Cargo.lock
generated
|
|
@ -508,7 +508,6 @@ dependencies = [
|
||||||
"jsonschema",
|
"jsonschema",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ pub fn example_composition() -> LegacyComposition {
|
||||||
byte_length: 1_048_576,
|
byte_length: 1_048_576,
|
||||||
format: "gb-rom".to_owned(),
|
format: "gb-rom".to_owned(),
|
||||||
},
|
},
|
||||||
adapter: "pokered-unique8-v7".to_owned(),
|
adapter: "pokered-unique8-v6".to_owned(),
|
||||||
symbol_provenance: pokered.to_owned(),
|
symbol_provenance: pokered.to_owned(),
|
||||||
mode: "macros".to_owned(),
|
mode: "macros".to_owned(),
|
||||||
macro_channels: decoder_vector("macros")["macroChannels"]
|
macro_channels: decoder_vector("macros")["macroChannels"]
|
||||||
|
|
@ -71,7 +71,7 @@ pub fn example_composition() -> LegacyComposition {
|
||||||
audio_sample_rate: 48_000,
|
audio_sample_rate: 48_000,
|
||||||
},
|
},
|
||||||
flysim_compatibility: format!(
|
flysim_compatibility: format!(
|
||||||
"{}/pokered-unique8-v7/{}/{}/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:{pokered}/statefmt:199616-x86_64-unknown-linux-gnu",
|
"{}/pokered-unique8-v6/{}/{}/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:{pokered}/statefmt:199616-x86_64-unknown-linux-gnu",
|
||||||
gameboy::KERNEL_VERSION,
|
gameboy::KERNEL_VERSION,
|
||||||
gameboy::FAFB_V783_FINGERPRINT,
|
gameboy::FAFB_V783_FINGERPRINT,
|
||||||
gameboy::PLASTICITY_VERSION,
|
gameboy::PLASTICITY_VERSION,
|
||||||
|
|
|
||||||
|
|
@ -492,7 +492,7 @@
|
||||||
"byteLength": "1048576",
|
"byteLength": "1048576",
|
||||||
"format": "gb-rom"
|
"format": "gb-rom"
|
||||||
},
|
},
|
||||||
"adapter": "pokered-unique8-v7",
|
"adapter": "pokered-unique8-v6",
|
||||||
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
||||||
"mode": "macros",
|
"mode": "macros",
|
||||||
"macroChannels": [
|
"macroChannels": [
|
||||||
|
|
@ -560,9 +560,9 @@
|
||||||
"episodePolicy": "legacy-ratchet-rollback-v1",
|
"episodePolicy": "legacy-ratchet-rollback-v1",
|
||||||
"restore": "legacy-transient-reset",
|
"restore": "legacy-transient-reset",
|
||||||
"checkpointFormatOfRecord": "FLYSIM01",
|
"checkpointFormatOfRecord": "FLYSIM01",
|
||||||
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
||||||
},
|
},
|
||||||
"digest": "44916db0a0846d3338f50d4f24d9d4e0214fa239af7d15e4174213cd93b07e28",
|
"digest": "77d8a88ff7fea51eb29b1ae75fc9cf8c17c184e6a1e5a585e86399f41fd9c9b0",
|
||||||
"recipeLines": [
|
"recipeLines": [
|
||||||
"fly-session/composition-v1",
|
"fly-session/composition-v1",
|
||||||
"session=<sessionId>",
|
"session=<sessionId>",
|
||||||
|
|
|
||||||
|
|
@ -5202,457 +5202,7 @@
|
||||||
"byteLength": "1048576",
|
"byteLength": "1048576",
|
||||||
"format": "gb-rom"
|
"format": "gb-rom"
|
||||||
},
|
},
|
||||||
"adapter": "pokered-unique8-v7",
|
"adapter": "pokered-unique8-v6",
|
||||||
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
|
||||||
"mode": "macros",
|
|
||||||
"macroChannels": [
|
|
||||||
"macro_go_objective",
|
|
||||||
"macro_talk",
|
|
||||||
"macro_next",
|
|
||||||
"macro_move_1"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
|
||||||
"environment": {
|
|
||||||
"extensions": [
|
|
||||||
"gameboy-slots-v1"
|
|
||||||
],
|
|
||||||
"slots": [
|
|
||||||
"best"
|
|
||||||
],
|
|
||||||
"stepDuration": {
|
|
||||||
"numerator": "8572265625",
|
|
||||||
"denominator": "512"
|
|
||||||
},
|
|
||||||
"inspectionSchema": {
|
|
||||||
"id": "gameboy-memory-inspection-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
|
||||||
},
|
|
||||||
"controllerSchema": {
|
|
||||||
"id": "gameboy-joypad-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
|
||||||
},
|
|
||||||
"setupFrames": 1,
|
|
||||||
"audio": {
|
|
||||||
"sampleRate": 48000,
|
|
||||||
"channels": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"episodePolicy": "legacy-ratchet-rollback-v1",
|
|
||||||
"restore": "legacy-transient-reset",
|
|
||||||
"checkpointFormatOfRecord": "FLYSIM01",
|
|
||||||
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
|
||||||
},
|
|
||||||
"reason": "the profile AssetRef digest is the legacy profile's"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "composition in raw mode with macro channels",
|
|
||||||
"type": "LegacyGameboyComposition",
|
|
||||||
"value": {
|
|
||||||
"compositionId": "pokered-live",
|
|
||||||
"scheduler": "lockstep-v1",
|
|
||||||
"profile": {
|
|
||||||
"id": "gameboy-legacy-fafb-v783-v1",
|
|
||||||
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
|
||||||
"byteLength": "1137",
|
|
||||||
"format": "fly-profile-v1"
|
|
||||||
},
|
|
||||||
"executor": {
|
|
||||||
"id": "pokered-macros-v1",
|
|
||||||
"rom": {
|
|
||||||
"id": "pokered-rom",
|
|
||||||
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
|
||||||
"byteLength": "1048576",
|
|
||||||
"format": "gb-rom"
|
|
||||||
},
|
|
||||||
"adapter": "pokered-unique8-v7",
|
|
||||||
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
|
||||||
"mode": "raw",
|
|
||||||
"macroChannels": [
|
|
||||||
"macro_go_objective",
|
|
||||||
"macro_talk",
|
|
||||||
"macro_next",
|
|
||||||
"macro_move_1"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
|
||||||
"environment": {
|
|
||||||
"extensions": [
|
|
||||||
"gameboy-slots-v1"
|
|
||||||
],
|
|
||||||
"slots": [
|
|
||||||
"best"
|
|
||||||
],
|
|
||||||
"stepDuration": {
|
|
||||||
"numerator": "8572265625",
|
|
||||||
"denominator": "512"
|
|
||||||
},
|
|
||||||
"inspectionSchema": {
|
|
||||||
"id": "gameboy-memory-inspection-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
|
||||||
},
|
|
||||||
"controllerSchema": {
|
|
||||||
"id": "gameboy-joypad-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
|
||||||
},
|
|
||||||
"setupFrames": 1,
|
|
||||||
"audio": {
|
|
||||||
"sampleRate": 48000,
|
|
||||||
"channels": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"episodePolicy": "legacy-ratchet-rollback-v1",
|
|
||||||
"restore": "legacy-transient-reset",
|
|
||||||
"checkpointFormatOfRecord": "FLYSIM01",
|
|
||||||
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
|
||||||
},
|
|
||||||
"reason": "raw mode deals no macro channels"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "composition in macros mode with no channels",
|
|
||||||
"type": "LegacyGameboyComposition",
|
|
||||||
"value": {
|
|
||||||
"compositionId": "pokered-live",
|
|
||||||
"scheduler": "lockstep-v1",
|
|
||||||
"profile": {
|
|
||||||
"id": "gameboy-legacy-fafb-v783-v1",
|
|
||||||
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
|
||||||
"byteLength": "1137",
|
|
||||||
"format": "fly-profile-v1"
|
|
||||||
},
|
|
||||||
"executor": {
|
|
||||||
"id": "pokered-macros-v1",
|
|
||||||
"rom": {
|
|
||||||
"id": "pokered-rom",
|
|
||||||
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
|
||||||
"byteLength": "1048576",
|
|
||||||
"format": "gb-rom"
|
|
||||||
},
|
|
||||||
"adapter": "pokered-unique8-v7",
|
|
||||||
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
|
||||||
"mode": "macros",
|
|
||||||
"macroChannels": []
|
|
||||||
},
|
|
||||||
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
|
||||||
"environment": {
|
|
||||||
"extensions": [
|
|
||||||
"gameboy-slots-v1"
|
|
||||||
],
|
|
||||||
"slots": [
|
|
||||||
"best"
|
|
||||||
],
|
|
||||||
"stepDuration": {
|
|
||||||
"numerator": "8572265625",
|
|
||||||
"denominator": "512"
|
|
||||||
},
|
|
||||||
"inspectionSchema": {
|
|
||||||
"id": "gameboy-memory-inspection-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
|
||||||
},
|
|
||||||
"controllerSchema": {
|
|
||||||
"id": "gameboy-joypad-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
|
||||||
},
|
|
||||||
"setupFrames": 1,
|
|
||||||
"audio": {
|
|
||||||
"sampleRate": 48000,
|
|
||||||
"channels": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"episodePolicy": "legacy-ratchet-rollback-v1",
|
|
||||||
"restore": "legacy-transient-reset",
|
|
||||||
"checkpointFormatOfRecord": "FLYSIM01",
|
|
||||||
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
|
||||||
},
|
|
||||||
"reason": "macros mode needs its channels"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "composition with another executor",
|
|
||||||
"type": "LegacyGameboyComposition",
|
|
||||||
"value": {
|
|
||||||
"compositionId": "pokered-live",
|
|
||||||
"scheduler": "lockstep-v1",
|
|
||||||
"profile": {
|
|
||||||
"id": "gameboy-legacy-fafb-v783-v1",
|
|
||||||
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
|
||||||
"byteLength": "1137",
|
|
||||||
"format": "fly-profile-v1"
|
|
||||||
},
|
|
||||||
"executor": {
|
|
||||||
"id": "identity-v1",
|
|
||||||
"rom": {
|
|
||||||
"id": "pokered-rom",
|
|
||||||
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
|
||||||
"byteLength": "1048576",
|
|
||||||
"format": "gb-rom"
|
|
||||||
},
|
|
||||||
"adapter": "pokered-unique8-v7",
|
|
||||||
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
|
||||||
"mode": "macros",
|
|
||||||
"macroChannels": [
|
|
||||||
"macro_go_objective",
|
|
||||||
"macro_talk",
|
|
||||||
"macro_next",
|
|
||||||
"macro_move_1"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
|
||||||
"environment": {
|
|
||||||
"extensions": [
|
|
||||||
"gameboy-slots-v1"
|
|
||||||
],
|
|
||||||
"slots": [
|
|
||||||
"best"
|
|
||||||
],
|
|
||||||
"stepDuration": {
|
|
||||||
"numerator": "8572265625",
|
|
||||||
"denominator": "512"
|
|
||||||
},
|
|
||||||
"inspectionSchema": {
|
|
||||||
"id": "gameboy-memory-inspection-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
|
||||||
},
|
|
||||||
"controllerSchema": {
|
|
||||||
"id": "gameboy-joypad-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
|
||||||
},
|
|
||||||
"setupFrames": 1,
|
|
||||||
"audio": {
|
|
||||||
"sampleRate": 48000,
|
|
||||||
"channels": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"episodePolicy": "legacy-ratchet-rollback-v1",
|
|
||||||
"restore": "legacy-transient-reset",
|
|
||||||
"checkpointFormatOfRecord": "FLYSIM01",
|
|
||||||
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
|
||||||
},
|
|
||||||
"reason": "the executor extension is pokered-macros-v1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "composition with exact restore",
|
|
||||||
"type": "LegacyGameboyComposition",
|
|
||||||
"value": {
|
|
||||||
"compositionId": "pokered-live",
|
|
||||||
"scheduler": "lockstep-v1",
|
|
||||||
"profile": {
|
|
||||||
"id": "gameboy-legacy-fafb-v783-v1",
|
|
||||||
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
|
||||||
"byteLength": "1137",
|
|
||||||
"format": "fly-profile-v1"
|
|
||||||
},
|
|
||||||
"executor": {
|
|
||||||
"id": "pokered-macros-v1",
|
|
||||||
"rom": {
|
|
||||||
"id": "pokered-rom",
|
|
||||||
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
|
||||||
"byteLength": "1048576",
|
|
||||||
"format": "gb-rom"
|
|
||||||
},
|
|
||||||
"adapter": "pokered-unique8-v7",
|
|
||||||
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
|
||||||
"mode": "macros",
|
|
||||||
"macroChannels": [
|
|
||||||
"macro_go_objective",
|
|
||||||
"macro_talk",
|
|
||||||
"macro_next",
|
|
||||||
"macro_move_1"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
|
||||||
"environment": {
|
|
||||||
"extensions": [
|
|
||||||
"gameboy-slots-v1"
|
|
||||||
],
|
|
||||||
"slots": [
|
|
||||||
"best"
|
|
||||||
],
|
|
||||||
"stepDuration": {
|
|
||||||
"numerator": "8572265625",
|
|
||||||
"denominator": "512"
|
|
||||||
},
|
|
||||||
"inspectionSchema": {
|
|
||||||
"id": "gameboy-memory-inspection-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
|
||||||
},
|
|
||||||
"controllerSchema": {
|
|
||||||
"id": "gameboy-joypad-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
|
||||||
},
|
|
||||||
"setupFrames": 1,
|
|
||||||
"audio": {
|
|
||||||
"sampleRate": 48000,
|
|
||||||
"channels": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"episodePolicy": "legacy-ratchet-rollback-v1",
|
|
||||||
"restore": "exact",
|
|
||||||
"checkpointFormatOfRecord": "FLYSIM01",
|
|
||||||
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
|
||||||
},
|
|
||||||
"reason": "the legacy composition declares legacy-transient-reset"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "composition without the slots extension",
|
|
||||||
"type": "LegacyGameboyComposition",
|
|
||||||
"value": {
|
|
||||||
"compositionId": "pokered-live",
|
|
||||||
"scheduler": "lockstep-v1",
|
|
||||||
"profile": {
|
|
||||||
"id": "gameboy-legacy-fafb-v783-v1",
|
|
||||||
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
|
||||||
"byteLength": "1137",
|
|
||||||
"format": "fly-profile-v1"
|
|
||||||
},
|
|
||||||
"executor": {
|
|
||||||
"id": "pokered-macros-v1",
|
|
||||||
"rom": {
|
|
||||||
"id": "pokered-rom",
|
|
||||||
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
|
||||||
"byteLength": "1048576",
|
|
||||||
"format": "gb-rom"
|
|
||||||
},
|
|
||||||
"adapter": "pokered-unique8-v7",
|
|
||||||
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
|
||||||
"mode": "macros",
|
|
||||||
"macroChannels": [
|
|
||||||
"macro_go_objective",
|
|
||||||
"macro_talk",
|
|
||||||
"macro_next",
|
|
||||||
"macro_move_1"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
|
||||||
"environment": {
|
|
||||||
"extensions": [
|
|
||||||
"world-step-v1"
|
|
||||||
],
|
|
||||||
"slots": [
|
|
||||||
"best"
|
|
||||||
],
|
|
||||||
"stepDuration": {
|
|
||||||
"numerator": "8572265625",
|
|
||||||
"denominator": "512"
|
|
||||||
},
|
|
||||||
"inspectionSchema": {
|
|
||||||
"id": "gameboy-memory-inspection-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
|
||||||
},
|
|
||||||
"controllerSchema": {
|
|
||||||
"id": "gameboy-joypad-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
|
||||||
},
|
|
||||||
"setupFrames": 1,
|
|
||||||
"audio": {
|
|
||||||
"sampleRate": 48000,
|
|
||||||
"channels": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"episodePolicy": "legacy-ratchet-rollback-v1",
|
|
||||||
"restore": "legacy-transient-reset",
|
|
||||||
"checkpointFormatOfRecord": "FLYSIM01",
|
|
||||||
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
|
||||||
},
|
|
||||||
"reason": "the rollback policy needs gameboy-slots-v1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "composition with a 60 Hz step",
|
|
||||||
"type": "LegacyGameboyComposition",
|
|
||||||
"value": {
|
|
||||||
"compositionId": "pokered-live",
|
|
||||||
"scheduler": "lockstep-v1",
|
|
||||||
"profile": {
|
|
||||||
"id": "gameboy-legacy-fafb-v783-v1",
|
|
||||||
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
|
||||||
"byteLength": "1137",
|
|
||||||
"format": "fly-profile-v1"
|
|
||||||
},
|
|
||||||
"executor": {
|
|
||||||
"id": "pokered-macros-v1",
|
|
||||||
"rom": {
|
|
||||||
"id": "pokered-rom",
|
|
||||||
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
|
||||||
"byteLength": "1048576",
|
|
||||||
"format": "gb-rom"
|
|
||||||
},
|
|
||||||
"adapter": "pokered-unique8-v7",
|
|
||||||
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
|
||||||
"mode": "macros",
|
|
||||||
"macroChannels": [
|
|
||||||
"macro_go_objective",
|
|
||||||
"macro_talk",
|
|
||||||
"macro_next",
|
|
||||||
"macro_move_1"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
|
||||||
"environment": {
|
|
||||||
"extensions": [
|
|
||||||
"gameboy-slots-v1"
|
|
||||||
],
|
|
||||||
"slots": [
|
|
||||||
"best"
|
|
||||||
],
|
|
||||||
"stepDuration": {
|
|
||||||
"numerator": "50000000",
|
|
||||||
"denominator": "3"
|
|
||||||
},
|
|
||||||
"inspectionSchema": {
|
|
||||||
"id": "gameboy-memory-inspection-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
|
||||||
},
|
|
||||||
"controllerSchema": {
|
|
||||||
"id": "gameboy-joypad-v1",
|
|
||||||
"version": 1,
|
|
||||||
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
|
||||||
},
|
|
||||||
"setupFrames": 1,
|
|
||||||
"audio": {
|
|
||||||
"sampleRate": 48000,
|
|
||||||
"channels": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"episodePolicy": "legacy-ratchet-rollback-v1",
|
|
||||||
"restore": "legacy-transient-reset",
|
|
||||||
"checkpointFormatOfRecord": "FLYSIM01",
|
|
||||||
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
|
||||||
},
|
|
||||||
"reason": "one Game Boy frame is 8572265625/512 ns, not 1/60 s"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "composition whose compatibility string names another adapter",
|
|
||||||
"type": "LegacyGameboyComposition",
|
|
||||||
"value": {
|
|
||||||
"compositionId": "pokered-live",
|
|
||||||
"scheduler": "lockstep-v1",
|
|
||||||
"profile": {
|
|
||||||
"id": "gameboy-legacy-fafb-v783-v1",
|
|
||||||
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
|
||||||
"byteLength": "1137",
|
|
||||||
"format": "fly-profile-v1"
|
|
||||||
},
|
|
||||||
"executor": {
|
|
||||||
"id": "pokered-macros-v1",
|
|
||||||
"rom": {
|
|
||||||
"id": "pokered-rom",
|
|
||||||
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
|
||||||
"byteLength": "1048576",
|
|
||||||
"format": "gb-rom"
|
|
||||||
},
|
|
||||||
"adapter": "pokered-unique8-v7",
|
|
||||||
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
||||||
"mode": "macros",
|
"mode": "macros",
|
||||||
"macroChannels": [
|
"macroChannels": [
|
||||||
|
|
@ -5695,6 +5245,456 @@
|
||||||
"checkpointFormatOfRecord": "FLYSIM01",
|
"checkpointFormatOfRecord": "FLYSIM01",
|
||||||
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
||||||
},
|
},
|
||||||
|
"reason": "the profile AssetRef digest is the legacy profile's"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "composition in raw mode with macro channels",
|
||||||
|
"type": "LegacyGameboyComposition",
|
||||||
|
"value": {
|
||||||
|
"compositionId": "pokered-live",
|
||||||
|
"scheduler": "lockstep-v1",
|
||||||
|
"profile": {
|
||||||
|
"id": "gameboy-legacy-fafb-v783-v1",
|
||||||
|
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
||||||
|
"byteLength": "1137",
|
||||||
|
"format": "fly-profile-v1"
|
||||||
|
},
|
||||||
|
"executor": {
|
||||||
|
"id": "pokered-macros-v1",
|
||||||
|
"rom": {
|
||||||
|
"id": "pokered-rom",
|
||||||
|
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
||||||
|
"byteLength": "1048576",
|
||||||
|
"format": "gb-rom"
|
||||||
|
},
|
||||||
|
"adapter": "pokered-unique8-v6",
|
||||||
|
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
||||||
|
"mode": "raw",
|
||||||
|
"macroChannels": [
|
||||||
|
"macro_go_objective",
|
||||||
|
"macro_talk",
|
||||||
|
"macro_next",
|
||||||
|
"macro_move_1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
||||||
|
"environment": {
|
||||||
|
"extensions": [
|
||||||
|
"gameboy-slots-v1"
|
||||||
|
],
|
||||||
|
"slots": [
|
||||||
|
"best"
|
||||||
|
],
|
||||||
|
"stepDuration": {
|
||||||
|
"numerator": "8572265625",
|
||||||
|
"denominator": "512"
|
||||||
|
},
|
||||||
|
"inspectionSchema": {
|
||||||
|
"id": "gameboy-memory-inspection-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
||||||
|
},
|
||||||
|
"controllerSchema": {
|
||||||
|
"id": "gameboy-joypad-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
||||||
|
},
|
||||||
|
"setupFrames": 1,
|
||||||
|
"audio": {
|
||||||
|
"sampleRate": 48000,
|
||||||
|
"channels": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"episodePolicy": "legacy-ratchet-rollback-v1",
|
||||||
|
"restore": "legacy-transient-reset",
|
||||||
|
"checkpointFormatOfRecord": "FLYSIM01",
|
||||||
|
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
||||||
|
},
|
||||||
|
"reason": "raw mode deals no macro channels"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "composition in macros mode with no channels",
|
||||||
|
"type": "LegacyGameboyComposition",
|
||||||
|
"value": {
|
||||||
|
"compositionId": "pokered-live",
|
||||||
|
"scheduler": "lockstep-v1",
|
||||||
|
"profile": {
|
||||||
|
"id": "gameboy-legacy-fafb-v783-v1",
|
||||||
|
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
||||||
|
"byteLength": "1137",
|
||||||
|
"format": "fly-profile-v1"
|
||||||
|
},
|
||||||
|
"executor": {
|
||||||
|
"id": "pokered-macros-v1",
|
||||||
|
"rom": {
|
||||||
|
"id": "pokered-rom",
|
||||||
|
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
||||||
|
"byteLength": "1048576",
|
||||||
|
"format": "gb-rom"
|
||||||
|
},
|
||||||
|
"adapter": "pokered-unique8-v6",
|
||||||
|
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
||||||
|
"mode": "macros",
|
||||||
|
"macroChannels": []
|
||||||
|
},
|
||||||
|
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
||||||
|
"environment": {
|
||||||
|
"extensions": [
|
||||||
|
"gameboy-slots-v1"
|
||||||
|
],
|
||||||
|
"slots": [
|
||||||
|
"best"
|
||||||
|
],
|
||||||
|
"stepDuration": {
|
||||||
|
"numerator": "8572265625",
|
||||||
|
"denominator": "512"
|
||||||
|
},
|
||||||
|
"inspectionSchema": {
|
||||||
|
"id": "gameboy-memory-inspection-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
||||||
|
},
|
||||||
|
"controllerSchema": {
|
||||||
|
"id": "gameboy-joypad-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
||||||
|
},
|
||||||
|
"setupFrames": 1,
|
||||||
|
"audio": {
|
||||||
|
"sampleRate": 48000,
|
||||||
|
"channels": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"episodePolicy": "legacy-ratchet-rollback-v1",
|
||||||
|
"restore": "legacy-transient-reset",
|
||||||
|
"checkpointFormatOfRecord": "FLYSIM01",
|
||||||
|
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
||||||
|
},
|
||||||
|
"reason": "macros mode needs its channels"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "composition with another executor",
|
||||||
|
"type": "LegacyGameboyComposition",
|
||||||
|
"value": {
|
||||||
|
"compositionId": "pokered-live",
|
||||||
|
"scheduler": "lockstep-v1",
|
||||||
|
"profile": {
|
||||||
|
"id": "gameboy-legacy-fafb-v783-v1",
|
||||||
|
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
||||||
|
"byteLength": "1137",
|
||||||
|
"format": "fly-profile-v1"
|
||||||
|
},
|
||||||
|
"executor": {
|
||||||
|
"id": "identity-v1",
|
||||||
|
"rom": {
|
||||||
|
"id": "pokered-rom",
|
||||||
|
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
||||||
|
"byteLength": "1048576",
|
||||||
|
"format": "gb-rom"
|
||||||
|
},
|
||||||
|
"adapter": "pokered-unique8-v6",
|
||||||
|
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
||||||
|
"mode": "macros",
|
||||||
|
"macroChannels": [
|
||||||
|
"macro_go_objective",
|
||||||
|
"macro_talk",
|
||||||
|
"macro_next",
|
||||||
|
"macro_move_1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
||||||
|
"environment": {
|
||||||
|
"extensions": [
|
||||||
|
"gameboy-slots-v1"
|
||||||
|
],
|
||||||
|
"slots": [
|
||||||
|
"best"
|
||||||
|
],
|
||||||
|
"stepDuration": {
|
||||||
|
"numerator": "8572265625",
|
||||||
|
"denominator": "512"
|
||||||
|
},
|
||||||
|
"inspectionSchema": {
|
||||||
|
"id": "gameboy-memory-inspection-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
||||||
|
},
|
||||||
|
"controllerSchema": {
|
||||||
|
"id": "gameboy-joypad-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
||||||
|
},
|
||||||
|
"setupFrames": 1,
|
||||||
|
"audio": {
|
||||||
|
"sampleRate": 48000,
|
||||||
|
"channels": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"episodePolicy": "legacy-ratchet-rollback-v1",
|
||||||
|
"restore": "legacy-transient-reset",
|
||||||
|
"checkpointFormatOfRecord": "FLYSIM01",
|
||||||
|
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
||||||
|
},
|
||||||
|
"reason": "the executor extension is pokered-macros-v1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "composition with exact restore",
|
||||||
|
"type": "LegacyGameboyComposition",
|
||||||
|
"value": {
|
||||||
|
"compositionId": "pokered-live",
|
||||||
|
"scheduler": "lockstep-v1",
|
||||||
|
"profile": {
|
||||||
|
"id": "gameboy-legacy-fafb-v783-v1",
|
||||||
|
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
||||||
|
"byteLength": "1137",
|
||||||
|
"format": "fly-profile-v1"
|
||||||
|
},
|
||||||
|
"executor": {
|
||||||
|
"id": "pokered-macros-v1",
|
||||||
|
"rom": {
|
||||||
|
"id": "pokered-rom",
|
||||||
|
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
||||||
|
"byteLength": "1048576",
|
||||||
|
"format": "gb-rom"
|
||||||
|
},
|
||||||
|
"adapter": "pokered-unique8-v6",
|
||||||
|
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
||||||
|
"mode": "macros",
|
||||||
|
"macroChannels": [
|
||||||
|
"macro_go_objective",
|
||||||
|
"macro_talk",
|
||||||
|
"macro_next",
|
||||||
|
"macro_move_1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
||||||
|
"environment": {
|
||||||
|
"extensions": [
|
||||||
|
"gameboy-slots-v1"
|
||||||
|
],
|
||||||
|
"slots": [
|
||||||
|
"best"
|
||||||
|
],
|
||||||
|
"stepDuration": {
|
||||||
|
"numerator": "8572265625",
|
||||||
|
"denominator": "512"
|
||||||
|
},
|
||||||
|
"inspectionSchema": {
|
||||||
|
"id": "gameboy-memory-inspection-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
||||||
|
},
|
||||||
|
"controllerSchema": {
|
||||||
|
"id": "gameboy-joypad-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
||||||
|
},
|
||||||
|
"setupFrames": 1,
|
||||||
|
"audio": {
|
||||||
|
"sampleRate": 48000,
|
||||||
|
"channels": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"episodePolicy": "legacy-ratchet-rollback-v1",
|
||||||
|
"restore": "exact",
|
||||||
|
"checkpointFormatOfRecord": "FLYSIM01",
|
||||||
|
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
||||||
|
},
|
||||||
|
"reason": "the legacy composition declares legacy-transient-reset"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "composition without the slots extension",
|
||||||
|
"type": "LegacyGameboyComposition",
|
||||||
|
"value": {
|
||||||
|
"compositionId": "pokered-live",
|
||||||
|
"scheduler": "lockstep-v1",
|
||||||
|
"profile": {
|
||||||
|
"id": "gameboy-legacy-fafb-v783-v1",
|
||||||
|
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
||||||
|
"byteLength": "1137",
|
||||||
|
"format": "fly-profile-v1"
|
||||||
|
},
|
||||||
|
"executor": {
|
||||||
|
"id": "pokered-macros-v1",
|
||||||
|
"rom": {
|
||||||
|
"id": "pokered-rom",
|
||||||
|
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
||||||
|
"byteLength": "1048576",
|
||||||
|
"format": "gb-rom"
|
||||||
|
},
|
||||||
|
"adapter": "pokered-unique8-v6",
|
||||||
|
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
||||||
|
"mode": "macros",
|
||||||
|
"macroChannels": [
|
||||||
|
"macro_go_objective",
|
||||||
|
"macro_talk",
|
||||||
|
"macro_next",
|
||||||
|
"macro_move_1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
||||||
|
"environment": {
|
||||||
|
"extensions": [
|
||||||
|
"world-step-v1"
|
||||||
|
],
|
||||||
|
"slots": [
|
||||||
|
"best"
|
||||||
|
],
|
||||||
|
"stepDuration": {
|
||||||
|
"numerator": "8572265625",
|
||||||
|
"denominator": "512"
|
||||||
|
},
|
||||||
|
"inspectionSchema": {
|
||||||
|
"id": "gameboy-memory-inspection-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
||||||
|
},
|
||||||
|
"controllerSchema": {
|
||||||
|
"id": "gameboy-joypad-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
||||||
|
},
|
||||||
|
"setupFrames": 1,
|
||||||
|
"audio": {
|
||||||
|
"sampleRate": 48000,
|
||||||
|
"channels": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"episodePolicy": "legacy-ratchet-rollback-v1",
|
||||||
|
"restore": "legacy-transient-reset",
|
||||||
|
"checkpointFormatOfRecord": "FLYSIM01",
|
||||||
|
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
||||||
|
},
|
||||||
|
"reason": "the rollback policy needs gameboy-slots-v1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "composition with a 60 Hz step",
|
||||||
|
"type": "LegacyGameboyComposition",
|
||||||
|
"value": {
|
||||||
|
"compositionId": "pokered-live",
|
||||||
|
"scheduler": "lockstep-v1",
|
||||||
|
"profile": {
|
||||||
|
"id": "gameboy-legacy-fafb-v783-v1",
|
||||||
|
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
||||||
|
"byteLength": "1137",
|
||||||
|
"format": "fly-profile-v1"
|
||||||
|
},
|
||||||
|
"executor": {
|
||||||
|
"id": "pokered-macros-v1",
|
||||||
|
"rom": {
|
||||||
|
"id": "pokered-rom",
|
||||||
|
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
||||||
|
"byteLength": "1048576",
|
||||||
|
"format": "gb-rom"
|
||||||
|
},
|
||||||
|
"adapter": "pokered-unique8-v6",
|
||||||
|
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
||||||
|
"mode": "macros",
|
||||||
|
"macroChannels": [
|
||||||
|
"macro_go_objective",
|
||||||
|
"macro_talk",
|
||||||
|
"macro_next",
|
||||||
|
"macro_move_1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
||||||
|
"environment": {
|
||||||
|
"extensions": [
|
||||||
|
"gameboy-slots-v1"
|
||||||
|
],
|
||||||
|
"slots": [
|
||||||
|
"best"
|
||||||
|
],
|
||||||
|
"stepDuration": {
|
||||||
|
"numerator": "50000000",
|
||||||
|
"denominator": "3"
|
||||||
|
},
|
||||||
|
"inspectionSchema": {
|
||||||
|
"id": "gameboy-memory-inspection-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
||||||
|
},
|
||||||
|
"controllerSchema": {
|
||||||
|
"id": "gameboy-joypad-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
||||||
|
},
|
||||||
|
"setupFrames": 1,
|
||||||
|
"audio": {
|
||||||
|
"sampleRate": 48000,
|
||||||
|
"channels": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"episodePolicy": "legacy-ratchet-rollback-v1",
|
||||||
|
"restore": "legacy-transient-reset",
|
||||||
|
"checkpointFormatOfRecord": "FLYSIM01",
|
||||||
|
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
||||||
|
},
|
||||||
|
"reason": "one Game Boy frame is 8572265625/512 ns, not 1/60 s"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "composition whose compatibility string names another adapter",
|
||||||
|
"type": "LegacyGameboyComposition",
|
||||||
|
"value": {
|
||||||
|
"compositionId": "pokered-live",
|
||||||
|
"scheduler": "lockstep-v1",
|
||||||
|
"profile": {
|
||||||
|
"id": "gameboy-legacy-fafb-v783-v1",
|
||||||
|
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
|
||||||
|
"byteLength": "1137",
|
||||||
|
"format": "fly-profile-v1"
|
||||||
|
},
|
||||||
|
"executor": {
|
||||||
|
"id": "pokered-macros-v1",
|
||||||
|
"rom": {
|
||||||
|
"id": "pokered-rom",
|
||||||
|
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
|
||||||
|
"byteLength": "1048576",
|
||||||
|
"format": "gb-rom"
|
||||||
|
},
|
||||||
|
"adapter": "pokered-unique8-v6",
|
||||||
|
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
||||||
|
"mode": "macros",
|
||||||
|
"macroChannels": [
|
||||||
|
"macro_go_objective",
|
||||||
|
"macro_talk",
|
||||||
|
"macro_next",
|
||||||
|
"macro_move_1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
|
||||||
|
"environment": {
|
||||||
|
"extensions": [
|
||||||
|
"gameboy-slots-v1"
|
||||||
|
],
|
||||||
|
"slots": [
|
||||||
|
"best"
|
||||||
|
],
|
||||||
|
"stepDuration": {
|
||||||
|
"numerator": "8572265625",
|
||||||
|
"denominator": "512"
|
||||||
|
},
|
||||||
|
"inspectionSchema": {
|
||||||
|
"id": "gameboy-memory-inspection-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
|
||||||
|
},
|
||||||
|
"controllerSchema": {
|
||||||
|
"id": "gameboy-joypad-v1",
|
||||||
|
"version": 1,
|
||||||
|
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
|
||||||
|
},
|
||||||
|
"setupFrames": 1,
|
||||||
|
"audio": {
|
||||||
|
"sampleRate": 48000,
|
||||||
|
"channels": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"episodePolicy": "legacy-ratchet-rollback-v1",
|
||||||
|
"restore": "legacy-transient-reset",
|
||||||
|
"checkpointFormatOfRecord": "FLYSIM01",
|
||||||
|
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v5/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
||||||
|
},
|
||||||
"reason": "the FLYSIM01 string and the declaration describe the same fly"
|
"reason": "the FLYSIM01 string and the declaration describe the same fly"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -5717,7 +5717,7 @@
|
||||||
"byteLength": "1048576",
|
"byteLength": "1048576",
|
||||||
"format": "gb-rom"
|
"format": "gb-rom"
|
||||||
},
|
},
|
||||||
"adapter": "pokered-unique8-v7",
|
"adapter": "pokered-unique8-v6",
|
||||||
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
||||||
"mode": "macros",
|
"mode": "macros",
|
||||||
"macroChannels": [
|
"macroChannels": [
|
||||||
|
|
@ -5758,7 +5758,7 @@
|
||||||
"episodePolicy": "legacy-ratchet-rollback-v1",
|
"episodePolicy": "legacy-ratchet-rollback-v1",
|
||||||
"restore": "legacy-transient-reset",
|
"restore": "legacy-transient-reset",
|
||||||
"checkpointFormatOfRecord": "FLYSESS1",
|
"checkpointFormatOfRecord": "FLYSESS1",
|
||||||
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
||||||
},
|
},
|
||||||
"reason": "FLYSIM01 stays until RETIRE-01"
|
"reason": "FLYSIM01 stays until RETIRE-01"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3337,7 +3337,7 @@
|
||||||
"byteLength": "1048576",
|
"byteLength": "1048576",
|
||||||
"format": "gb-rom"
|
"format": "gb-rom"
|
||||||
},
|
},
|
||||||
"adapter": "pokered-unique8-v7",
|
"adapter": "pokered-unique8-v6",
|
||||||
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
||||||
"mode": "macros",
|
"mode": "macros",
|
||||||
"macroChannels": [
|
"macroChannels": [
|
||||||
|
|
@ -3405,11 +3405,11 @@
|
||||||
"episodePolicy": "legacy-ratchet-rollback-v1",
|
"episodePolicy": "legacy-ratchet-rollback-v1",
|
||||||
"restore": "legacy-transient-reset",
|
"restore": "legacy-transient-reset",
|
||||||
"checkpointFormatOfRecord": "FLYSIM01",
|
"checkpointFormatOfRecord": "FLYSIM01",
|
||||||
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
||||||
},
|
},
|
||||||
"note": "placeholder ROM digest; real macros-mode decoder digest and channels",
|
"note": "placeholder ROM digest; real macros-mode decoder digest and channels",
|
||||||
"canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"82b6601f0390b742a67eca44ef935b49e16a73315db70f5644f1cbd21f7c8a52\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v7\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[\"macro_go_objective\",\"macro_go_out\",\"macro_go_warp\",\"macro_go_route\",\"macro_go_item\",\"macro_go_npc\",\"macro_go_frontier\",\"macro_go_shop\",\"macro_go_heal\",\"macro_talk\",\"macro_menu\",\"macro_next\",\"macro_yes\",\"macro_no\",\"macro_close\",\"macro_confirm\",\"macro_back\",\"macro_move_1\",\"macro_move_2\",\"macro_move_3\",\"macro_move_4\",\"macro_switch\",\"macro_item\",\"macro_throw_ball\",\"macro_run\",\"macro_buy_potion\",\"macro_buy_ball\",\"macro_buy_antidote\",\"macro_buy_repel\",\"macro_heal\",\"macro_leave\"],\"mode\":\"macros\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}",
|
"canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"82b6601f0390b742a67eca44ef935b49e16a73315db70f5644f1cbd21f7c8a52\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v6\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[\"macro_go_objective\",\"macro_go_out\",\"macro_go_warp\",\"macro_go_route\",\"macro_go_item\",\"macro_go_npc\",\"macro_go_frontier\",\"macro_go_shop\",\"macro_go_heal\",\"macro_talk\",\"macro_menu\",\"macro_next\",\"macro_yes\",\"macro_no\",\"macro_close\",\"macro_confirm\",\"macro_back\",\"macro_move_1\",\"macro_move_2\",\"macro_move_3\",\"macro_move_4\",\"macro_switch\",\"macro_item\",\"macro_throw_ball\",\"macro_run\",\"macro_buy_potion\",\"macro_buy_ball\",\"macro_buy_antidote\",\"macro_buy_repel\",\"macro_heal\",\"macro_leave\"],\"mode\":\"macros\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}",
|
||||||
"digest": "44916db0a0846d3338f50d4f24d9d4e0214fa239af7d15e4174213cd93b07e28"
|
"digest": "77d8a88ff7fea51eb29b1ae75fc9cf8c17c184e6a1e5a585e86399f41fd9c9b0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "an example legacy composition in raw mode",
|
"name": "an example legacy composition in raw mode",
|
||||||
|
|
@ -3431,7 +3431,7 @@
|
||||||
"byteLength": "1048576",
|
"byteLength": "1048576",
|
||||||
"format": "gb-rom"
|
"format": "gb-rom"
|
||||||
},
|
},
|
||||||
"adapter": "pokered-unique8-v7",
|
"adapter": "pokered-unique8-v6",
|
||||||
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"macroChannels": []
|
"macroChannels": []
|
||||||
|
|
@ -3467,11 +3467,11 @@
|
||||||
"episodePolicy": "legacy-ratchet-rollback-v1",
|
"episodePolicy": "legacy-ratchet-rollback-v1",
|
||||||
"restore": "legacy-transient-reset",
|
"restore": "legacy-transient-reset",
|
||||||
"checkpointFormatOfRecord": "FLYSIM01",
|
"checkpointFormatOfRecord": "FLYSIM01",
|
||||||
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
|
||||||
},
|
},
|
||||||
"note": "",
|
"note": "",
|
||||||
"canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"6234e4a0363cfe82b8d515943c4f645a3960eda8fa5bf9465009061f80d50812\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v7\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[],\"mode\":\"raw\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}",
|
"canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"6234e4a0363cfe82b8d515943c4f645a3960eda8fa5bf9465009061f80d50812\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v6\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[],\"mode\":\"raw\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}",
|
||||||
"digest": "568eceeb052ebf4fbd0c10661e227783cecc8ac7fee75ae6ba84335c684bfb7c"
|
"digest": "ac42e702ec6c9b09482b1ee335743ef85fe599a72e2c34a4d32aea67d67d0201"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "a rank climb: slot saved, then the milestone capture",
|
"name": "a rank climb: slot saved, then the milestone capture",
|
||||||
|
|
|
||||||
|
|
@ -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` does not come through here: its frame (`flysim::frame::LegacyFrame`) drives the
|
/// `flysim`'s sim loop does not come through here (it drives the network and the decoder
|
||||||
/// network and the decoder itself, so that the macro layer can read the emulator between the
|
/// itself, so that the macro layer can read the emulator between the two), but the bench that
|
||||||
/// two, and it installs a frame and its rewards straight after the frame rather than after the
|
/// measures the two arms against each other does, and a bench whose macro group could win a
|
||||||
/// next ticks. Every `flysim` harness runs that frame too.
|
/// channel the scene never bound would be measuring something the stream cannot do.
|
||||||
pub fn tick_bound(
|
pub fn tick_bound(
|
||||||
&mut self,
|
&mut self,
|
||||||
frame: &[u8],
|
frame: &[u8],
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ ROM bytes -> Emulator::run_frame -> RGBA frame + PCM + WRAM
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `emulator` | Safe wrapper over binjgb: frames, framebuffer, buttons, WRAM, audio, save states, ROM hash |
|
| `emulator` | Safe wrapper over binjgb: frames, framebuffer, buttons, WRAM, audio, save states, ROM hash |
|
||||||
| `adapter` | `GameAdapter`, `RewardEvent`, `ProgressSnapshot`, `MemoryReader`, `adapter_for` |
|
| `adapter` | `GameAdapter`, `RewardEvent`, `ProgressSnapshot`, `MemoryReader`, `adapter_for` |
|
||||||
| `pokemon_red` | The `pokered-unique8-v7` reward adapter, its catalog and its generated symbol table |
|
| `pokemon_red` | The `pokered-unique8-v5` reward adapter, its catalog and its generated symbol table |
|
||||||
| `platformer` | The `sml-progress-v1` Super Mario Land adapter, its catalog and its RAM map |
|
| `platformer` | The `sml-progress-v1` Super Mario Land adapter, its catalog and its RAM map |
|
||||||
| `ratchet` | The progress ratchet, generic over the adapter's rank and its `RecoveryPolicy` |
|
| `ratchet` | The progress ratchet, generic over the adapter's rank and its `RecoveryPolicy` |
|
||||||
| `recovery` | Rolling the game back to the ratchet's best safe snapshot |
|
| `recovery` | Rolling the game back to the ratchet's best safe snapshot |
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,10 @@ impl MemoryReader for &mut dyn MemoryReader {
|
||||||
/// One reward payout in one frame.
|
/// One reward payout in one frame.
|
||||||
///
|
///
|
||||||
/// `kind` is an adapter-owned interned name (Pokémon: `milestone`,
|
/// `kind` is an adapter-owned interned name (Pokémon: `milestone`,
|
||||||
/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`,
|
/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`); it is the
|
||||||
/// `talk`, `item`); it is the key the statistics counters and the on-screen ticker group
|
/// key the statistics counters and the on-screen ticker group by. Field names
|
||||||
/// by. Field names serialize exactly as the prototype's `RewardEvent` did, so a
|
/// serialize exactly as the prototype's `RewardEvent` did, so a checkpoint
|
||||||
/// checkpoint written by either implementation reads in the other.
|
/// written by either implementation reads in the other.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
pub struct RewardEvent {
|
pub struct RewardEvent {
|
||||||
pub kind: &'static str,
|
pub kind: &'static str,
|
||||||
|
|
@ -241,7 +241,7 @@ impl std::error::Error for AdapterError {}
|
||||||
/// A game, as the sim loop sees it.
|
/// A game, as the sim loop sees it.
|
||||||
pub trait GameAdapter: Send {
|
pub trait GameAdapter: Send {
|
||||||
/// Adapter version string, pinned into the checkpoint compatibility string.
|
/// Adapter version string, pinned into the checkpoint compatibility string.
|
||||||
/// Pokémon: `pokered-unique8-v7`.
|
/// Pokémon: `pokered-unique8-v6`.
|
||||||
fn id(&self) -> &'static str;
|
fn id(&self) -> &'static str;
|
||||||
|
|
||||||
/// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration
|
/// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ pub const PROTOTYPE_PLASTICITY_VERSION: &str = "fly-kc-mbon-rstdp-v2";
|
||||||
pub struct Compatibility<'a> {
|
pub struct Compatibility<'a> {
|
||||||
/// `kernelVersion(config)` from the neural library.
|
/// `kernelVersion(config)` from the neural library.
|
||||||
pub neural_kernel_version: &'a str,
|
pub neural_kernel_version: &'a str,
|
||||||
/// The adapter's version string, e.g. `pokered-unique8-v7`.
|
/// The adapter's version string, e.g. `pokered-unique8-v6`.
|
||||||
pub adapter: &'a str,
|
pub adapter: &'a str,
|
||||||
/// The dataset's seven SHA-256 digests joined with `:`.
|
/// The dataset's seven SHA-256 digests joined with `:`.
|
||||||
pub dataset_fingerprint: &'a str,
|
pub dataset_fingerprint: &'a str,
|
||||||
|
|
@ -78,7 +78,7 @@ const ADAPTER_SEGMENT: usize = 1;
|
||||||
/// The environment variable that opts a deploy into the adapter migration.
|
/// The environment variable that opts a deploy into the adapter migration.
|
||||||
///
|
///
|
||||||
/// Read by flysim at restore and by `infra/05-deploy.sh`'s compatibility gate. Comma- or
|
/// Read by flysim at restore and by `infra/05-deploy.sh`'s compatibility gate. Comma- or
|
||||||
/// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v6`.
|
/// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v5`.
|
||||||
pub const ACCEPT_ADAPTERS_ENV: &str = "FLY_ACCEPT_ADAPTERS";
|
pub const ACCEPT_ADAPTERS_ENV: &str = "FLY_ACCEPT_ADAPTERS";
|
||||||
|
|
||||||
/// What a build may do with a checkpoint whose compatibility string is not its own.
|
/// What a build may do with a checkpoint whose compatibility string is not its own.
|
||||||
|
|
@ -180,7 +180,7 @@ mod tests {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fixture().prototype_string(),
|
fixture().prototype_string(),
|
||||||
concat!(
|
concat!(
|
||||||
"lif-1ms-f64-v2/pokered-unique8-v7/aa:bb:cc:dd:ee:ff:00/",
|
"lif-1ms-f64-v2/pokered-unique8-v6/aa:bb:cc:dd:ee:ff:00/",
|
||||||
"fly-kc-mbon-rstdp-v2/",
|
"fly-kc-mbon-rstdp-v2/",
|
||||||
"binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/",
|
"binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/",
|
||||||
"pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
"pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
|
||||||
|
|
@ -194,37 +194,37 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_identical_string_restores_without_any_opt_in() {
|
fn an_identical_string_restores_without_any_opt_in() {
|
||||||
let current = with_adapter("pokered-unique8-v7");
|
let current = with_adapter("pokered-unique8-v6");
|
||||||
assert_eq!(decide(¤t, ¤t, &[], &[]), RestoreDecision::Exact);
|
assert_eq!(decide(¤t, ¤t, &[], &[]), RestoreDecision::Exact);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_v6_checkpoint_restores_under_v7_only_with_the_opt_in() {
|
fn a_v5_checkpoint_restores_under_v6_only_with_the_opt_in() {
|
||||||
let old = with_adapter("pokered-unique8-v6");
|
let old = with_adapter("pokered-unique8-v5");
|
||||||
let new = with_adapter("pokered-unique8-v7");
|
let new = with_adapter("pokered-unique8-v6");
|
||||||
let migrates = ["pokered-unique8-v6"];
|
let migrates = ["pokered-unique8-v5"];
|
||||||
|
|
||||||
assert!(matches!(decide(&old, &new, &migrates, &[]), RestoreDecision::Refuse(_)));
|
assert!(matches!(decide(&old, &new, &migrates, &[]), RestoreDecision::Refuse(_)));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v6"))),
|
decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v5"))),
|
||||||
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v6".to_string() }
|
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() }
|
||||||
);
|
);
|
||||||
// And only for a pair the running adapter says it can migrate.
|
// And only for a pair the running adapter says it can migrate.
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v6"))),
|
decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v5"))),
|
||||||
RestoreDecision::Refuse(_)
|
RestoreDecision::Refuse(_)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nothing_but_the_adapter_segment_may_move() {
|
fn nothing_but_the_adapter_segment_may_move() {
|
||||||
let migrates = ["pokered-unique8-v6"];
|
let migrates = ["pokered-unique8-v5"];
|
||||||
let accepted = accepted_adapters(Some("pokered-unique8-v6"));
|
let accepted = accepted_adapters(Some("pokered-unique8-v5"));
|
||||||
let new = with_adapter("pokered-unique8-v7");
|
let new = with_adapter("pokered-unique8-v6");
|
||||||
|
|
||||||
// A different dataset, with the same adapter bump, is not a migration.
|
// A different dataset, with the same adapter bump, is not a migration.
|
||||||
let other_dataset = Compatibility {
|
let other_dataset = Compatibility {
|
||||||
adapter: "pokered-unique8-v6",
|
adapter: "pokered-unique8-v5",
|
||||||
dataset_fingerprint: "00:11:22:33:44:55:66",
|
dataset_fingerprint: "00:11:22:33:44:55:66",
|
||||||
..fixture()
|
..fixture()
|
||||||
}
|
}
|
||||||
|
|
@ -236,7 +236,7 @@ mod tests {
|
||||||
|
|
||||||
// Neither is a different kernel, and neither is a string of another shape.
|
// Neither is a different kernel, and neither is a string of another shape.
|
||||||
let other_kernel =
|
let other_kernel =
|
||||||
Compatibility { adapter: "pokered-unique8-v6", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() }
|
Compatibility { adapter: "pokered-unique8-v5", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() }
|
||||||
.string();
|
.string();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
decide(&other_kernel, &new, &migrates, &accepted),
|
decide(&other_kernel, &new, &migrates, &accepted),
|
||||||
|
|
@ -250,8 +250,8 @@ mod tests {
|
||||||
assert!(accepted_adapters(None).is_empty());
|
assert!(accepted_adapters(None).is_empty());
|
||||||
assert!(accepted_adapters(Some(" ")).is_empty());
|
assert!(accepted_adapters(Some(" ")).is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
accepted_adapters(Some("pokered-unique8-v6, pokered-unique8-v5")),
|
accepted_adapters(Some("pokered-unique8-v5, pokered-unique8-v4")),
|
||||||
vec!["pokered-unique8-v6".to_string(), "pokered-unique8-v5".to_string()]
|
vec!["pokered-unique8-v5".to_string(), "pokered-unique8-v4".to_string()]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! The `pokered-unique8-v7` reward catalog.
|
//! The `pokered-unique8-v6` reward catalog.
|
||||||
//!
|
//!
|
||||||
//! A direct port of the prototype's `src/reward/catalog.ts`, including the
|
//! A direct port of the prototype's `src/reward/catalog.ts`, including the
|
||||||
//! declaration order, which is the order `counts` and `last` serialize in.
|
//! declaration order, which is the order `counts` and `last` serialize in.
|
||||||
|
|
@ -21,8 +21,6 @@ pub mod kind {
|
||||||
pub const BADGE: &str = "badge";
|
pub const BADGE: &str = "badge";
|
||||||
pub const BOUNDARY: &str = "boundary";
|
pub const BOUNDARY: &str = "boundary";
|
||||||
pub const CATCH: &str = "catch";
|
pub const CATCH: &str = "catch";
|
||||||
pub const TALK: &str = "talk";
|
|
||||||
pub const ITEM: &str = "item";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a `catch` of a species this run has already caught pays.
|
/// What a `catch` of a species this run has already caught pays.
|
||||||
|
|
@ -46,7 +44,7 @@ pub struct RewardRule {
|
||||||
pub stimulation_ms: u32,
|
pub stimulation_ms: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const REWARDS: [RewardRule; 11] = [
|
pub const REWARDS: [RewardRule; 9] = [
|
||||||
RewardRule {
|
RewardRule {
|
||||||
kind: kind::MILESTONE,
|
kind: kind::MILESTONE,
|
||||||
label: "Story",
|
label: "Story",
|
||||||
|
|
@ -127,30 +125,6 @@ pub const REWARDS: [RewardRule; 11] = [
|
||||||
value: 0.30,
|
value: 0.30,
|
||||||
stimulation_ms: 150,
|
stimulation_ms: 150,
|
||||||
},
|
},
|
||||||
// The operator's decision of 2026-09-23: pay the fly for engaging with what is *inside* a
|
|
||||||
// building rather than for leaving it (`boundary` pays nothing on an indoor map from v7).
|
|
||||||
// Both appended, for the reason every rule since `boundary` was: the declaration order is
|
|
||||||
// the key order `counts` serializes in, and every checkpoint already written carries the
|
|
||||||
// first nine in this order.
|
|
||||||
//
|
|
||||||
// `talk` is one payout per person or sign per map for the lifetime of the ledger, and only
|
|
||||||
// indoors: the conversation the fly opened by pressing A at it, paid when the box closes.
|
|
||||||
RewardRule {
|
|
||||||
kind: kind::TALK,
|
|
||||||
label: "Talk",
|
|
||||||
trigger: "Conversation the fly opened indoors; once per map and person or sign",
|
|
||||||
value: 0.10,
|
|
||||||
stimulation_ms: 100,
|
|
||||||
},
|
|
||||||
// `item` is one payout per item ball or hidden item for the lifetime of the ledger, on any
|
|
||||||
// map: the cartridge's own "this one has been taken" bit rising.
|
|
||||||
RewardRule {
|
|
||||||
kind: kind::ITEM,
|
|
||||||
label: "Item",
|
|
||||||
trigger: "Item ball or hidden item picked up; once per item",
|
|
||||||
value: 0.15,
|
|
||||||
stimulation_ms: 120,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Position of `kind` in [`REWARDS`], or `None` for an unknown kind. This is
|
/// Position of `kind` in [`REWARDS`], or `None` for an unknown kind. This is
|
||||||
|
|
@ -255,17 +229,12 @@ mod tests {
|
||||||
assert_eq!(rule(kind::CATCH).unwrap().value, 0.30);
|
assert_eq!(rule(kind::CATCH).unwrap().value, 0.30);
|
||||||
assert_eq!(CATCH_REPEAT_VALUE, 0.10);
|
assert_eq!(CATCH_REPEAT_VALUE, 0.10);
|
||||||
assert_eq!(rule(kind::CATCH).unwrap().stimulation_ms, 150);
|
assert_eq!(rule(kind::CATCH).unwrap().stimulation_ms, 150);
|
||||||
// Nor these: the operator's engagement rules, `pokered-unique8-v7`.
|
|
||||||
assert_eq!(rule(kind::TALK).unwrap().value, 0.10);
|
|
||||||
assert_eq!(rule(kind::TALK).unwrap().stimulation_ms, 100);
|
|
||||||
assert_eq!(rule(kind::ITEM).unwrap().value, 0.15);
|
|
||||||
assert_eq!(rule(kind::ITEM).unwrap().stimulation_ms, 120);
|
|
||||||
assert!(rule("blackout").is_none(), "the catalog has no penalties");
|
assert!(rule("blackout").is_none(), "the catalog has no penalties");
|
||||||
assert!(REWARDS.iter().all(|rule| rule.value > 0.0));
|
assert!(REWARDS.iter().all(|rule| rule.value > 0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_rules_are_appended_so_the_older_key_order_does_not_move() {
|
fn the_catch_rule_is_last_so_the_older_key_order_does_not_move() {
|
||||||
let order: Vec<&str> = REWARDS.iter().map(|rule| rule.kind).collect();
|
let order: Vec<&str> = REWARDS.iter().map(|rule| rule.kind).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
order,
|
order,
|
||||||
|
|
@ -279,11 +248,9 @@ mod tests {
|
||||||
kind::BADGE,
|
kind::BADGE,
|
||||||
kind::BOUNDARY,
|
kind::BOUNDARY,
|
||||||
kind::CATCH,
|
kind::CATCH,
|
||||||
kind::TALK,
|
|
||||||
kind::ITEM,
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(index(kind::ITEM), Some(REWARDS.len() - 1));
|
assert_eq!(index(kind::CATCH), Some(REWARDS.len() - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,423 +0,0 @@
|
||||||
//! What the engagement rules of `pokered-unique8-v7` read: indoors, a conversation the fly
|
|
||||||
//! opened, and an item picked up.
|
|
||||||
//!
|
|
||||||
//! The operator's decision of 2026-09-23 (`docs/rewards-learning.md`, "Engagement rewards"):
|
|
||||||
//! pay the fly for engaging with what is inside a building -- `talk` and `item` -- and stop
|
|
||||||
//! paying `boundary` for walking back out of one. Everything here is a read of game memory
|
|
||||||
//! after a frame; nothing chooses, biases or presses a button, and nothing is checkpointed.
|
|
||||||
//! The lifetime ledgers the payouts are keyed into are the adapter's own `seen` set, in
|
|
||||||
//! [`super::PokemonRedReward`].
|
|
||||||
|
|
||||||
use crate::adapter::MemoryReader;
|
|
||||||
|
|
||||||
use super::macros::state::{Facing, Player};
|
|
||||||
use super::state::{self, poke};
|
|
||||||
use super::symbols::ram;
|
|
||||||
|
|
||||||
/// Tileset ids, `constants/tileset_constants.asm` at [`super::symbols::POKERED_COMMIT`]. Only the
|
|
||||||
/// ones the indoor rule names.
|
|
||||||
pub mod tileset {
|
|
||||||
pub const OVERWORLD: u8 = 0;
|
|
||||||
pub const FOREST: u8 = 3;
|
|
||||||
pub const UNDERGROUND: u8 = 11;
|
|
||||||
pub const SHIP_PORT: u8 = 14;
|
|
||||||
pub const CAVERN: u8 = 17;
|
|
||||||
pub const PLATEAU: u8 = 23;
|
|
||||||
/// `DEF NUM_TILESETS EQU const_value`: 24 tilesets, ids 0 to 23.
|
|
||||||
pub const COUNT: u8 = 24;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a map with this tileset is **inside a building**, by the cartridge's own two tables.
|
|
||||||
///
|
|
||||||
/// - `CheckIfInOutsideMap` (`home/overworld.asm`) is the game's own outdoor test: tileset
|
|
||||||
/// `OVERWORLD` or `PLATEAU` is "a town or route", and `WarpFound2` labels the other branch
|
|
||||||
/// `.indoorMaps`. On its own that also calls Viridian Forest and every cave indoor.
|
|
||||||
/// - `BikeRidingTilesets` (`data/tilesets/bike_riding_tilesets.asm`) is the game's list of places
|
|
||||||
/// a bicycle may be ridden -- `OVERWORLD`, `FOREST`, `UNDERGROUND`, `SHIP_PORT`, `CAVERN` -- and
|
|
||||||
/// the bike is the one thing the cartridge refuses *inside a building* by rule.
|
|
||||||
///
|
|
||||||
/// Indoor is neither: not outside, and not somewhere the bike is allowed. That is every house,
|
|
||||||
/// mart, Pokémon Center, gym, gate, lab, museum, the S.S. Anne, Silph Co., the Pokémon Tower, the
|
|
||||||
/// Mansion, the Rocket Hideout and the Indigo Plateau's rooms -- and *not* Viridian Forest, a
|
|
||||||
/// cave, the Underground Path or Vermilion's dock, whose exits are how the fly gets anywhere.
|
|
||||||
/// A tileset id past the table is not indoor: an unreadable map is never a reason to withhold
|
|
||||||
/// `boundary`.
|
|
||||||
pub fn indoor(tileset: u8) -> bool {
|
|
||||||
use tileset::*;
|
|
||||||
tileset < COUNT
|
|
||||||
&& !matches!(
|
|
||||||
tileset,
|
|
||||||
OVERWORLD | PLATEAU | FOREST | UNDERGROUND | SHIP_PORT | CAVERN
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a conversation was with, as `DisplayTextID` names it: a sprite slot, or a sign's text id.
|
|
||||||
///
|
|
||||||
/// The same split the macros' session `talked` ledger uses, but this is not that ledger: the
|
|
||||||
/// payout is keyed into the adapter's lifetime `seen` set, which is checkpointed.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Thing {
|
|
||||||
Sprite(u8),
|
|
||||||
Sign(u8),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A conversation the fly opened and the cartridge has now closed.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct Conversation {
|
|
||||||
pub map: u8,
|
|
||||||
pub thing: Thing,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Conversation {
|
|
||||||
/// The `seen` ledger key: one payout per `(map, object)` for the lifetime of the ledger.
|
|
||||||
pub fn key(&self) -> String {
|
|
||||||
match self.thing {
|
|
||||||
Thing::Sprite(slot) => format!("talk:{}:sprite:{slot}", self.map),
|
|
||||||
Thing::Sign(id) => format!("talk:{}:sign:{id}", self.map),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn label(&self) -> String {
|
|
||||||
match self.thing {
|
|
||||||
Thing::Sprite(slot) => format!("TALKED TO #{slot} IN AREA {}", self.map),
|
|
||||||
Thing::Sign(id) => format!("READ SIGN #{id} IN AREA {}", self.map),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Samples after the box opens by which `DisplayTextID` has certainly written its argument.
|
|
||||||
///
|
|
||||||
/// `DisplayTextIDInit` sets the font bit and then loads the font's tiles into VRAM, which takes
|
|
||||||
/// frames; `DisplayTextID` copies its argument into `wSpriteIndex` only after that. Measured on
|
|
||||||
/// the cartridge (`tests/rom_engage.rs`, the Viridian Forest north gate): the bit rose on one
|
|
||||||
/// frame and the argument arrived **twenty frames** later. Until then the byte still holds
|
|
||||||
/// whatever the *last* text was about -- which may well be the person in front of the fly, from
|
|
||||||
/// a conversation that did not pay -- so it is not read as this conversation's argument until it
|
|
||||||
/// has changed, or until this many samples have gone by, after which an unchanged byte means the
|
|
||||||
/// new text is about the same thing as the last one. More than twice the measured delay.
|
|
||||||
const ARGUMENT_SETTLED: u8 = 45;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
struct Armed {
|
|
||||||
map: u8,
|
|
||||||
x: u32,
|
|
||||||
y: u32,
|
|
||||||
/// The fly had the joypad and was standing still: [`state::controllable`] and a zero
|
|
||||||
/// `wWalkCounter`. The overworld only reads an A press in that state.
|
|
||||||
ready: bool,
|
|
||||||
/// `wSpriteIndex` before the box opened: the previous text's argument.
|
|
||||||
stale: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
struct Opening {
|
|
||||||
map: u8,
|
|
||||||
samples: u8,
|
|
||||||
stale: u8,
|
|
||||||
/// The bottom dialogue box has been on screen during this opening.
|
|
||||||
dialogue: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `talk` rule's per-frame watch. Transient: a restore or a rollback clears it, so a
|
|
||||||
/// conversation in flight at a checkpoint pays nothing, which is the conservative answer.
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
||||||
pub struct TalkWatch {
|
|
||||||
armed: Option<Armed>,
|
|
||||||
opening: Option<Opening>,
|
|
||||||
pending: Option<Conversation>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TalkWatch {
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
*self = Self::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A sample that is not the overworld -- a battle. Whatever the fly was doing before it is
|
|
||||||
/// not what opens the next text box, so the arming is dropped; a conversation already open
|
|
||||||
/// (a trainer the fly spoke to) stays pending and pays when its box is finally closed.
|
|
||||||
pub fn interrupt(&mut self) {
|
|
||||||
self.armed = None;
|
|
||||||
self.opening = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One overworld sample (`wIsInBattle` zero, every playability gate passed).
|
|
||||||
///
|
|
||||||
/// A conversation pays when all of this holds, each read out of WRAM:
|
|
||||||
///
|
|
||||||
/// 1. **The fly started it.** On the last sample before the text box opened
|
|
||||||
/// (`wFontLoaded` bit 0 rising) the fly had the joypad -- no ignored buttons, no simulated
|
|
||||||
/// input, no scripted movement ([`state::controllable`]) -- was standing still
|
|
||||||
/// (`wWalkCounter` zero, which is the only state the overworld reads A in) and stood on the
|
|
||||||
/// tile it is on now. Most script text opens with the joypad already taken, and is not
|
|
||||||
/// `ready`; a map script that runs the frame after a step ends can open text while the fly
|
|
||||||
/// is still `ready`, so it pays only if rule 2 names the thing in front (none in the early
|
|
||||||
/// game; the Fighting Dojo master and the Elite Four once each).
|
|
||||||
/// 2. **It is with the thing in front of the fly.** `DisplayTextID` copies its argument into
|
|
||||||
/// `wSpriteIndex` once the font is loaded ([`ARGUMENT_SETTLED`] has the timing, and why
|
|
||||||
/// the byte is read only once it has changed or settled), in the bottom dialogue box --
|
|
||||||
/// the start menu is drawn elsewhere. A value up to `wNumSprites` is a sprite slot, and that sprite must stand
|
|
||||||
/// on the tile the player faces -- or one further, across a counter, on a tileset that has
|
|
||||||
/// counter tiles (`IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter`). A larger
|
|
||||||
/// value is a text id, and it must be the text id of the sign on the tile the player faces.
|
|
||||||
/// An item ball is a sprite but not a person: it pays `item`, not `talk`.
|
|
||||||
/// 3. **Indoors**, by [`indoor`], on the map the box opened on.
|
|
||||||
/// 4. **It finished.** The box closed again on the same map. A conversation that ends in a
|
|
||||||
/// warp, a restore or a blackout pays nothing.
|
|
||||||
///
|
|
||||||
/// Returns the conversation on the sample the box closes; the caller pays it once per key.
|
|
||||||
pub fn observe(
|
|
||||||
&mut self,
|
|
||||||
memory: &mut dyn MemoryReader,
|
|
||||||
map: u8,
|
|
||||||
x: u32,
|
|
||||||
y: u32,
|
|
||||||
) -> Option<Conversation> {
|
|
||||||
let open = memory.read8(ram::wFontLoaded) & poke::BIT_FONT_LOADED != 0;
|
|
||||||
if !open {
|
|
||||||
// A box that closes before its argument was ever seen to change: a short text about
|
|
||||||
// the same thing as the last one. The argument was written before a letter printed,
|
|
||||||
// so it is this text's; the dialogue box must have been drawn for it.
|
|
||||||
let argument = memory.read8(ram::wSpriteIndex);
|
|
||||||
if let Some(opening) = self.opening.take()
|
|
||||||
&& opening.dialogue
|
|
||||||
&& opening.map == map
|
|
||||||
&& let Some(thing) = thing_named(memory, argument)
|
|
||||||
{
|
|
||||||
self.pending = Some(Conversation { map, thing });
|
|
||||||
}
|
|
||||||
let finished = self
|
|
||||||
.pending
|
|
||||||
.take()
|
|
||||||
.filter(|conversation| conversation.map == map);
|
|
||||||
let ready = state::controllable(memory) && memory.read8(ram::wWalkCounter) == 0;
|
|
||||||
let stale = argument;
|
|
||||||
self.armed = Some(Armed {
|
|
||||||
map,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
ready,
|
|
||||||
stale,
|
|
||||||
});
|
|
||||||
return finished;
|
|
||||||
}
|
|
||||||
if let Some(armed) = self.armed.take()
|
|
||||||
&& armed.ready
|
|
||||||
&& armed.map == map
|
|
||||||
&& armed.x == x
|
|
||||||
&& armed.y == y
|
|
||||||
&& self.pending.is_none()
|
|
||||||
&& indoor(memory.read8(ram::wCurMapTileset))
|
|
||||||
{
|
|
||||||
self.opening = Some(Opening {
|
|
||||||
map,
|
|
||||||
samples: 0,
|
|
||||||
stale: armed.stale,
|
|
||||||
dialogue: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Some(opening) = self.opening.as_mut() {
|
|
||||||
opening.samples += 1;
|
|
||||||
opening.dialogue |= state::text_box(memory).waiting;
|
|
||||||
let map = opening.map;
|
|
||||||
let argument = memory.read8(ram::wSpriteIndex);
|
|
||||||
if argument != opening.stale || opening.samples >= ARGUMENT_SETTLED {
|
|
||||||
// One reading, whichever way it goes: the argument this text was opened with,
|
|
||||||
// and only while the dialogue box is what is drawn (not the start menu's).
|
|
||||||
let dialogue = state::text_box(memory).waiting;
|
|
||||||
self.opening = None;
|
|
||||||
if dialogue && let Some(thing) = thing_named(memory, argument) {
|
|
||||||
self.pending = Some(Conversation { map, thing });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The step `facing` points at from `(x, y)`, or `None` off the top or left of the map.
|
|
||||||
fn ahead(x: u8, y: u8, facing: Facing) -> Option<(u8, u8)> {
|
|
||||||
let (dx, dy) = facing.delta();
|
|
||||||
let x = u8::try_from(i16::from(x) + dx).ok()?;
|
|
||||||
let y = u8::try_from(i16::from(y) + dy).ok()?;
|
|
||||||
Some((x, y))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `wMapSpriteExtraData`'s two bytes for a sprite slot (1-based, as `hSpriteIndex` is).
|
|
||||||
fn extra_data(memory: &mut dyn MemoryReader, slot: u8) -> (u8, u8) {
|
|
||||||
let entry = ram::wMapSpriteExtraData + (u16::from(slot) - 1) * 2;
|
|
||||||
(memory.read8(entry), memory.read8(entry + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether sprite `slot` is an item ball: `LoadMapHeader` writes `(item id, 0)` into its extra
|
|
||||||
/// data for an `ITEM`-flagged `object_event`, `(trainer class, trainer number)` for a `TRAINER`
|
|
||||||
/// one -- trainer numbers start at 1 -- and two zeroes for everything else.
|
|
||||||
fn is_item_ball(memory: &mut dyn MemoryReader, slot: u8) -> bool {
|
|
||||||
let (item, second) = extra_data(memory, slot);
|
|
||||||
item != 0 && second == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `DisplayTextID`'s argument, if it names something the player is facing.
|
|
||||||
///
|
|
||||||
/// The caller also asks for the bottom dialogue box ([`state::text_box`]'s `waiting`):
|
|
||||||
/// `DisplayTextIDInit` draws it for every text id but the start menu's, which it draws at the top
|
|
||||||
/// right instead.
|
|
||||||
fn thing_named(memory: &mut dyn MemoryReader, argument: u8) -> Option<Thing> {
|
|
||||||
if argument == 0 {
|
|
||||||
// TEXT_START_MENU.
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let player: Player = state::player(memory)?;
|
|
||||||
let one = ahead(player.x, player.y, player.facing)?;
|
|
||||||
let sprites = memory.read8(ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
|
|
||||||
if argument <= sprites {
|
|
||||||
let npc = state::npcs(memory)
|
|
||||||
.into_iter()
|
|
||||||
.find(|npc| npc.slot == argument)?;
|
|
||||||
let at = (npc.x, npc.y);
|
|
||||||
let reached = at == one
|
|
||||||
|| (ahead(one.0, one.1, player.facing) == Some(at)
|
|
||||||
&& state::counter_tiles(memory)
|
|
||||||
.iter()
|
|
||||||
.any(|tile| *tile != poke::NO_COUNTER_TILE));
|
|
||||||
if reached && !is_item_ball(memory, argument) {
|
|
||||||
return Some(Thing::Sprite(argument));
|
|
||||||
}
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
state::signs(memory)
|
|
||||||
.into_iter()
|
|
||||||
.any(|sign| sign.text_id == argument && (sign.x, sign.y) == one)
|
|
||||||
.then_some(Thing::Sign(argument))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `wToggleableObjectFlags` is `flag_array $100`.
|
|
||||||
const TOGGLE_BYTES: usize = 32;
|
|
||||||
/// `wObtainedHiddenItemsFlags` is `flag_array MAX_HIDDEN_ITEMS`, and `MAX_HIDDEN_ITEMS` is 112
|
|
||||||
/// (`constants/item_constants.asm`).
|
|
||||||
const HIDDEN_ITEM_BYTES: usize = 14;
|
|
||||||
/// `wToggleableObjectList` is `ds 16 * 2 + 1`: sixteen `(sprite slot, global index)` pairs and a
|
|
||||||
/// `$ff` terminator.
|
|
||||||
const TOGGLE_LIST_ENTRIES: u16 = 16;
|
|
||||||
|
|
||||||
/// The two item balls a script *reveals*: `TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4` (`$87`, the Silph
|
|
||||||
/// Scope) and `TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5` (`$88`, the Lift Key), the only `ITEM`
|
|
||||||
/// `object_event`s `data/maps/toggleable_objects.asm` starts `OFF`, and the only item entries
|
|
||||||
/// `constants/toggle_constants.asm` does not mark "X, never toggled by a script".
|
|
||||||
///
|
|
||||||
/// Every other item ball's bit is clear from a new game until `PickUpItem` sets it, so a set bit
|
|
||||||
/// is a pickup. These two are set from the start and cleared when Giovanni's defeat shows them,
|
|
||||||
/// so seeding them as "already taken" would withhold two payouts for ever. They are the only
|
|
||||||
/// bits the seed leaves out.
|
|
||||||
pub const SCRIPT_SHOWN_ITEM_BALLS: [u8; 2] = [0x87, 0x88];
|
|
||||||
|
|
||||||
/// The cartridge's two "this item has been taken" bitsets, as of one sample.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ItemFlags {
|
|
||||||
toggles: [u8; TOGGLE_BYTES],
|
|
||||||
hidden: [u8; HIDDEN_ITEM_BYTES],
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bit(bytes: &[u8], index: usize) -> bool {
|
|
||||||
bytes
|
|
||||||
.get(index / 8)
|
|
||||||
.is_some_and(|byte| byte & (1 << (index % 8)) != 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ItemFlags {
|
|
||||||
pub fn read(memory: &mut dyn MemoryReader) -> Self {
|
|
||||||
let mut toggles = [0; TOGGLE_BYTES];
|
|
||||||
for (offset, byte) in toggles.iter_mut().enumerate() {
|
|
||||||
*byte = memory.read8(ram::wToggleableObjectFlags + offset as u16);
|
|
||||||
}
|
|
||||||
let mut hidden = [0; HIDDEN_ITEM_BYTES];
|
|
||||||
for (offset, byte) in hidden.iter_mut().enumerate() {
|
|
||||||
*byte = memory.read8(ram::wObtainedHiddenItemsFlags + offset as u16);
|
|
||||||
}
|
|
||||||
Self { toggles, hidden }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ledger keys for every item this state already shows as taken: the seed that stops a
|
|
||||||
/// pickup made before the rule existed from paying after a rollback un-takes it.
|
|
||||||
///
|
|
||||||
/// Every set toggle bit but [`SCRIPT_SHOWN_ITEM_BALLS`] -- which includes the bits of people
|
|
||||||
/// a script has hidden, harmlessly, because only an item ball's key is ever looked up -- and
|
|
||||||
/// every set hidden-item bit.
|
|
||||||
pub fn seed(&self) -> Vec<String> {
|
|
||||||
let mut keys = Vec::new();
|
|
||||||
for index in 0..TOGGLE_BYTES * 8 {
|
|
||||||
if bit(&self.toggles, index) && !SCRIPT_SHOWN_ITEM_BALLS.contains(&(index as u8)) {
|
|
||||||
keys.push(ball_key(index as u8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for index in 0..HIDDEN_ITEM_BYTES * 8 {
|
|
||||||
if bit(&self.hidden, index) {
|
|
||||||
keys.push(hidden_key(index as u8));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keys
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One item the fly has just picked up.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Pickup {
|
|
||||||
pub key: String,
|
|
||||||
pub label: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ball_key(global: u8) -> String {
|
|
||||||
format!("item:{global}")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn hidden_key(index: u8) -> String {
|
|
||||||
format!("hidden:{index}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Items whose "taken" bit rose between `before` and `now`.
|
|
||||||
///
|
|
||||||
/// - **An item ball** is one of this map's toggleable sprites (`wToggleableObjectList`) whose
|
|
||||||
/// extra data says item ([`is_item_ball`]). `PickUpItem` sets its global bit in
|
|
||||||
/// `wToggleableObjectFlags` through `HideObject`, and only after `GiveItem` succeeded, so a full
|
|
||||||
/// bag pays nothing. A toggleable that is not an item -- a person a script hides, a legendary
|
|
||||||
/// after its battle -- is never looked at.
|
|
||||||
/// - **A hidden item** is a bit of `wObtainedHiddenItemsFlags`, which `FoundHiddenItemText` sets
|
|
||||||
/// after `GiveItem` succeeded and nothing else in the game writes. Hidden coins have a bitset of
|
|
||||||
/// their own and are not items.
|
|
||||||
///
|
|
||||||
/// A bit that was already set on the previous sample is not a pickup, which is what keeps a
|
|
||||||
/// restored or seeded state from paying for anything it already holds.
|
|
||||||
pub fn pickups(memory: &mut dyn MemoryReader, before: &ItemFlags, now: &ItemFlags) -> Vec<Pickup> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
if now == before {
|
|
||||||
// The overwhelmingly common frame: nothing was taken, and nothing more need be read.
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
let sprites = memory.read8(ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
|
|
||||||
for entry in 0..TOGGLE_LIST_ENTRIES {
|
|
||||||
let slot = memory.read8(ram::wToggleableObjectList + entry * 2);
|
|
||||||
if slot == 0xff {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let global = memory.read8(ram::wToggleableObjectList + entry * 2 + 1);
|
|
||||||
let index = usize::from(global);
|
|
||||||
if slot == 0 || slot > sprites || !bit(&now.toggles, index) || bit(&before.toggles, index) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (item, second) = extra_data(memory, slot);
|
|
||||||
if item != 0 && second == 0 {
|
|
||||||
out.push(Pickup {
|
|
||||||
key: ball_key(global),
|
|
||||||
label: format!("FOUND ITEM #{item}"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for index in 0..HIDDEN_ITEM_BYTES * 8 {
|
|
||||||
if bit(&now.hidden, index) && !bit(&before.hidden, index) {
|
|
||||||
out.push(Pickup {
|
|
||||||
key: hidden_key(index as u8),
|
|
||||||
label: "FOUND A HIDDEN ITEM".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
@ -416,38 +416,6 @@ impl Wram {
|
||||||
pub const BLOCKSET_BANK: u8 = 0x11;
|
pub const BLOCKSET_BANK: u8 = 0x11;
|
||||||
pub const BLOCKSET_BASE: u16 = 0x4000;
|
pub const BLOCKSET_BASE: u16 = 0x4000;
|
||||||
|
|
||||||
/// Rows of the cartridge's move table, where `data/moves/moves.asm` puts it: `$0E:$4000`,
|
|
||||||
/// six bytes a row in move-id order, each opening with its own id. `(id, effect, power,
|
|
||||||
/// type)`; accuracy 100 and PP 35 stand in for the two bytes nothing here reads.
|
|
||||||
pub fn move_table(&mut self, rows: &[(u8, u8, u8, u8)]) -> &mut Self {
|
|
||||||
use super::state::poke::moves::{ROW_BYTES, TABLE_ADDRESS, TABLE_BANK};
|
|
||||||
for (id, effect, power, kind) in rows {
|
|
||||||
let base = TABLE_ADDRESS + u16::from(id - 1) * ROW_BYTES;
|
|
||||||
for (offset, byte) in [*id, *effect, *power, *kind, 0xff, 35].into_iter().enumerate() {
|
|
||||||
self.rom.insert((TABLE_BANK, base + offset as u16), byte);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One byte of a fake cartridge bank.
|
|
||||||
pub fn rom_byte(&mut self, bank: u8, address: u16, byte: u8) -> &mut Self {
|
|
||||||
self.rom.insert((bank, address), byte);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every stage of both battlers at normal (7), as `InitBattleVariables`-era code leaves them.
|
|
||||||
pub fn normal_stages(&mut self) -> &mut Self {
|
|
||||||
for stat in 0..6 {
|
|
||||||
self.set(ram::wPlayerMonStatMods + stat, 7).set(ram::wEnemyMonStatMods + stat, 7);
|
|
||||||
}
|
|
||||||
for stat in 0..4 {
|
|
||||||
self.set_word_be(ram::wBattleMonAttack + 2 * stat, 12)
|
|
||||||
.set_word_be(ram::wEnemyMonAttack + 2 * stat, 9);
|
|
||||||
}
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which tileset the loaded map uses, for the tile-pair collision lists.
|
/// Which tileset the loaded map uses, for the tile-pair collision lists.
|
||||||
pub fn tileset(&mut self, id: u8) -> &mut Self {
|
pub fn tileset(&mut self, id: u8) -> &mut Self {
|
||||||
self.set(ram::wCurMapTileset, id)
|
self.set(ram::wCurMapTileset, id)
|
||||||
|
|
|
||||||
|
|
@ -377,17 +377,6 @@ pub trait MacroState: GameState {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the battle engine will answer the fly's move `id` with nothing on this frame: a
|
|
||||||
/// stat stage already at its limit, a status move against a target it cannot affect.
|
|
||||||
///
|
|
||||||
/// Row 60 (`docs/design/macros.md` 12.23), read from the cartridge's own move table and the
|
|
||||||
/// bytes its effect routines test ([`crate::pokemon_red::state::move_without_effect`]). The
|
|
||||||
/// default is `false`: a state that cannot read the table has proved nothing, so the `MOVE n`
|
|
||||||
/// buttons stay where they were.
|
|
||||||
fn move_without_effect(&mut self, _id: u8) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable.
|
/// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable.
|
||||||
///
|
///
|
||||||
/// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state
|
/// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state
|
||||||
|
|
|
||||||
|
|
@ -349,9 +349,10 @@ 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(|_| {
|
let approach = standing.and_then(|player| {
|
||||||
let objective = palette::objective_place(&mut state)?;
|
let objective = palette::objective_place(&mut state)?;
|
||||||
let hops = geography::hops(palette::region_here(&mut state)?, objective.map)?;
|
let hops =
|
||||||
|
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);
|
||||||
|
|
@ -616,28 +617,6 @@ 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,12 +149,6 @@ 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)]
|
||||||
|
|
@ -668,9 +662,6 @@ 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
|
||||||
|
|
@ -705,7 +696,6 @@ 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 },
|
||||||
}
|
}
|
||||||
|
|
@ -989,7 +979,6 @@ 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.
|
||||||
|
|
@ -1047,32 +1036,17 @@ 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, for [`PUSH_SETTLE_FRAMES`] running:
|
/// Back in the overworld with the buttons the fly's again: a refusal, written exactly as
|
||||||
/// a refusal, written exactly as section 12.4 and row 37 always wrote it. A battle: a
|
/// section 12.4 and row 37 always wrote it. A battle: a trainer's challenge, and it teaches the
|
||||||
/// trainer's challenge, and it teaches the ledgers nothing. Anything else -- the text, the
|
/// ledgers nothing. Anything else -- the text, the walk, the frames between -- is still the
|
||||||
/// walk, the frames between -- is still the cartridge's, and the decision waits.
|
/// 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 => {
|
Class::Battle | Class::ForcedSwitch => self.pending_push.clear(),
|
||||||
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) {
|
||||||
|
|
@ -1084,7 +1058,7 @@ impl MacroMachine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => self.pending_push_calm = 0,
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ 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;
|
||||||
|
|
@ -45,13 +44,10 @@ 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.
|
||||||
///
|
///
|
||||||
/// Every row is the header's own, checked line by line against the disassembly at the pinned
|
/// Two rows are worth a note. `ROUTE_3` and `ROUTE_4` are connected along the east-west axis even
|
||||||
/// commit (row 59). Four pairs had the right neighbour in the wrong column, and a wrong column is
|
/// though Mt. Moon stands between them, so the walkable path is the cave and not the edge; that
|
||||||
/// a wrong map on the other side of an edge: `ROUTE_3` / `ROUTE_4` (Route 4 is north of Route 3,
|
/// costs nothing, because an edge whose tiles are not walkable produces no exit at all
|
||||||
/// not east, and Route 3's top edge is the road to Mt. Moon's Pokécenter), `ROUTE_14` /
|
/// ([`super::path::exits`] filters on the walkable predicate) and the cave is in [`LINKS`].
|
||||||
/// `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]),
|
||||||
|
|
@ -66,8 +62,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, [maps::ROUTE_4, NONE, maps::PEWTER_CITY, NONE]),
|
(maps::ROUTE_3, [NONE, NONE, maps::PEWTER_CITY, maps::ROUTE_4]),
|
||||||
(maps::ROUTE_4, [NONE, maps::ROUTE_3, NONE, maps::CERULEAN_CITY]),
|
(maps::ROUTE_4, [NONE, NONE, maps::ROUTE_3, 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]),
|
||||||
|
|
@ -77,54 +73,28 @@ 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, NONE, maps::ROUTE_15, maps::ROUTE_13]),
|
(maps::ROUTE_14, [NONE, maps::ROUTE_15, NONE, maps::ROUTE_13]),
|
||||||
(maps::ROUTE_15, [NONE, NONE, maps::FUCHSIA_CITY, maps::ROUTE_14]),
|
(maps::ROUTE_15, [maps::ROUTE_14, NONE, maps::FUCHSIA_CITY, NONE]),
|
||||||
(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]),
|
||||||
(maps::ROUTE_22, [maps::ROUTE_23, NONE, NONE, maps::VIRIDIAN_CITY]),
|
// Route 22 ends at the League gate, which is a building rather than an edge, and this
|
||||||
(maps::ROUTE_23, [maps::INDIGO_PLATEAU, maps::ROUTE_22, NONE, NONE]),
|
// table has no id for it: Indigo Plateau is on the graph but not reachable from the south.
|
||||||
(maps::ROUTE_24, [NONE, maps::CERULEAN_CITY, NONE, maps::ROUTE_25]),
|
(maps::ROUTE_22, [NONE, NONE, NONE, maps::VIRIDIAN_CITY]),
|
||||||
(maps::ROUTE_25, [NONE, NONE, maps::ROUTE_24, NONE]),
|
(maps::ROUTE_23, [maps::INDIGO_PLATEAU, NONE, NONE, 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. Every pair is two warp tables that name each
|
/// at from another map and changes nothing else. A cave with two mouths appears twice, which is
|
||||||
/// other (a `LAST_MAP` door resolved to the one outdoor map whose warps lead in).
|
/// what makes Mt. Moon a way from Route 3 to Route 4.
|
||||||
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),
|
||||||
|
|
@ -152,14 +122,10 @@ 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),
|
||||||
// Mt. Moon has two mouths, and both are on Route 4 (`data/maps/objects/Route4.asm`): (18, 5)
|
(maps::MT_MOON_1F, maps::ROUTE_3),
|
||||||
// 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),
|
||||||
|
|
@ -175,12 +141,11 @@ 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. Row 59 found three more
|
/// out of the gate it had just walked into, once per hold, for four hours.
|
||||||
/// 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: its index in that row. 0 everywhere else.
|
/// Which piece, for a map [`SPLIT`] has a row for; 0 everywhere else.
|
||||||
pub part: u8,
|
pub part: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,268 +154,108 @@ 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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One piece of a split map.
|
/// [`SPLIT`]'s two piece numbers.
|
||||||
struct Piece {
|
|
||||||
/// 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 pieces the player cannot walk between.
|
|
||||||
struct Split {
|
|
||||||
map: u8,
|
|
||||||
pieces: &'static [Piece],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Route 2's two halves, in [`SPLIT`]'s order.
|
|
||||||
#[cfg(test)]
|
|
||||||
const NORTH_PIECE: u8 = 0;
|
const NORTH_PIECE: u8 = 0;
|
||||||
#[cfg(test)]
|
|
||||||
const SOUTH_PIECE: u8 = 1;
|
const SOUTH_PIECE: u8 = 1;
|
||||||
|
|
||||||
/// Route 4's two sides of the mountain.
|
/// A map whose walkable ground is in two pieces, and which of its neighbours each piece touches.
|
||||||
#[cfg(test)]
|
struct Split {
|
||||||
const WEST_SIDE: u8 = 0;
|
map: u8,
|
||||||
const EAST_SIDE: u8 = 1;
|
/// The tile rows each piece's own doorway is on, measured from the cartridge: a tile belongs
|
||||||
|
/// 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],
|
||||||
|
}
|
||||||
|
|
||||||
/// Mt. Moon B1F's four chambers, named by what they hold.
|
/// Every map whose ground is in two pieces. One row, and it took four hours of stream to find.
|
||||||
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.
|
|
||||||
///
|
///
|
||||||
/// Every row is measured from the disassembly at the pinned commit: the map's blocks, its
|
/// `ROUTE_2`, surveyed from the cartridge on 2026-09-17 (`docs/design/macros-wram.md`'s method,
|
||||||
/// tileset's blockset and collision list, the tile-pair walls and the ledges, flooded tile by tile
|
/// the run recorded in `infra/docs/macros-traps.md` row 33). The map is 20 by 72 and its warp
|
||||||
/// (`infra/docs/macros-traps.md` row 59 has the method). A map is listed here only when two of its
|
/// table reads:
|
||||||
/// ways out are on different pieces, and the audit ran over every map on the graph.
|
|
||||||
///
|
///
|
||||||
/// - **`ROUTE_2`** (row 33): the forest's north gate at (3, 11) and Pewter's edge; the south gate
|
/// | warp | tile | into |
|
||||||
/// at (3, 43) and Viridian's. Maps 46, 48 and 49 stay off the graph, the module's standing rule
|
/// | ---: | --- | --- |
|
||||||
/// for a building no rung place needs a route through: 49's two doors are both Route 2's own.
|
/// | 0 | (12, 9) | `DIGLETTS_CAVE_ROUTE_2` (46) |
|
||||||
/// - **`ROUTE_4`** (row 59): Mt. Moon stands across it. The west side holds the Pokécenter at
|
/// | 1 | (3, 11) | `VIRIDIAN_FOREST_NORTH_GATE` (47) |
|
||||||
/// (11, 5), the cave mouth at (18, 5) and the road down to Route 3; the east side holds B1F's
|
/// | 2 | (15, 19) | `ROUTE_2_TRADE_HOUSE` (48) |
|
||||||
/// exit at (24, 5) and the ledges down to Cerulean. From the Pewter side the only way east is
|
/// | 3 | (16, 35) | `ROUTE_2_GATE` (49) |
|
||||||
/// through the mountain.
|
/// | 4 | (15, 39) | `ROUTE_2_GATE` (49) |
|
||||||
/// - **`MT_MOON_B1F`** (row 59): four chambers, each two ladders and nothing between them. The
|
/// | 5 | (3, 43) | `VIRIDIAN_FOREST_SOUTH_GATE` (50) |
|
||||||
/// one road through is 1F (5, 5) to the west chamber, (21, 17) down to B2F, B2F (5, 7) up to the
|
///
|
||||||
/// exit chamber, (27, 3) out onto Route 4's east side. The middle and south chambers are ladders
|
/// with `north: true` and `south: true` in `wCurMapConnections` — Pewter off the top row, Viridian
|
||||||
/// to dead ends on B2F.
|
/// off the bottom one. The two forest gates at rows 11 and 43 are the doorways this splits on.
|
||||||
/// - **`MT_MOON_B2F`** (row 59): the fossil floor, one large piece with the two ladders the road
|
///
|
||||||
/// uses, and two small pieces under the dead-end ladders.
|
/// Maps 46, 48 and 49 are deliberately *not* on the graph, which is this module's standing rule
|
||||||
const SPLIT: &[Split] = &[
|
/// for a building no rung place needs a route through: 49's two doors are both warps of `ROUTE_2`
|
||||||
Split {
|
/// itself, so it is a shortcut within one map rather than a way between two, and 46 and 48 are
|
||||||
|
/// ends of the line. A route the table does not carry is simply not offered; nothing is guessed.
|
||||||
|
const SPLIT: &[Split] = &[Split {
|
||||||
map: maps::ROUTE_2,
|
map: maps::ROUTE_2,
|
||||||
pieces: &[
|
north_door: 11,
|
||||||
Piece {
|
south_door: 43,
|
||||||
doors: &[(1, 3, 11)],
|
north: &[maps::PEWTER_CITY, maps::VIRIDIAN_FOREST_NORTH_GATE],
|
||||||
next: &[
|
south: &[maps::VIRIDIAN_CITY, maps::VIRIDIAN_FOREST_SOUTH_GATE],
|
||||||
Region::whole(maps::PEWTER_CITY),
|
}];
|
||||||
Region::whole(maps::VIRIDIAN_FOREST_NORTH_GATE),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Piece {
|
|
||||||
doors: &[(5, 3, 43)],
|
|
||||||
next: &[
|
|
||||||
Region::whole(maps::VIRIDIAN_CITY),
|
|
||||||
Region::whole(maps::VIRIDIAN_FOREST_SOUTH_GATE),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Split {
|
|
||||||
map: maps::ROUTE_4,
|
|
||||||
pieces: &[
|
|
||||||
Piece {
|
|
||||||
doors: &[(0, 11, 5), (1, 18, 5)],
|
|
||||||
next: &[Region::whole(maps::ROUTE_3), Region::whole(maps::MT_MOON_1F)],
|
|
||||||
},
|
|
||||||
Piece {
|
|
||||||
doors: &[(2, 24, 5)],
|
|
||||||
next: &[
|
|
||||||
Region::piece(maps::MT_MOON_B1F, B1F_EXIT),
|
|
||||||
Region::whole(maps::CERULEAN_CITY),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Split {
|
|
||||||
map: maps::MT_MOON_B1F,
|
|
||||||
pieces: &[
|
|
||||||
Piece {
|
|
||||||
doors: &[(6, 23, 3), (7, 27, 3)],
|
|
||||||
next: &[
|
|
||||||
Region::piece(maps::MT_MOON_B2F, B2F_MAIN),
|
|
||||||
Region::piece(maps::ROUTE_4, EAST_SIDE),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Piece {
|
|
||||||
doors: &[(0, 5, 5), (4, 21, 17)],
|
|
||||||
next: &[
|
|
||||||
Region::whole(maps::MT_MOON_1F),
|
|
||||||
Region::piece(maps::MT_MOON_B2F, B2F_MAIN),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Piece {
|
|
||||||
doors: &[(1, 17, 11), (2, 25, 9)],
|
|
||||||
next: &[
|
|
||||||
Region::whole(maps::MT_MOON_1F),
|
|
||||||
Region::piece(maps::MT_MOON_B2F, B2F_NORTH),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Piece {
|
|
||||||
doors: &[(3, 25, 15), (5, 13, 27)],
|
|
||||||
next: &[
|
|
||||||
Region::whole(maps::MT_MOON_1F),
|
|
||||||
Region::piece(maps::MT_MOON_B2F, B2F_SOUTH),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Split {
|
|
||||||
map: maps::MT_MOON_B2F,
|
|
||||||
pieces: &[
|
|
||||||
Piece {
|
|
||||||
doors: &[(1, 21, 17), (3, 5, 7)],
|
|
||||||
next: &[
|
|
||||||
Region::piece(maps::MT_MOON_B1F, B1F_EXIT),
|
|
||||||
Region::piece(maps::MT_MOON_B1F, B1F_WEST),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Piece {
|
|
||||||
doors: &[(0, 25, 9)],
|
|
||||||
next: &[Region::piece(maps::MT_MOON_B1F, B1F_MIDDLE)],
|
|
||||||
},
|
|
||||||
Piece {
|
|
||||||
doors: &[(2, 15, 27)],
|
|
||||||
next: &[Region::piece(maps::MT_MOON_B1F, B1F_SOUTH)],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
fn split_of(map: u8) -> Option<&'static Split> {
|
fn split_of(map: u8) -> Option<&'static Split> {
|
||||||
SPLIT.iter().find(|split| split.map == map)
|
SPLIT.iter().find(|split| split.map == map)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pieces of a split map with their numbers, which are their [`Region::part`]s.
|
/// The piece of `map` a tile on row `y` is in.
|
||||||
fn pieces(split: &'static Split) -> impl Iterator<Item = (u8, &'static Piece)> {
|
///
|
||||||
split.pieces.iter().enumerate().filter_map(|(part, piece)| Some((u8::try_from(part).ok()?, piece)))
|
/// For every map but [`SPLIT`]'s rows this is [`Region::whole`]. Callers pass the player's own
|
||||||
|
/// 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` the tile `(x, y)` is in, by the doors alone.
|
/// The piece of `map` that `from` opens onto, or `None` when no piece of it touches `from`.
|
||||||
///
|
///
|
||||||
/// For every map but [`SPLIT`]'s rows this is [`Region::whole`]. On a split map it is the piece
|
/// This is the reverse of [`region_at`] and it needs no tile: a door or an edge is listed under
|
||||||
/// with the nearest door, counting tiles across and down, the first piece on a tie. That is exact
|
/// exactly one piece, so "which half of Route 2 does the north gate open onto" is a table lookup.
|
||||||
/// for every tile of Route 2's and Route 4's ground, which is where the grid cannot answer
|
/// `None` is an edge the graph does not have -- the south half of Route 2 is not reachable from
|
||||||
/// ([`region_on`]: a ledge is a one-way step the grid does not model), and it is only the fallback
|
/// Pewter City, whatever the map ids alone would suggest.
|
||||||
/// on Mt. Moon's floors, whose chambers wrap round each other.
|
fn region_toward(map: u8, from: u8) -> Option<Region> {
|
||||||
pub fn region_at(map: u8, x: u8, y: u8) -> Region {
|
match split_of(map) {
|
||||||
let Some(split) = split_of(map) else { return Region::whole(map) };
|
None => Some(Region::whole(map)),
|
||||||
let distance = |piece: &Piece| {
|
Some(split) => {
|
||||||
piece
|
if split.north.contains(&from) {
|
||||||
.doors
|
Some(Region { map, part: NORTH_PIECE })
|
||||||
.iter()
|
} else if split.south.contains(&from) {
|
||||||
.map(|(_, dx, dy)| u16::from(x.abs_diff(*dx)) + u16::from(y.abs_diff(*dy)))
|
Some(Region { map, part: SOUTH_PIECE })
|
||||||
.min()
|
} else {
|
||||||
.unwrap_or(u16::MAX)
|
None
|
||||||
};
|
|
||||||
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> {
|
||||||
if let Some(split) = split_of(region.map) {
|
let of = |map: u8| region_toward(map, region.map);
|
||||||
return split.pieces.get(usize::from(region.part)).map_or_else(Vec::new, |piece| piece.next.to_vec());
|
match split_of(region.map) {
|
||||||
}
|
None => neighbours(region.map).into_iter().filter_map(of).collect(),
|
||||||
// A whole map steps onto every piece of a split neighbour that lists it back: Mt. Moon's
|
Some(split) => {
|
||||||
// first floor has a ladder into three of B1F's four chambers.
|
let own = if region.part == NORTH_PIECE { split.north } else { split.south };
|
||||||
let mut out = Vec::new();
|
own.iter().copied().filter_map(of).collect()
|
||||||
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.
|
||||||
|
|
@ -475,20 +280,11 @@ 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 on foot from `map`, doors and edges together, deduplicated and in id order.
|
/// Every map one step 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(
|
out.extend(row.1.iter().copied().filter(|id| *id != NONE));
|
||||||
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 {
|
||||||
|
|
@ -500,7 +296,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.iter().zip(EDGES).any(|(other, edge)| *other == map && crossable(*id, edge)) {
|
if row.contains(&map) {
|
||||||
out.push(*id);
|
out.push(*id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -517,23 +313,16 @@ 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, Region> = HashMap::new();
|
let mut first: HashMap<Region, u8> = 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);
|
first.insert(hop, hop.map);
|
||||||
queue.push_back(hop);
|
queue.push_back(hop);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -706,62 +495,6 @@ 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));
|
||||||
|
|
@ -787,7 +520,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));
|
||||||
// Out along Route 3, whose only other end is the road up to Mt. Moon.
|
// Through the cave, because Route 3 and Route 4 are the same two maps either way round.
|
||||||
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);
|
||||||
|
|
@ -795,169 +528,37 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_split_maps_pieces_add_up_and_answer_each_other() {
|
fn a_split_maps_pieces_divide_its_neighbours_between_them() {
|
||||||
// The invariants that keep [`SPLIT`] honest. A typo in any of them is a road that does
|
// The invariant that keeps [`SPLIT`] honest: a piece's own list is a real subset of the
|
||||||
// not exist, or a door that leads nowhere.
|
// map's neighbours, the two pieces together are all of them, and neither claims the same
|
||||||
|
// neighbour twice. A typo here is a road that does not exist.
|
||||||
for split in SPLIT {
|
for split in SPLIT {
|
||||||
// The pieces' neighbours together are exactly the map's.
|
let mut both: Vec<u8> =
|
||||||
let mut maps_of: Vec<u8> =
|
split.north.iter().chain(split.south.iter()).copied().collect();
|
||||||
split.pieces.iter().flat_map(|piece| piece.next.iter().map(|r| r.map)).collect();
|
both.sort_unstable();
|
||||||
maps_of.sort_unstable();
|
let mut once = both.clone();
|
||||||
maps_of.dedup();
|
once.dedup();
|
||||||
|
assert_eq!(both, once, "{:#04x} lists a neighbour under both pieces", split.map);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
maps_of,
|
both,
|
||||||
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
|
||||||
);
|
);
|
||||||
let mut doors: Vec<u8> =
|
assert_ne!(split.north_door, split.south_door);
|
||||||
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, 3, 11).part, NORTH_PIECE);
|
assert_eq!(region_at(maps::ROUTE_2, 11).part, NORTH_PIECE);
|
||||||
assert_eq!(region_at(maps::ROUTE_2, 3, 43).part, SOUTH_PIECE);
|
assert_eq!(region_at(maps::ROUTE_2, 43).part, SOUTH_PIECE);
|
||||||
assert_eq!(region_at(maps::ROUTE_2, 8, 0).part, NORTH_PIECE, "Pewter's end");
|
assert_eq!(region_at(maps::ROUTE_2, 0).part, NORTH_PIECE, "Pewter's end");
|
||||||
assert_eq!(region_at(maps::ROUTE_2, 8, 71).part, SOUTH_PIECE, "Viridian's end");
|
assert_eq!(region_at(maps::ROUTE_2, 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, 10, 30), Region::whole(maps::ROUTE_1));
|
assert_eq!(region_at(maps::ROUTE_1, 30), Region::whole(maps::ROUTE_1));
|
||||||
assert_eq!(region_at(maps::VIRIDIAN_FOREST_SOUTH_GATE, 4, 7).part, 0);
|
assert_eq!(region_at(maps::VIRIDIAN_FOREST_SOUTH_GATE, 7).part, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -977,16 +578,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, 3, 11), maps::PEWTER_CITY), Some(maps::PEWTER_CITY));
|
assert_eq!(next_hop(region_at(maps::ROUTE_2, 11), maps::PEWTER_CITY), Some(maps::PEWTER_CITY));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
next_hop(region_at(maps::ROUTE_2, 3, 43), maps::PEWTER_CITY),
|
next_hop(region_at(maps::ROUTE_2, 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, 3, 11), maps::VIRIDIAN_CITY),
|
next_hop(region_at(maps::ROUTE_2, 11), maps::VIRIDIAN_CITY),
|
||||||
Some(maps::VIRIDIAN_FOREST_NORTH_GATE)
|
Some(maps::VIRIDIAN_FOREST_NORTH_GATE)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1044,11 +645,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, 3, 11), maps::PEWTER_CITY), Some(1));
|
assert_eq!(hops(region_at(maps::ROUTE_2, 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, 3, 43), maps::PEWTER_CITY), Some(5));
|
assert_eq!(hops(region_at(maps::ROUTE_2, 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,47 +1422,14 @@ 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(from) = region_here(state) else { return Vec::new() };
|
let Some(player) = state.player() else { return Vec::new() };
|
||||||
let here = from.map;
|
let here = player.map;
|
||||||
if here == objective.map {
|
if here == objective.map {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
match geography::next_step(from, objective.map) {
|
let hop = geography::next_hop(geography::region_at(here, player.y), objective.map);
|
||||||
Some(hop) => {
|
let aim = hop.unwrap_or(objective.map);
|
||||||
candidates.iter().copied().filter(|exit| leads_to(state, exit, here, hop)).collect()
|
candidates.iter().copied().filter(|exit| exit.destination(here) == Some(aim)).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.
|
||||||
|
|
@ -1801,10 +1768,9 @@ pub fn goals_toward(state: &mut dyn MacroState, target: u8) -> Vec<Aim> {
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
let hop = region_here(state).and_then(|from| geography::next_step(from, target));
|
if let Some(hop) = geography::next_hop(geography::region_at(here, player.y), target) {
|
||||||
if let Some(hop) = hop {
|
|
||||||
let toward: Vec<Exit> =
|
let toward: Vec<Exit> =
|
||||||
exits.iter().copied().filter(|exit| leads_to(state, exit, here, hop)).collect();
|
exits.iter().copied().filter(|exit| exit.destination(here) == Some(hop)).collect();
|
||||||
if !toward.is_empty() {
|
if !toward.is_empty() {
|
||||||
return of(toward);
|
return of(toward);
|
||||||
}
|
}
|
||||||
|
|
@ -1927,8 +1893,7 @@ pub const fn move_index(kind: MacroKind) -> Option<u8> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `kind`'s move slot holds a move with PP that the battle engine will not answer with
|
/// Whether `kind`'s move slot holds a move with PP: the four buttons' precondition (section 14).
|
||||||
/// nothing: the four buttons' precondition (section 14, row 60).
|
|
||||||
///
|
///
|
||||||
/// Three things it is *not*, each of them a bug this palette has had:
|
/// Three things it is *not*, each of them a bug this palette has had:
|
||||||
///
|
///
|
||||||
|
|
@ -1956,41 +1921,23 @@ pub fn move_slot_bound(state: &mut dyn MacroState, kind: MacroKind) -> bool {
|
||||||
// all, so the button is bound there whatever the seam can make of the battler. That is the
|
// all, so the button is bound there whatever the seam can make of the battler. That is the
|
||||||
// backstop `NEXT` used to be on this row (section 12.10): the own turn's main menu always has
|
// backstop `NEXT` used to be on this row (section 12.10): the own turn's main menu always has
|
||||||
// a button that ends the turn, and it is never one that merely reopens a list.
|
// a button that ends the turn, and it is never one that merely reopens a list.
|
||||||
let main = matches!(battle.menu, BattleMenu::Main { .. });
|
if index == 0 && matches!(battle.menu, BattleMenu::Main { .. }) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
// And the same backstop over an **open move list** whose battler the seam cannot read
|
// And the same backstop over an **open move list** whose battler the seam cannot read
|
||||||
// (section 12.11). That frame used to deal `BACK` alone -- the only button on it closed the
|
// (section 12.11). That frame used to deal `BACK` alone -- the only button on it closed the
|
||||||
// list `MOVE 1` on the menu underneath had just opened, which is 12.10's pair with `MOVE 1` in
|
// list `MOVE 1` on the menu underneath had just opened, which is 12.10's pair with `MOVE 1` in
|
||||||
// `NEXT`'s place. `MOVE 1`'s script over an open list confirms wherever the cursor stands, so
|
// `NEXT`'s place. `MOVE 1`'s script over an open list confirms wherever the cursor stands, so
|
||||||
// it reads no move either, and confirming a move is what ends a turn.
|
// it reads no move either, and confirming a move is what ends a turn.
|
||||||
let Some(own) = battle.own else {
|
let Some(own) = battle.own else {
|
||||||
return index == 0
|
return index == 0 && matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. });
|
||||||
&& (main || matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. }));
|
|
||||||
};
|
};
|
||||||
let holds = |slot: usize| -> Option<&Move> {
|
let holds = |slot: usize| -> Option<&Move> {
|
||||||
own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0)
|
own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0)
|
||||||
};
|
};
|
||||||
// **A move the cartridge will answer with nothing is not dealt beside one it will not**
|
let Some(entry) = holds(usize::from(index)) else { return false };
|
||||||
// (row 60, section 12.23). Live on Route 1: Squirtle's TAIL WHIP against a Pidgey whose
|
|
||||||
// DEFENSE was already at -6 was `MOVE 2` 183 times, "Nothing happened!" every time, and no
|
|
||||||
// battle ended by the fly's hand. What the move does is the move table's and the effect
|
|
||||||
// routine's answer ([`MacroState::move_without_effect`]), read the same way for every move,
|
|
||||||
// and it is PP's rule over again: a spent move is not offered beside a usable one, and when
|
|
||||||
// nothing is usable what was dealt stays dealt -- taking the last moves away would leave a list
|
|
||||||
// whose only button is `BACK`, which is 12.11's pair.
|
|
||||||
let mut useful = [false; 4];
|
|
||||||
for (slot, flag) in useful.iter_mut().enumerate() {
|
|
||||||
if let Some(entry) = holds(slot).copied() {
|
|
||||||
*flag = entry.pp > 0 && !state.move_without_effect(entry.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let any_useful = useful.iter().any(|flag| *flag);
|
|
||||||
let entry = holds(usize::from(index)).copied();
|
|
||||||
if index == 0 && main {
|
|
||||||
return !any_useful || useful[0] || entry.is_none_or(|entry| entry.pp == 0);
|
|
||||||
}
|
|
||||||
let Some(entry) = entry else { return false };
|
|
||||||
if entry.pp > 0 {
|
if entry.pp > 0 {
|
||||||
return useful[usize::from(index)] || !any_useful;
|
return true;
|
||||||
}
|
}
|
||||||
// Out of PP. Only `MOVE 1` stays, and only when nothing else has any either -- otherwise the
|
// Out of PP. Only `MOVE 1` stays, and only when nothing else has any either -- otherwise the
|
||||||
// fly would be offered a spent move beside a usable one.
|
// fly would be offered a spent move beside a usable one.
|
||||||
|
|
|
||||||
|
|
@ -430,9 +430,7 @@ 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,
|
||||||
};
|
};
|
||||||
// A connection the headers name and no step on foot crosses -- Pallet Town's shore --
|
if !connected {
|
||||||
// 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,22 +536,18 @@ 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 {
|
||||||
self.reachable(x, y).iter().filter(|seen| **seen).count()
|
if self.index(x, y).is_none() {
|
||||||
|
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;
|
||||||
|
|
@ -567,30 +563,7 @@ impl MapGrid {
|
||||||
queue.push_back((nx, ny));
|
queue.push_back((nx, ny));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reachable { width: self.width, seen }
|
count
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,6 @@ struct World {
|
||||||
money: u32,
|
money: u32,
|
||||||
bag: Vec<(u8, u8)>,
|
bag: Vec<(u8, u8)>,
|
||||||
stock: Vec<u8>,
|
stock: Vec<u8>,
|
||||||
/// Move ids the battle engine would answer with "Nothing happened!" on this frame (row 60).
|
|
||||||
no_effect: BTreeSet<u8>,
|
|
||||||
/// Tiles the game lets the player talk *over*: a mart's or a centre's counter.
|
/// Tiles the game lets the player talk *over*: a mart's or a centre's counter.
|
||||||
counters: BTreeSet<Tile>,
|
counters: BTreeSet<Tile>,
|
||||||
/// Errands this run has discharged (`docs/design/macros.md` section 13).
|
/// Errands this run has discharged (`docs/design/macros.md` section 13).
|
||||||
|
|
@ -244,7 +242,6 @@ impl World {
|
||||||
pushes: BTreeSet::new(),
|
pushes: BTreeSet::new(),
|
||||||
exhausted: BTreeSet::new(),
|
exhausted: BTreeSet::new(),
|
||||||
stock: Vec::new(),
|
stock: Vec::new(),
|
||||||
no_effect: BTreeSet::new(),
|
|
||||||
visited: BTreeSet::new(),
|
visited: BTreeSet::new(),
|
||||||
stood: BTreeSet::new(),
|
stood: BTreeSet::new(),
|
||||||
seen_maps: BTreeSet::new(),
|
seen_maps: BTreeSet::new(),
|
||||||
|
|
@ -699,10 +696,6 @@ impl MacroState for World {
|
||||||
self.prompt && self.scene == Scene::Dialog
|
self.prompt && self.scene == Scene::Dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_without_effect(&mut self, id: u8) -> bool {
|
|
||||||
self.no_effect.contains(&id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shop_stock(&mut self) -> Vec<u8> {
|
fn shop_stock(&mut self) -> Vec<u8> {
|
||||||
self.stock.clone()
|
self.stock.clone()
|
||||||
}
|
}
|
||||||
|
|
@ -854,16 +847,14 @@ fn settle(machine: &mut MacroMachine, world: &mut World) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The cartridge gives the joypad back in the overworld: the settle window of it, observed, and
|
/// The cartridge gives the joypad back in the overworld: one frame of it, observed, and whatever
|
||||||
/// whatever it decided taken into the ledgers (rows 58 and 59).
|
/// it decided taken into the ledgers (row 58).
|
||||||
fn hand_back(machine: &mut MacroMachine, world: &mut World) {
|
fn hand_back(machine: &mut MacroMachine, world: &mut World) {
|
||||||
world.scene = Scene::Overworld;
|
world.scene = Scene::Overworld;
|
||||||
world.scripted = false;
|
world.scripted = false;
|
||||||
world.scripted_at = None;
|
world.scripted_at = None;
|
||||||
world.switch = None;
|
world.switch = None;
|
||||||
for _ in 0..super::executor::PUSH_SETTLE_FRAMES {
|
|
||||||
machine.observe_frame(world);
|
machine.observe_frame(world);
|
||||||
}
|
|
||||||
settle(machine, world);
|
settle(machine, world);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3948,95 +3939,6 @@ fn a_turn_with_nothing_to_attack_switch_or_flee_with_still_has_a_button() {
|
||||||
assert!(pad_of(&mut world).contains(&"MOVE 1"));
|
assert!(pad_of(&mut world).contains(&"MOVE 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `constants/move_constants.asm`: the two moves a level-5 Squirtle knows.
|
|
||||||
const TACKLE: u8 = 0x21;
|
|
||||||
const TAIL_WHIP: u8 = 0x27;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_move_the_cartridge_answers_with_nothing_is_off_the_pad_beside_one_it_does_not() {
|
|
||||||
// Row 60, live on Route 1: Squirtle L5 with TACKLE and TAIL WHIP, a Pidgey whose DEFENSE is
|
|
||||||
// already at -6, and `MOVE 2` chosen 183 times to "Nothing happened!". Over the menu and over
|
|
||||||
// the open list, TAIL WHIP leaves the pad and TACKLE stays.
|
|
||||||
let mut world = World::battle();
|
|
||||||
world.mons = vec![mon(0, 8, 20, &[(TACKLE, 35), (TAIL_WHIP, 30)])];
|
|
||||||
world.active = Some(0);
|
|
||||||
world.list = List::BattleMain;
|
|
||||||
assert!(on_the_pad(&mut world, MacroKind::Move2), "before the stage is at its limit");
|
|
||||||
|
|
||||||
world.no_effect.insert(TAIL_WHIP);
|
|
||||||
for list in [List::BattleMain, List::Moves(2)] {
|
|
||||||
world.list = list;
|
|
||||||
world.grid = list == List::BattleMain;
|
|
||||||
let pad = pad_of(&mut world);
|
|
||||||
assert!(!pad.contains(&"MOVE 2"), "{list:?} deals {pad:?}");
|
|
||||||
assert!(pad.contains(&"MOVE 1"), "{list:?} deals {pad:?}");
|
|
||||||
}
|
|
||||||
// Nothing presses for the fly: the button is gone, and nothing is chosen in its place.
|
|
||||||
world.list = List::BattleMain;
|
|
||||||
world.grid = true;
|
|
||||||
assert!(!move_slot_bound(&mut world, MacroKind::Move2));
|
|
||||||
|
|
||||||
// Slot one is read the same way: FIGHT's backstop over the menu is not a way to deal a move
|
|
||||||
// that does nothing beside one that does.
|
|
||||||
let mut swapped = World::battle();
|
|
||||||
swapped.mons = vec![mon(0, 8, 20, &[(TAIL_WHIP, 30), (TACKLE, 35)])];
|
|
||||||
swapped.active = Some(0);
|
|
||||||
swapped.list = List::BattleMain;
|
|
||||||
swapped.no_effect.insert(TAIL_WHIP);
|
|
||||||
assert_eq!(
|
|
||||||
pad_of(&mut swapped).iter().filter(|name| name.starts_with("MOVE")).collect::<Vec<_>>(),
|
|
||||||
[&"MOVE 2"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn with_no_move_that_does_anything_the_moves_stay_as_pp_deals_them() {
|
|
||||||
// PP's own rule (section 12.8, row 30a): with nothing usable, what ends the turn stays on the
|
|
||||||
// pad. Taking every move away over an open list would leave `BACK` alone, which closes what
|
|
||||||
// `MOVE 1` on the menu underneath opened -- 12.11's pair.
|
|
||||||
let mut world = World::battle();
|
|
||||||
world.mons = vec![mon(0, 8, 20, &[(TACKLE, 0), (TAIL_WHIP, 30)])];
|
|
||||||
world.active = Some(0);
|
|
||||||
world.no_effect.insert(TAIL_WHIP);
|
|
||||||
world.list = List::Moves(2);
|
|
||||||
world.grid = false;
|
|
||||||
world.cursor_max = 1;
|
|
||||||
let pad = pad_of(&mut world);
|
|
||||||
assert!(pad.contains(&"MOVE 2"), "the one move with PP still ends the turn: {pad:?}");
|
|
||||||
assert_ne!(pad, ["BACK"]);
|
|
||||||
|
|
||||||
world.list = List::BattleMain;
|
|
||||||
world.grid = true;
|
|
||||||
let pad = pad_of(&mut world);
|
|
||||||
assert!(pad.contains(&"MOVE 1") && pad.contains(&"MOVE 2"), "{pad:?}");
|
|
||||||
|
|
||||||
// And both moves without effect: nothing changes from what PP alone deals.
|
|
||||||
let mut both = World::battle();
|
|
||||||
both.mons = vec![mon(0, 8, 20, &[(TACKLE, 35), (TAIL_WHIP, 30)])];
|
|
||||||
both.active = Some(0);
|
|
||||||
both.list = List::BattleMain;
|
|
||||||
let before = pad_of(&mut both);
|
|
||||||
both.no_effect.extend([TACKLE, TAIL_WHIP]);
|
|
||||||
assert_eq!(pad_of(&mut both), before);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_spent_move_and_a_move_without_effect_leave_the_one_that_works() {
|
|
||||||
// The two readings together: slot one spent, slot two refused, slot three usable. Only
|
|
||||||
// `MOVE 3` is a move; `MOVE 1` over the menu is FIGHT's backstop only while slot one is the
|
|
||||||
// thing that can end the turn, and it is spent -- row 34's behaviour, unchanged.
|
|
||||||
let mut world = World::battle();
|
|
||||||
world.mons = vec![mon(0, 8, 20, &[(TACKLE, 0), (TAIL_WHIP, 30), (0x2d, 40)])];
|
|
||||||
world.active = Some(0);
|
|
||||||
world.no_effect.insert(TAIL_WHIP);
|
|
||||||
world.list = List::Moves(3);
|
|
||||||
world.grid = false;
|
|
||||||
world.cursor_max = 2;
|
|
||||||
let moves: Vec<&str> =
|
|
||||||
pad_of(&mut world).into_iter().filter(|name| name.starts_with("MOVE")).collect();
|
|
||||||
assert_eq!(moves, ["MOVE 3"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The bound buttons of the macros-mode pad for the scene the world is in, unbound slots dropped.
|
/// The bound buttons of the macros-mode pad for the scene the world is in, unbound slots dropped.
|
||||||
fn pad_of(world: &mut World) -> Vec<&'static str> {
|
fn pad_of(world: &mut World) -> Vec<&'static str> {
|
||||||
let scene = world.scene();
|
let scene = world.scene();
|
||||||
|
|
@ -4162,20 +4064,18 @@ 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 reported Route
|
// The rung-11 reading of row 54 (`infra/docs/macros-traps.md`). The cartridge reports Route
|
||||||
// 3's connections as north and west while `geography`'s row carried west and east, so the
|
// 3's connections as north and west; `geography`'s row carries west and east, so the north
|
||||||
// north edge's destination was unnameable -- and an unnameable destination counted as
|
// edge's destination is unnameable -- and an unnameable destination counted as *unvisited*,
|
||||||
// *unvisited*, which made those tiles first-tier for `GO ROUTE` on every hold for ever. Row 59
|
// which made those tiles first-tier for `GO ROUTE` on every hold for ever, with
|
||||||
// corrected the row itself, and since then every header connection has a row; the rule is
|
// `GO OBJECTIVE` off the pad beside them because nothing on this map leads to the objective.
|
||||||
// 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 = 0x0b;
|
world.map = maps::ROUTE_3;
|
||||||
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 the run has already stood on.
|
// West is Pewter City, which the table does name and the run has stood on.
|
||||||
world.visited.insert(ExitId::Edge(Edge::West));
|
world.seen_maps.insert(maps::PEWTER_CITY);
|
||||||
|
|
||||||
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!(
|
||||||
|
|
@ -5431,37 +5331,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! The Pokémon Red reward adapter, `pokered-unique8-v7`.
|
//! The Pokémon Red reward adapter, `pokered-unique8-v6`.
|
||||||
//!
|
//!
|
||||||
//! A port of the prototype's `src/reward/pokemon-red.ts`. The gates and budgets
|
//! A port of the prototype's `src/reward/pokemon-red.ts`. The gates and budgets
|
||||||
//! are unchanged; `docs/rewards-learning.md` holds the live rule table and the
|
//! are unchanged; `docs/rewards-learning.md` holds the live rule table and the
|
||||||
|
|
@ -6,12 +6,9 @@
|
||||||
//! ladder with the 38 rungs of `docs/design/ladder.md`; v5 adds one reward rule,
|
//! ladder with the 38 rungs of `docs/design/ladder.md`; v5 adds one reward rule,
|
||||||
//! `boundary` (`docs/design/room-escape.md` section 2), which pays the first step
|
//! `boundary` (`docs/design/room-escape.md` section 2), which pays the first step
|
||||||
//! next to and the first step onto each of a map's exits; v6 adds `catch`, the
|
//! next to and the first step onto each of a map's exits; v6 adds `catch`, the
|
||||||
//! operator's decision of 2026-09-22, which pays for keeping a wild Pokémon; v7 adds
|
//! operator's decision of 2026-09-22, which pays for keeping a wild Pokémon.
|
||||||
//! `talk` and `item` and stops `boundary` paying indoors, the operator's decision of
|
|
||||||
//! 2026-09-23 to pay for engaging with a building rather than for leaving it.
|
|
||||||
|
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod engage;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) mod fake_wram;
|
pub(crate) mod fake_wram;
|
||||||
pub mod macros;
|
pub mod macros;
|
||||||
|
|
@ -36,39 +33,33 @@ use symbols::ram;
|
||||||
|
|
||||||
/// Adapter version, pinned into the checkpoint compatibility string.
|
/// Adapter version, pinned into the checkpoint compatibility string.
|
||||||
///
|
///
|
||||||
/// `v7` is the engagement rules: `talk` and `item` pay, and `boundary` stops paying on an
|
/// `v6` is the `catch` rule. Bumping it is what makes a `v5` checkpoint a decision
|
||||||
/// indoor map (the operator, 2026-09-23). Bumping it is what makes a `v6` checkpoint a
|
/// rather than an accident: the compatibility string is compared whole before a
|
||||||
/// decision rather than an accident: the compatibility string is compared whole before a
|
/// restore is attempted, so a `v5` run is refused by default and resumed only when
|
||||||
/// restore is attempted, so a `v6` run is refused by default and resumed only when the
|
/// the operator names it in `FLY_ACCEPT_ADAPTERS`
|
||||||
/// operator names it in `FLY_ACCEPT_ADAPTERS` ([`crate::compatibility::RestoreDecision`],
|
/// ([`crate::compatibility::RestoreDecision`], `docs/design/flysim.md`). That
|
||||||
/// `docs/design/flysim.md`). That migration is safe in one direction only, and only for this
|
/// migration is safe in one direction only, and only for this pair: `v5`'s ledger is
|
||||||
/// pair: `v6`'s ledger is a `v7` ledger holding no `talk:`, `item:` or `hidden:` keys, and the
|
/// a `v6` ledger with the catch counter absent, and an absent counter reads as zero.
|
||||||
/// first sample after the restore seeds the item keys from the cartridge's own bits, so
|
|
||||||
/// nothing already picked up pays ([`engage::ItemFlags::seed`]).
|
|
||||||
///
|
///
|
||||||
/// (`v6` was the `catch` rule and migrated `v5` the same way: an absent counter reads as
|
/// (`v5` was the `boundary` rule, and rejected `v4` because a ledger that had never
|
||||||
/// zero. `v5` was the `boundary` rule, and rejected `v4` because a ledger that had never
|
|
||||||
/// recorded a `boundary:` key could not be resumed as though its exits were already
|
/// recorded a `boundary:` key could not be resumed as though its exits were already
|
||||||
/// collected. `v4` was the 38-rung ladder, and rejected `v3` because a stored rank
|
/// collected. `v4` was the 38-rung ladder, and rejected `v3` because a stored rank
|
||||||
/// that meant "4 badges" on the old ladder is not a rung on the new one. Neither of
|
/// that meant "4 badges" on the old ladder is not a rung on the new one. Neither of
|
||||||
/// those is a migration; the last two are, because nothing an older ledger holds means
|
/// those is a migration: this one is, because nothing a `v5` ledger holds means
|
||||||
/// something different under the newer rules.)
|
/// something different under `v6`.)
|
||||||
pub const REWARD_ADAPTER: &str = "pokered-unique8-v7";
|
pub const REWARD_ADAPTER: &str = "pokered-unique8-v6";
|
||||||
|
|
||||||
/// Adapter ids whose checkpoints `v7` can read.
|
/// Adapter ids whose checkpoints `v6` can read.
|
||||||
///
|
///
|
||||||
/// Exactly one, and it is one because the engagement rules add ledger keys and change nothing
|
/// Exactly one, and it is one because the `catch` rule adds a counter and changes nothing else:
|
||||||
/// else a `v6` state holds: every field keeps its name, shape and meaning, the `talk:` keys start
|
/// a `v5` ledger restores as a `v6` ledger with `catchCounts` empty, and every other byte of the
|
||||||
/// empty (no conversation was ever paid), and the `item:`/`hidden:` keys are seeded from the
|
/// state means what it meant. `v4` is not here -- its `seen` ledger holds no `boundary:` keys, so
|
||||||
/// game's own flags on the first sample, so no pickup made under `v6` pays when a rollback
|
/// resuming it would pay a second time for every exit the run had already found -- and neither is
|
||||||
/// un-takes it. The `boundary:` keys a `v6` run earned indoors stay in the ledger and mean what
|
/// `v3`, whose stored rank is a rung on a different ladder.
|
||||||
/// they meant -- `exit_visited` still reads them. `v5` is not here: the live run is `v6`, and
|
|
||||||
/// the one migration the operator asked for is the one this adapter tests. `v4` and `v3` stay
|
|
||||||
/// refused for the reasons [`REWARD_ADAPTER`] gives.
|
|
||||||
///
|
///
|
||||||
/// Listing an id here is necessary but not sufficient: `FLY_ACCEPT_ADAPTERS` must name it too
|
/// Listing an id here is necessary but not sufficient: `FLY_ACCEPT_ADAPTERS` must name it too
|
||||||
/// (`crate::compatibility::decide`, `docs/design/flysim.md`).
|
/// (`crate::compatibility::decide`, `docs/design/flysim.md`).
|
||||||
pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v6"];
|
pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v5"];
|
||||||
|
|
||||||
/// The only cartridge semantic rewards are enabled for. Even the canonical
|
/// The only cartridge semantic rewards are enabled for. Even the canonical
|
||||||
/// pret build stays disabled until reviewed; see `docs/rewards-learning.md`.
|
/// pret build stays disabled until reviewed; see `docs/rewards-learning.md`.
|
||||||
|
|
@ -98,19 +89,8 @@ pub const SUPPORTED_ROM: &str =
|
||||||
/// empty, which is the truth about a run that was never paid for a catch. That is the
|
/// empty, which is the truth about a run that was never paid for a catch. That is the
|
||||||
/// whole of the documented `v5` -> `v6` migration; see
|
/// whole of the documented `v5` -> `v6` migration; see
|
||||||
/// [`crate::compatibility::RestoreDecision`].
|
/// [`crate::compatibility::RestoreDecision`].
|
||||||
///
|
|
||||||
/// *Not* bumped for the engagement rules either. `talk` and `item` key their payouts into the
|
|
||||||
/// existing `seen` array, the way `boundary` did, and the item seed is marked there too
|
|
||||||
/// ([`ITEMS_SEEDED`]); a `v6` state is structurally a `v7` state with none of those keys.
|
|
||||||
pub const STATE_VERSION: u64 = 4;
|
pub const STATE_VERSION: u64 = 4;
|
||||||
|
|
||||||
/// The `seen` key that says the item keys have been seeded from the cartridge's flags.
|
|
||||||
///
|
|
||||||
/// Absent from every `v6` state and from a fresh adapter; the first playable sample that finds
|
|
||||||
/// it absent writes one `item:`/`hidden:` key per item the game already shows as taken, pays
|
|
||||||
/// nothing for any of them, and writes this.
|
|
||||||
const ITEMS_SEEDED: &str = "items:seeded";
|
|
||||||
|
|
||||||
/// Catch payouts one species may earn in the lifetime of a run's ledger.
|
/// Catch payouts one species may earn in the lifetime of a run's ledger.
|
||||||
///
|
///
|
||||||
/// The same cap and the same reason as the wild-KO rule's three: a species the fly can
|
/// The same cap and the same reason as the wild-KO rule's three: a species the fly can
|
||||||
|
|
@ -461,13 +441,6 @@ pub struct PokemonRedReward {
|
||||||
battle: Option<Battle>,
|
battle: Option<Battle>,
|
||||||
mode: String,
|
mode: String,
|
||||||
|
|
||||||
/// The `talk` rule's frame-to-frame watch. Transient: never checkpointed, cleared by a
|
|
||||||
/// rollback and by a restore.
|
|
||||||
talk: engage::TalkWatch,
|
|
||||||
/// The item bitsets as of the last playable sample, so a pickup is a bit that *rose*.
|
|
||||||
/// Transient for the same reason.
|
|
||||||
item_flags: Option<engage::ItemFlags>,
|
|
||||||
|
|
||||||
/// Transient, recomputed every sample and never checkpointed.
|
/// Transient, recomputed every sample and never checkpointed.
|
||||||
safe: bool,
|
safe: bool,
|
||||||
progress: u32,
|
progress: u32,
|
||||||
|
|
@ -518,8 +491,6 @@ impl PokemonRedReward {
|
||||||
stable: 0,
|
stable: 0,
|
||||||
battle: None,
|
battle: None,
|
||||||
mode: "BOOT".to_string(),
|
mode: "BOOT".to_string(),
|
||||||
talk: engage::TalkWatch::default(),
|
|
||||||
item_flags: None,
|
|
||||||
safe: false,
|
safe: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
badges: 0,
|
badges: 0,
|
||||||
|
|
@ -664,10 +635,6 @@ impl PokemonRedReward {
|
||||||
self.stable = 0;
|
self.stable = 0;
|
||||||
self.battle = None;
|
self.battle = None;
|
||||||
self.safe = false;
|
self.safe = false;
|
||||||
// A conversation or a pickup in flight across a rollback is not paid: the game the
|
|
||||||
// fly returns to has not had it. What *was* paid stays in `seen` and blocks a replay.
|
|
||||||
self.talk.clear();
|
|
||||||
self.item_flags = None;
|
|
||||||
let keys: Vec<String> = self.wild_wins.keys().cloned().collect();
|
let keys: Vec<String> = self.wild_wins.keys().cloned().collect();
|
||||||
for key in keys {
|
for key in keys {
|
||||||
self.replay_blocked.insert(&key);
|
self.replay_blocked.insert(&key);
|
||||||
|
|
@ -783,13 +750,11 @@ impl PokemonRedReward {
|
||||||
self.boundary(&mut emitted, memory, map, x, y, width, height, true, brain_ms);
|
self.boundary(&mut emitted, memory, map, x, y, width, height, true, brain_ms);
|
||||||
self.initialized = true;
|
self.initialized = true;
|
||||||
}
|
}
|
||||||
self.items(&mut emitted, memory, brain_ms);
|
|
||||||
|
|
||||||
let in_battle = memory.read8(ram::wIsInBattle);
|
let in_battle = memory.read8(ram::wIsInBattle);
|
||||||
if in_battle == 1 || in_battle == 2 || in_battle == 255 {
|
if in_battle == 1 || in_battle == 2 || in_battle == 255 {
|
||||||
self.mode = "BATTLE".to_string();
|
self.mode = "BATTLE".to_string();
|
||||||
self.stable = 0;
|
self.stable = 0;
|
||||||
self.talk.interrupt();
|
|
||||||
let species_paid = self.counts.get(kind::SPECIES);
|
let species_paid = self.counts.get(kind::SPECIES);
|
||||||
if self.battle.is_none() && in_battle != 255 {
|
if self.battle.is_none() && in_battle != 255 {
|
||||||
self.battle = Some(Battle {
|
self.battle = Some(Battle {
|
||||||
|
|
@ -884,18 +849,6 @@ impl PokemonRedReward {
|
||||||
self.catch_counts.insert(key, (paid + 1).min(MAX_CATCH_PAYOUTS));
|
self.catch_counts.insert(key, (paid + 1).min(MAX_CATCH_PAYOUTS));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The talk rule (`docs/rewards-learning.md`, the operator 2026-09-23): a conversation
|
|
||||||
// the fly opened indoors, paid once per (map, object) when its box closes.
|
|
||||||
if let Some(conversation) = self.talk.observe(memory, map, x, y) {
|
|
||||||
self.once(
|
|
||||||
&mut emitted,
|
|
||||||
&conversation.key(),
|
|
||||||
kind::TALK,
|
|
||||||
conversation.label(),
|
|
||||||
false,
|
|
||||||
brain_ms,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let location = format!("{map}:{x}:{y}");
|
let location = format!("{map}:{x}:{y}");
|
||||||
self.stable = if self.location == location { self.stable + 1 } else { 1 };
|
self.stable = if self.location == location { self.stable + 1 } else { 1 };
|
||||||
self.location = location.clone();
|
self.location = location.clone();
|
||||||
|
|
@ -1103,11 +1056,6 @@ impl PokemonRedReward {
|
||||||
/// What this is not: a path. No button is chosen here, nothing is planned, and no map
|
/// What this is not: a path. No button is chosen here, nothing is planned, and no map
|
||||||
/// knowledge reaches the readout. It is a reward the fly may or may not find, like every other
|
/// knowledge reaches the readout. It is a reward the fly may or may not find, like every other
|
||||||
/// rule in the catalog.
|
/// rule in the catalog.
|
||||||
///
|
|
||||||
/// **Indoors it pays nothing** (the operator, 2026-09-23): on a map [`engage::indoor`] calls a
|
|
||||||
/// building, every key is still written to the ledger -- so [`GameAdapter::exit_visited`]
|
|
||||||
/// answers exactly what it did, and a door found indoors is found -- but no payout is emitted.
|
|
||||||
/// The exits of a town, a route, a forest or a cave pay as they always have.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn boundary(
|
fn boundary(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
@ -1124,7 +1072,6 @@ impl PokemonRedReward {
|
||||||
// Collected first, paid second: the ledger writes need `&mut self` and the table walk
|
// Collected first, paid second: the ledger writes need `&mut self` and the table walk
|
||||||
// needs the sample cache, and the order of the collection is the order of the payouts.
|
// needs the sample cache, and the order of the collection is the order of the payouts.
|
||||||
let mut hits: Vec<(String, bool)> = Vec::new();
|
let mut hits: Vec<(String, bool)> = Vec::new();
|
||||||
let pays = !engage::indoor(memory.read8(ram::wCurMapTileset));
|
|
||||||
|
|
||||||
let warps = memory.read8(ram::wNumberOfWarps).min(MAX_WARP_EVENTS);
|
let warps = memory.read8(ram::wNumberOfWarps).min(MAX_WARP_EVENTS);
|
||||||
for index in 0..u16::from(warps) {
|
for index in 0..u16::from(warps) {
|
||||||
|
|
@ -1165,10 +1112,6 @@ impl PokemonRedReward {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let key = format!("{key}:{}", if on_exit { "on" } else { "near" });
|
let key = format!("{key}:{}", if on_exit { "on" } else { "near" });
|
||||||
if !pays {
|
|
||||||
self.seen.insert(&key);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
self.once_scaled(
|
self.once_scaled(
|
||||||
emitted,
|
emitted,
|
||||||
&key,
|
&key,
|
||||||
|
|
@ -1181,34 +1124,6 @@ impl PokemonRedReward {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pay each item picked up since the last playable sample, once per item for the lifetime of
|
|
||||||
/// the ledger (`docs/rewards-learning.md`, the operator 2026-09-23).
|
|
||||||
///
|
|
||||||
/// The first sample that finds [`ITEMS_SEEDED`] absent -- a fresh adapter, or a `v6` state
|
|
||||||
/// restored under `v7` -- keys every item the cartridge already shows as taken and pays for
|
|
||||||
/// none of them. After that a pickup is a bit that rose between two playable samples
|
|
||||||
/// ([`engage::pickups`]) and pays unless its key is already in `seen`, which is what stops a
|
|
||||||
/// rollback that un-takes an item from paying for it twice.
|
|
||||||
fn items(
|
|
||||||
&mut self,
|
|
||||||
emitted: &mut Vec<RewardEvent>,
|
|
||||||
memory: &mut impl MemoryReader,
|
|
||||||
brain_ms: f64,
|
|
||||||
) {
|
|
||||||
let now = engage::ItemFlags::read(memory);
|
|
||||||
if !self.seen.contains(ITEMS_SEEDED) {
|
|
||||||
for key in now.seed() {
|
|
||||||
self.seen.insert(&key);
|
|
||||||
}
|
|
||||||
self.seen.insert(ITEMS_SEEDED);
|
|
||||||
} else if let Some(before) = &self.item_flags {
|
|
||||||
for pickup in engage::pickups(memory, before, &now) {
|
|
||||||
self.once(emitted, &pickup.key, kind::ITEM, pickup.label, false, brain_ms);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.item_flags = Some(now);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn export_state(&self) -> Value {
|
pub fn export_state(&self) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"version": STATE_VERSION,
|
"version": STATE_VERSION,
|
||||||
|
|
@ -1372,8 +1287,6 @@ impl PokemonRedReward {
|
||||||
self.mode = mode.to_string();
|
self.mode = mode.to_string();
|
||||||
self.progress = progress;
|
self.progress = progress;
|
||||||
self.badges = badges;
|
self.badges = badges;
|
||||||
self.talk.clear();
|
|
||||||
self.item_flags = None;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -145,11 +145,6 @@ 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;
|
||||||
|
|
@ -237,54 +232,6 @@ pub mod poke {
|
||||||
/// this fixed point.
|
/// this fixed point.
|
||||||
pub const PLAYER_SCREEN_X: i32 = 8;
|
pub const PLAYER_SCREEN_X: i32 = 8;
|
||||||
pub const PLAYER_SCREEN_Y: i32 = 9;
|
pub const PLAYER_SCREEN_Y: i32 = 9;
|
||||||
|
|
||||||
/// The cartridge's move table and the battle engine's answers to it (row 60,
|
|
||||||
/// `docs/design/macros.md` 12.23).
|
|
||||||
pub mod moves {
|
|
||||||
/// `data/moves/moves.asm`: `Moves` opens `SECTION "Battle Engine 7"`, which
|
|
||||||
/// `layout.link` places first in ROM bank `$0E`, so the table starts at `$0E:$4000`.
|
|
||||||
/// Six bytes a row (`MOVE_LENGTH`): animation (the move id itself), effect, power,
|
|
||||||
/// type, accuracy, PP, rows in move-id order from `POUND` (1).
|
|
||||||
pub const TABLE_BANK: u8 = 0x0e;
|
|
||||||
pub const TABLE_ADDRESS: u16 = 0x4000;
|
|
||||||
pub const ROW_BYTES: u16 = 6;
|
|
||||||
/// `constants/move_constants.asm`: `NUM_ATTACKS`, `STRUGGLE` (`$a5`) the last.
|
|
||||||
pub const LAST_MOVE: u8 = 0xa5;
|
|
||||||
|
|
||||||
/// `constants/move_effect_constants.asm`: the stat-stage effects, each run in stage
|
|
||||||
/// order ATTACK, DEFENSE, SPEED, SPECIAL, ACCURACY, EVASION.
|
|
||||||
pub const ATTACK_UP1: u8 = 0x0a;
|
|
||||||
pub const EVASION_UP1: u8 = 0x0f;
|
|
||||||
pub const ATTACK_DOWN1: u8 = 0x12;
|
|
||||||
pub const EVASION_DOWN1: u8 = 0x17;
|
|
||||||
pub const SLEEP: u8 = 0x20;
|
|
||||||
pub const ATTACK_UP2: u8 = 0x32;
|
|
||||||
pub const EVASION_UP2: u8 = 0x37;
|
|
||||||
pub const ATTACK_DOWN2: u8 = 0x3a;
|
|
||||||
pub const EVASION_DOWN2: u8 = 0x3f;
|
|
||||||
pub const POISON: u8 = 0x42;
|
|
||||||
pub const PARALYZE: u8 = 0x43;
|
|
||||||
|
|
||||||
/// `constants/battle_constants.asm`: a stage byte is 1 (-6) to `MAX_STAT_LEVEL` 13 (+6),
|
|
||||||
/// 7 normal; `MAX_STAT_VALUE` 999. The first four stages have a stat behind them
|
|
||||||
/// (`wBattleMonAttack` onwards, big-endian words); accuracy and evasion do not.
|
|
||||||
pub const MIN_STAGE: u8 = 1;
|
|
||||||
pub const MAX_STAGE: u8 = 13;
|
|
||||||
pub const STATS_WITH_VALUES: u8 = 4;
|
|
||||||
pub const MIN_STAT: u16 = 1;
|
|
||||||
pub const MAX_STAT: u16 = 999;
|
|
||||||
|
|
||||||
/// `wEnemyBattleStatus2` bits: `PROTECTED_BY_MIST` 1, `HAS_SUBSTITUTE_UP` 4,
|
|
||||||
/// `NEEDS_TO_RECHARGE` 5.
|
|
||||||
pub const MIST: u8 = 1 << 1;
|
|
||||||
pub const SUBSTITUTE: u8 = 1 << 4;
|
|
||||||
pub const RECHARGE: u8 = 1 << 5;
|
|
||||||
|
|
||||||
/// `constants/type_constants.asm`.
|
|
||||||
pub const TYPE_POISON: u8 = 0x03;
|
|
||||||
pub const TYPE_GROUND: u8 = 0x04;
|
|
||||||
pub const TYPE_ELECTRIC: u8 = 0x17;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read(memory: &mut dyn MemoryReader, address: u16) -> u8 {
|
fn read(memory: &mut dyn MemoryReader, address: u16) -> u8 {
|
||||||
|
|
@ -678,114 +625,6 @@ fn enemy_mon(memory: &mut dyn MemoryReader) -> Option<EnemyMon> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the cartridge's move table (`data/moves/moves.asm`).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct MoveData {
|
|
||||||
pub id: u8,
|
|
||||||
pub effect: u8,
|
|
||||||
pub power: u8,
|
|
||||||
pub kind: u8,
|
|
||||||
pub accuracy: u8,
|
|
||||||
pub pp: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move `id`'s row of the move table, read from the cartridge image.
|
|
||||||
///
|
|
||||||
/// `None` when the seam has no cartridge behind it, when `id` is not a move, or when the row does
|
|
||||||
/// not open with its own id -- every row of `Moves` does (`move`'s first byte is the animation,
|
|
||||||
/// "interchangeable with move id"), so a table that is not where the disassembly puts it answers
|
|
||||||
/// nothing rather than a neighbour's effect.
|
|
||||||
pub fn move_data(memory: &mut dyn MemoryReader, id: u8) -> Option<MoveData> {
|
|
||||||
use poke::moves::{LAST_MOVE, ROW_BYTES, TABLE_ADDRESS, TABLE_BANK};
|
|
||||||
if id == 0 || id > LAST_MOVE {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let base = TABLE_ADDRESS + u16::from(id - 1) * ROW_BYTES;
|
|
||||||
let mut row = [0u8; 6];
|
|
||||||
for (offset, byte) in row.iter_mut().enumerate() {
|
|
||||||
*byte = memory.read_rom(TABLE_BANK, base + offset as u16)?;
|
|
||||||
}
|
|
||||||
if row[0] != id {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(MoveData {
|
|
||||||
id,
|
|
||||||
effect: row[1],
|
|
||||||
power: row[2],
|
|
||||||
kind: row[3],
|
|
||||||
accuracy: row[4],
|
|
||||||
pp: row[5],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the battle engine will answer the fly's move `id` with nothing at all, on this frame.
|
|
||||||
///
|
|
||||||
/// Row 60 (`docs/design/macros.md` 12.23): Squirtle's TAIL WHIP against a Pidgey whose DEFENSE
|
|
||||||
/// was already at -6 printed "Nothing happened!" 183 times on Route 1. These are the refusals the
|
|
||||||
/// effect routines in `engine/battle/effects.asm` make on bytes that are already in WRAM when the
|
|
||||||
/// move is chosen, for a move that deals no damage (a move with power is always attempted (type immunity, e.g. TACKLE against GASTLY, is not answered here)):
|
|
||||||
///
|
|
||||||
/// - a stat-raising effect (`StatModifierUpEffect`): the user's stage is already +6, or the stat
|
|
||||||
/// itself is already 999;
|
|
||||||
/// - a stat-lowering effect (`StatModifierDownEffect`, `MoveHitTest`): the target has a
|
|
||||||
/// substitute or Mist, its stage is already -6, or the stat itself is already 1;
|
|
||||||
/// - `SleepEffect`: the target already has a status and is not recharging;
|
|
||||||
/// - `PoisonEffect`: a substitute, a status, or a Poison type;
|
|
||||||
/// - `ParalyzeEffect`: a status, or an Electric move against a Ground type.
|
|
||||||
///
|
|
||||||
/// `Some(false)` for every other move, which the cartridge may still miss -- a miss is a roll,
|
|
||||||
/// and this answers only what is already decided. `None` outside a battle this module
|
|
||||||
/// understands, when the move table cannot be read, or when a stage byte is out of its range:
|
|
||||||
/// a refusal this module cannot read is not one it reports.
|
|
||||||
pub fn move_without_effect(memory: &mut dyn MemoryReader, id: u8) -> Option<bool> {
|
|
||||||
use poke::moves::*;
|
|
||||||
in_battle(memory)?;
|
|
||||||
let data = move_data(memory, id)?;
|
|
||||||
if data.power != 0 {
|
|
||||||
return Some(false);
|
|
||||||
}
|
|
||||||
let stage = |memory: &mut dyn MemoryReader, base: u16, stat: u8| -> Option<u8> {
|
|
||||||
Some(read(memory, base + u16::from(stat))).filter(|stage| (MIN_STAGE..=MAX_STAGE).contains(stage))
|
|
||||||
};
|
|
||||||
let value = |memory: &mut dyn MemoryReader, base: u16, stat: u8| -> Option<u16> {
|
|
||||||
(stat < STATS_WITH_VALUES).then(|| word_be(memory, base + 2 * u16::from(stat)))
|
|
||||||
};
|
|
||||||
let effect = data.effect;
|
|
||||||
let raised = match effect {
|
|
||||||
ATTACK_UP1..=EVASION_UP1 => Some(effect - ATTACK_UP1),
|
|
||||||
ATTACK_UP2..=EVASION_UP2 => Some(effect - ATTACK_UP2),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if let Some(stat) = raised {
|
|
||||||
let at = stage(memory, ram::wPlayerMonStatMods, stat)?;
|
|
||||||
return Some(at >= MAX_STAGE || value(memory, ram::wBattleMonAttack, stat) == Some(MAX_STAT));
|
|
||||||
}
|
|
||||||
let target = read(memory, ram::wEnemyBattleStatus2);
|
|
||||||
let lowered = match effect {
|
|
||||||
ATTACK_DOWN1..=EVASION_DOWN1 => Some(effect - ATTACK_DOWN1),
|
|
||||||
ATTACK_DOWN2..=EVASION_DOWN2 => Some(effect - ATTACK_DOWN2),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
if let Some(stat) = lowered {
|
|
||||||
let at = stage(memory, ram::wEnemyMonStatMods, stat)?;
|
|
||||||
return Some(
|
|
||||||
target & (SUBSTITUTE | MIST) != 0
|
|
||||||
|| at <= MIN_STAGE
|
|
||||||
|| value(memory, ram::wEnemyMonAttack, stat) == Some(MIN_STAT),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let status = read(memory, ram::wEnemyMonStatus);
|
|
||||||
let types = [read(memory, ram::wEnemyMonType1), read(memory, ram::wEnemyMonType1 + 1)];
|
|
||||||
Some(match effect {
|
|
||||||
SLEEP => status != 0 && target & RECHARGE == 0,
|
|
||||||
POISON => target & SUBSTITUTE != 0 || status != 0 || types.contains(&TYPE_POISON),
|
|
||||||
PARALYZE => {
|
|
||||||
status != 0 || (data.kind == TYPE_ELECTRIC && types.contains(&TYPE_GROUND))
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a text box is open, and whether the bottom-of-screen dialogue box is the one drawn.
|
/// Whether a text box is open, and whether the bottom-of-screen dialogue box is the one drawn.
|
||||||
///
|
///
|
||||||
/// `open` is `wFontLoaded`'s bit 0, which `DisplayTextIDInit` sets for every text display — the
|
/// `open` is `wFontLoaded`'s bit 0, which `DisplayTextIDInit` sets for every text display — the
|
||||||
|
|
@ -1846,14 +1685,8 @@ 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 {
|
||||||
match super::scene::detect(self.memory) {
|
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> {
|
||||||
|
|
@ -1921,27 +1754,6 @@ 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
|
||||||
|
|
@ -1955,7 +1767,7 @@ pub fn trainer_engaged(memory: &mut dyn MemoryReader) -> bool {
|
||||||
/// 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) || trainer_engaged(self.memory)
|
!controllable(self.memory)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn text_open(&mut self) -> bool {
|
fn text_open(&mut self) -> bool {
|
||||||
|
|
@ -1970,10 +1782,6 @@ impl MacroState for PokeState<'_> {
|
||||||
yes_no_prompt(self.memory)
|
yes_no_prompt(self.memory)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_without_effect(&mut self, id: u8) -> bool {
|
|
||||||
move_without_effect(self.memory, id).unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The whole loaded map's walkability, from the cache when it is for this map
|
/// The whole loaded map's walkability, from the cache when it is for this map
|
||||||
/// (`docs/design/macros.md` section 15).
|
/// (`docs/design/macros.md` section 15).
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -1067,189 +1067,3 @@ fn a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map() {
|
||||||
let off: Vec<u8> = offscreen_npcs(&mut wram).iter().map(|npc| npc.slot).collect();
|
let off: Vec<u8> = offscreen_npcs(&mut wram).iter().map(|npc| npc.slot).collect();
|
||||||
assert_eq!(off, vec![1], "only the leader is still off the screen from (4, 8)");
|
assert_eq!(off, vec![1], "only the leader is still off the screen from (4, 8)");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row 60's cartridge rows, as `data/moves/moves.asm` has them: `(id, effect, power, type)`.
|
|
||||||
const TACKLE: (u8, u8, u8, u8) = (0x21, 0x00, 35, 0x00);
|
|
||||||
const TAIL_WHIP: (u8, u8, u8, u8) = (0x27, 0x13, 0, 0x00);
|
|
||||||
const GROWL: (u8, u8, u8, u8) = (0x2d, 0x12, 0, 0x00);
|
|
||||||
const SCREECH: (u8, u8, u8, u8) = (0x67, 0x3b, 0, 0x00);
|
|
||||||
const WITHDRAW: (u8, u8, u8, u8) = (0x6e, 0x0b, 0, 0x15);
|
|
||||||
const SAND_ATTACK: (u8, u8, u8, u8) = (0x1c, 0x16, 0, 0x00);
|
|
||||||
const SLEEP_POWDER: (u8, u8, u8, u8) = (0x4f, 0x20, 0, 0x16);
|
|
||||||
const POISONPOWDER: (u8, u8, u8, u8) = (0x4d, 0x42, 0, 0x03);
|
|
||||||
const THUNDER_WAVE: (u8, u8, u8, u8) = (0x56, 0x43, 0, 0x17);
|
|
||||||
/// `AURORA_BEAM`: a damaging move with a stat side effect, which always does something.
|
|
||||||
const AURORA_BEAM: (u8, u8, u8, u8) = (0x3e, 0x44, 65, 0x19);
|
|
||||||
|
|
||||||
fn stage_battle() -> Wram {
|
|
||||||
let mut wram = Wram::new();
|
|
||||||
wram.battle_mon(0, 0xb1, 5, 8, 20, 0, &[(0x21, 35), (0x27, 30)])
|
|
||||||
.enemy_mon(0x24, 2, 13, 13)
|
|
||||||
.battle(1)
|
|
||||||
.normal_stages()
|
|
||||||
.move_table(&[
|
|
||||||
TACKLE,
|
|
||||||
TAIL_WHIP,
|
|
||||||
GROWL,
|
|
||||||
SCREECH,
|
|
||||||
WITHDRAW,
|
|
||||||
SAND_ATTACK,
|
|
||||||
SLEEP_POWDER,
|
|
||||||
POISONPOWDER,
|
|
||||||
THUNDER_WAVE,
|
|
||||||
AURORA_BEAM,
|
|
||||||
]);
|
|
||||||
wram
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_move_table_is_read_by_id_and_checked_against_its_own_first_byte() {
|
|
||||||
let mut wram = stage_battle();
|
|
||||||
let tail_whip = move_data(&mut wram, TAIL_WHIP.0).expect("a row");
|
|
||||||
assert_eq!((tail_whip.effect, tail_whip.power), (0x13, 0));
|
|
||||||
assert_eq!(move_data(&mut wram, 0), None, "no move zero");
|
|
||||||
assert_eq!(move_data(&mut wram, 0xa6), None, "past STRUGGLE");
|
|
||||||
assert_eq!(move_data(&mut wram, 0x22), None, "a row the cartridge image does not answer");
|
|
||||||
// A table that is not where the disassembly says: a row that does not open with its own id
|
|
||||||
// is somebody else's row, and it is not read as this one.
|
|
||||||
let mut shifted = Wram::new();
|
|
||||||
shifted.move_table(&[(0x28, 0x13, 0, 0)]);
|
|
||||||
let base = poke::moves::TABLE_ADDRESS + 0x26 * poke::moves::ROW_BYTES;
|
|
||||||
for offset in 0..6 {
|
|
||||||
let byte = shifted.read_rom(poke::moves::TABLE_BANK, base + 6 + offset).unwrap();
|
|
||||||
shifted.rom_byte(poke::moves::TABLE_BANK, base + offset, byte);
|
|
||||||
}
|
|
||||||
assert_eq!(move_data(&mut shifted, 0x27), None);
|
|
||||||
assert!(move_data(&mut shifted, 0x28).is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tail_whip_does_nothing_at_minus_six_and_something_before() {
|
|
||||||
// Row 60: the Pidgey's DEFENSE stage walked down from 7 to 1 by TAIL WHIP after TAIL WHIP.
|
|
||||||
let mut wram = stage_battle();
|
|
||||||
for stage in 2..=7 {
|
|
||||||
wram.set(ram::wEnemyMonStatMods + 1, stage);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(false), "stage {stage}");
|
|
||||||
}
|
|
||||||
wram.set(ram::wEnemyMonStatMods + 1, 1);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
|
|
||||||
// The -2 variant reads the same stage.
|
|
||||||
assert_eq!(move_without_effect(&mut wram, SCREECH.0), Some(true));
|
|
||||||
// TACKLE deals damage whatever the stages say.
|
|
||||||
assert_eq!(move_without_effect(&mut wram, TACKLE.0), Some(false));
|
|
||||||
// And a damaging move with a stat side effect is never "nothing".
|
|
||||||
wram.set(ram::wEnemyMonStatMods + 3, 1);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, AURORA_BEAM.0), Some(false));
|
|
||||||
// GROWL reads ATTACK's stage, not DEFENSE's.
|
|
||||||
assert_eq!(move_without_effect(&mut wram, GROWL.0), Some(false));
|
|
||||||
wram.set(ram::wEnemyMonStatMods, 1);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, GROWL.0), Some(true));
|
|
||||||
// SAND-ATTACK reads ACCURACY's, which has no stat value behind it.
|
|
||||||
assert_eq!(move_without_effect(&mut wram, SAND_ATTACK.0), Some(false));
|
|
||||||
wram.set(ram::wEnemyMonStatMods + 4, 1);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, SAND_ATTACK.0), Some(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_stat_already_at_one_or_nine_hundred_ninety_nine_refuses_before_the_stage_does() {
|
|
||||||
// `StatModifierDownEffect` restores the stage and prints "Nothing happened!" when the stat
|
|
||||||
// itself is already 1 -- a level-2 Pidgey's DEFENSE gets there before -6.
|
|
||||||
let mut wram = stage_battle();
|
|
||||||
wram.set(ram::wEnemyMonStatMods + 1, 4).set_word_be(ram::wEnemyMonAttack + 2, 1);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
|
|
||||||
wram.set_word_be(ram::wEnemyMonAttack + 2, 2);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(false));
|
|
||||||
// Raising: +6, or a stat of 999.
|
|
||||||
wram.set(ram::wPlayerMonStatMods + 1, 12);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(false));
|
|
||||||
wram.set(ram::wPlayerMonStatMods + 1, 13);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(true));
|
|
||||||
wram.set(ram::wPlayerMonStatMods + 1, 9).set_word_be(ram::wBattleMonAttack + 2, 999);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mist_and_a_substitute_turn_a_stat_lowering_move_away() {
|
|
||||||
let mut wram = stage_battle();
|
|
||||||
wram.set(ram::wEnemyBattleStatus2, poke::moves::MIST);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
|
|
||||||
wram.set(ram::wEnemyBattleStatus2, poke::moves::SUBSTITUTE);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
|
|
||||||
assert_eq!(move_without_effect(&mut wram, POISONPOWDER.0), Some(true));
|
|
||||||
// A raise is the user's own business.
|
|
||||||
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_status_move_against_a_target_it_cannot_affect_does_nothing() {
|
|
||||||
let mut wram = stage_battle();
|
|
||||||
for id in [SLEEP_POWDER.0, POISONPOWDER.0, THUNDER_WAVE.0] {
|
|
||||||
assert_eq!(move_without_effect(&mut wram, id), Some(false), "healthy target, {id:#04x}");
|
|
||||||
}
|
|
||||||
// Any status: already asleep, poisoned, paralysed.
|
|
||||||
wram.set(ram::wEnemyMonStatus, 1 << 6);
|
|
||||||
for id in [SLEEP_POWDER.0, POISONPOWDER.0, THUNDER_WAVE.0] {
|
|
||||||
assert_eq!(move_without_effect(&mut wram, id), Some(true), "statused target, {id:#04x}");
|
|
||||||
}
|
|
||||||
// A target that must recharge is put to sleep whatever its status (`SleepEffect`).
|
|
||||||
wram.set(ram::wEnemyBattleStatus2, poke::moves::RECHARGE);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, SLEEP_POWDER.0), Some(false));
|
|
||||||
// Types: a Poison type is not poisoned; a Ground type is not paralysed by an Electric move.
|
|
||||||
let mut typed = stage_battle();
|
|
||||||
typed.set(ram::wEnemyMonType1 + 1, poke::moves::TYPE_POISON);
|
|
||||||
assert_eq!(move_without_effect(&mut typed, POISONPOWDER.0), Some(true));
|
|
||||||
typed.set(ram::wEnemyMonType1 + 1, 0).set(ram::wEnemyMonType1, poke::moves::TYPE_GROUND);
|
|
||||||
assert_eq!(move_without_effect(&mut typed, THUNDER_WAVE.0), Some(true));
|
|
||||||
assert_eq!(move_without_effect(&mut typed, POISONPOWDER.0), Some(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_refusal_that_cannot_be_read_is_not_reported() {
|
|
||||||
// Out of battle, no cartridge behind the seam, or a stage byte out of its 1..=13 range.
|
|
||||||
let mut wram = stage_battle();
|
|
||||||
wram.set(ram::wIsInBattle, 0);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), None);
|
|
||||||
let mut bare = Wram::new();
|
|
||||||
bare.battle(1).normal_stages().set(ram::wEnemyMonStatMods + 1, 1);
|
|
||||||
assert_eq!(move_without_effect(&mut bare, TAIL_WHIP.0), None, "no move table");
|
|
||||||
let mut wram = stage_battle();
|
|
||||||
wram.set(ram::wEnemyMonStatMods + 1, 0);
|
|
||||||
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), None);
|
|
||||||
// And the seam's own answer is "leave the button where it was".
|
|
||||||
let mut unread = Wram::new();
|
|
||||||
assert!(!PokeState::new(&mut unread).move_without_effect(TAIL_WHIP.0));
|
|
||||||
let mut read = stage_battle();
|
|
||||||
read.set(ram::wEnemyMonStatMods + 1, 1);
|
|
||||||
assert!(PokeState::new(&mut read).move_without_effect(TAIL_WHIP.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_trainers_challenge_is_the_cartridges_until_its_battle_is_over() {
|
|
||||||
use crate::pokemon_red::macros::state::GameState;
|
|
||||||
// Row 61. A trainer who saw the fly: its "!" bubble (before `wJoyIgnore` is set) and the five
|
|
||||||
// frames after its text (before `wCurOpponent` is) have every bit `controllable` reads clear.
|
|
||||||
// The macros read them as the cartridge's; the shared readings the reward adapter uses do not
|
|
||||||
// move.
|
|
||||||
let mut wram = Wram::overworld();
|
|
||||||
assert_eq!(PokeState::new(&mut wram).scene(), Scene::Overworld);
|
|
||||||
assert!(!PokeState::new(&mut wram).scripted());
|
|
||||||
|
|
||||||
wram.set(ram::wStatusFlags7, poke::TRAINER_BATTLE_STATUS7);
|
|
||||||
assert!(trainer_engaged(&mut wram));
|
|
||||||
assert_eq!(PokeState::new(&mut wram).scene(), Scene::Unknown, "inside the challenge");
|
|
||||||
assert!(PokeState::new(&mut wram).scripted(), "and the fly is not its own master");
|
|
||||||
assert!(controllable(&mut wram), "the adapter's gate is unchanged");
|
|
||||||
assert_eq!(crate::pokemon_red::scene::detect(&mut wram), Scene::Overworld, "and the shared scene");
|
|
||||||
|
|
||||||
// The challenge's own text is still a conversation to advance.
|
|
||||||
wram.dialogue_box();
|
|
||||||
assert_eq!(PokeState::new(&mut wram).scene(), Scene::Dialog);
|
|
||||||
|
|
||||||
// `.battleOccurred` clears the bit; the other bits of the byte are not a challenge:
|
|
||||||
// `BIT_NO_MAP_MUSIC` after a rival, `BIT_USE_CUR_MAP_SCRIPT` from a trainer talked to.
|
|
||||||
let mut after = Wram::overworld();
|
|
||||||
after.set(ram::wStatusFlags7, (1 << 1) | (1 << 4) | (1 << 7));
|
|
||||||
assert!(!trainer_engaged(&mut after));
|
|
||||||
assert_eq!(PokeState::new(&mut after).scene(), Scene::Overworld);
|
|
||||||
assert!(!PokeState::new(&mut after).scripted());
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -23,38 +23,29 @@ pub mod ram {
|
||||||
pub const wMaxMenuItem: u16 = 0xcc28; // 52264
|
pub const wMaxMenuItem: u16 = 0xcc28; // 52264
|
||||||
pub const wMenuWatchedKeys: u16 = 0xcc29; // 52265
|
pub const wMenuWatchedKeys: u16 = 0xcc29; // 52265
|
||||||
pub const wPlayerMonNumber: u16 = 0xcc2f; // 52271
|
pub const wPlayerMonNumber: u16 = 0xcc2f; // 52271
|
||||||
pub const wPlayerMonStatMods: u16 = 0xcd1a; // 52506
|
|
||||||
pub const wEnemyMonStatMods: u16 = 0xcd2e; // 52526
|
|
||||||
pub const wSimulatedJoypadStatesIndex: u16 = 0xcd38; // 52536
|
pub const wSimulatedJoypadStatesIndex: u16 = 0xcd38; // 52536
|
||||||
pub const wMiscFlags: u16 = 0xcd60; // 52576
|
pub const wMiscFlags: u16 = 0xcd60; // 52576
|
||||||
pub const wJoyIgnore: u16 = 0xcd6b; // 52587
|
pub const wJoyIgnore: u16 = 0xcd6b; // 52587
|
||||||
pub const wNumMovesMinusOne: u16 = 0xcd6c; // 52588
|
pub const wNumMovesMinusOne: u16 = 0xcd6c; // 52588
|
||||||
pub const wBattleResult: u16 = 0xcf0b; // 53003
|
pub const wBattleResult: u16 = 0xcf0b; // 53003
|
||||||
pub const wSpriteIndex: u16 = 0xcf13; // 53011
|
|
||||||
pub const wItemList: u16 = 0xcf7b; // 53115
|
pub const wItemList: u16 = 0xcf7b; // 53115
|
||||||
pub const wListMenuID: u16 = 0xcf94; // 53140
|
pub const wListMenuID: u16 = 0xcf94; // 53140
|
||||||
pub const wFontLoaded: u16 = 0xcfc4; // 53188
|
pub const wFontLoaded: u16 = 0xcfc4; // 53188
|
||||||
pub const wWalkCounter: u16 = 0xcfc5; // 53189
|
|
||||||
pub const wEnemyMonSpecies: u16 = 0xcfe5; // 53221
|
pub const wEnemyMonSpecies: u16 = 0xcfe5; // 53221
|
||||||
pub const wEnemyMonHP: u16 = 0xcfe6; // 53222
|
pub const wEnemyMonHP: u16 = 0xcfe6; // 53222
|
||||||
pub const wEnemyMonStatus: u16 = 0xcfe9; // 53225
|
|
||||||
pub const wEnemyMonType1: u16 = 0xcfea; // 53226
|
|
||||||
pub const wEnemyMonLevel: u16 = 0xcff3; // 53235
|
pub const wEnemyMonLevel: u16 = 0xcff3; // 53235
|
||||||
pub const wEnemyMonMaxHP: u16 = 0xcff4; // 53236
|
pub const wEnemyMonMaxHP: u16 = 0xcff4; // 53236
|
||||||
pub const wEnemyMonAttack: u16 = 0xcff6; // 53238
|
|
||||||
pub const wBattleMonSpecies: u16 = 0xd014; // 53268
|
pub const wBattleMonSpecies: u16 = 0xd014; // 53268
|
||||||
pub const wBattleMonHP: u16 = 0xd015; // 53269
|
pub const wBattleMonHP: u16 = 0xd015; // 53269
|
||||||
pub const wBattleMonStatus: u16 = 0xd018; // 53272
|
pub const wBattleMonStatus: u16 = 0xd018; // 53272
|
||||||
pub const wBattleMonMoves: u16 = 0xd01c; // 53276
|
pub const wBattleMonMoves: u16 = 0xd01c; // 53276
|
||||||
pub const wBattleMonLevel: u16 = 0xd022; // 53282
|
pub const wBattleMonLevel: u16 = 0xd022; // 53282
|
||||||
pub const wBattleMonMaxHP: u16 = 0xd023; // 53283
|
pub const wBattleMonMaxHP: u16 = 0xd023; // 53283
|
||||||
pub const wBattleMonAttack: u16 = 0xd025; // 53285
|
|
||||||
pub const wBattleMonPP: u16 = 0xd02d; // 53293
|
pub const wBattleMonPP: u16 = 0xd02d; // 53293
|
||||||
pub const wTrainerClass: u16 = 0xd031; // 53297
|
pub const wTrainerClass: u16 = 0xd031; // 53297
|
||||||
pub const wIsInBattle: u16 = 0xd057; // 53335
|
pub const wIsInBattle: u16 = 0xd057; // 53335
|
||||||
pub const wBattleType: u16 = 0xd05a; // 53338
|
pub const wBattleType: u16 = 0xd05a; // 53338
|
||||||
pub const wTrainerNo: u16 = 0xd05d; // 53341
|
pub const wTrainerNo: u16 = 0xd05d; // 53341
|
||||||
pub const wEnemyBattleStatus2: u16 = 0xd068; // 53352
|
|
||||||
pub const wPartyMenuTypeOrMessageID: u16 = 0xd07d; // 53373
|
pub const wPartyMenuTypeOrMessageID: u16 = 0xd07d; // 53373
|
||||||
pub const wCapturedMonSpecies: u16 = 0xd11c; // 53532
|
pub const wCapturedMonSpecies: u16 = 0xd11c; // 53532
|
||||||
pub const wForcePlayerToChooseMon: u16 = 0xd11f; // 53535
|
pub const wForcePlayerToChooseMon: u16 = 0xd11f; // 53535
|
||||||
|
|
@ -80,15 +71,11 @@ pub mod ram {
|
||||||
pub const wSignCoords: u16 = 0xd4b1; // 54449
|
pub const wSignCoords: u16 = 0xd4b1; // 54449
|
||||||
pub const wSignTextIDs: u16 = 0xd4d1; // 54481
|
pub const wSignTextIDs: u16 = 0xd4d1; // 54481
|
||||||
pub const wNumSprites: u16 = 0xd4e1; // 54497
|
pub const wNumSprites: u16 = 0xd4e1; // 54497
|
||||||
pub const wMapSpriteExtraData: u16 = 0xd504; // 54532
|
|
||||||
pub const wTilesetBank: u16 = 0xd52b; // 54571
|
pub const wTilesetBank: u16 = 0xd52b; // 54571
|
||||||
pub const wTilesetBlocksPtr: u16 = 0xd52c; // 54572
|
pub const wTilesetBlocksPtr: u16 = 0xd52c; // 54572
|
||||||
pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576
|
pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576
|
||||||
pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578
|
pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578
|
||||||
pub const wNumHoFTeams: u16 = 0xd5a2; // 54690
|
pub const wNumHoFTeams: u16 = 0xd5a2; // 54690
|
||||||
pub const wToggleableObjectFlags: u16 = 0xd5a6; // 54694
|
|
||||||
pub const wToggleableObjectList: u16 = 0xd5ce; // 54734
|
|
||||||
pub const wObtainedHiddenItemsFlags: u16 = 0xd6f0; // 55024
|
|
||||||
pub const wStatusFlags5: u16 = 0xd730; // 55088
|
pub const wStatusFlags5: u16 = 0xd730; // 55088
|
||||||
pub const wStatusFlags6: u16 = 0xd732; // 55090
|
pub const wStatusFlags6: u16 = 0xd732; // 55090
|
||||||
pub const wStatusFlags7: u16 = 0xd733; // 55091
|
pub const wStatusFlags7: u16 = 0xd733; // 55091
|
||||||
|
|
|
||||||
|
|
@ -865,8 +865,8 @@ fn the_recent_ticker_keeps_the_newest_eight_events_newest_first() {
|
||||||
#[test]
|
#[test]
|
||||||
fn the_adapter_reports_its_identity_and_pinned_rom() {
|
fn the_adapter_reports_its_identity_and_pinned_rom() {
|
||||||
let reward = PokemonRedReward::new();
|
let reward = PokemonRedReward::new();
|
||||||
assert_eq!(reward.id(), "pokered-unique8-v7");
|
assert_eq!(reward.id(), "pokered-unique8-v6");
|
||||||
assert_eq!(reward.migrates_from(), ["pokered-unique8-v6"]);
|
assert_eq!(reward.migrates_from(), ["pokered-unique8-v5"]);
|
||||||
assert!(reward.rom_allowed(SUPPORTED_ROM));
|
assert!(reward.rom_allowed(SUPPORTED_ROM));
|
||||||
assert!(!reward.rom_allowed(
|
assert!(!reward.rom_allowed(
|
||||||
"5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b"
|
"5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b"
|
||||||
|
|
@ -1356,541 +1356,3 @@ fn a_rung_earned_out_of_order_does_not_skip_the_ones_under_it() {
|
||||||
f.visit(3, 3);
|
f.visit(3, 3);
|
||||||
assert_eq!(f.reward.rank(), 9);
|
assert_eq!(f.reward.rank(), 9);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Engagement rewards (the operator, 2026-09-23) -----------------------------------------------
|
|
||||||
|
|
||||||
/// `constants/tileset_constants.asm`: a gym, a house, a mart, the forest and a cave.
|
|
||||||
const GYM: u8 = 7;
|
|
||||||
const HOUSE: u8 = 8;
|
|
||||||
const MART: u8 = 2;
|
|
||||||
const FOREST: u8 = 3;
|
|
||||||
const CAVERN: u8 = 17;
|
|
||||||
|
|
||||||
/// `wSpriteStateData1`'s facing byte: `SPRITE_FACING_DOWN`, `_UP`, `_LEFT`, `_RIGHT`.
|
|
||||||
const FACING_DOWN: u8 = 0x00;
|
|
||||||
const FACING_UP: u8 = 0x04;
|
|
||||||
const FACING_LEFT: u8 = 0x08;
|
|
||||||
|
|
||||||
impl Fixture {
|
|
||||||
/// Stand on `map`, drawn with `tileset`, with no counter tiles in the tileset header.
|
|
||||||
fn on_map(&mut self, map: u8, tileset: u8) {
|
|
||||||
self.memory.set(ram::wCurMap, map);
|
|
||||||
self.memory.set(ram::wCurMapTileset, tileset);
|
|
||||||
for index in 0..3 {
|
|
||||||
self.memory.set(ram::wTilesetTalkingOverTiles + index, 0xff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A visible sprite in `slot` at map tile `(x, y)`, stored plus four as `object_event` emits
|
|
||||||
/// it, with `extra` in its `wMapSpriteExtraData` entry.
|
|
||||||
fn sprite(&mut self, slot: u8, x: u8, y: u8, extra: (u8, u8)) {
|
|
||||||
let count = self.memory.bytes[ram::wNumSprites as usize].max(slot);
|
|
||||||
self.memory.set(ram::wNumSprites, count);
|
|
||||||
let data1 = ram::wSpriteStateData1 + u16::from(slot) * 16;
|
|
||||||
let data2 = ram::wSpriteStateData2 + u16::from(slot) * 16;
|
|
||||||
self.memory.set(data1, 1);
|
|
||||||
self.memory.set(data1 + 2, 0);
|
|
||||||
self.memory.set(data2 + 4, y + 4);
|
|
||||||
self.memory.set(data2 + 5, x + 4);
|
|
||||||
let entry = ram::wMapSpriteExtraData + (u16::from(slot) - 1) * 2;
|
|
||||||
self.memory.set(entry, extra.0);
|
|
||||||
self.memory.set(entry + 1, extra.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn face(&mut self, facing: u8) {
|
|
||||||
self.memory.set(ram::wSpriteStateData1 + 9, facing);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `DisplayTextIDInit`'s `TextBoxBorder` at screen (0, 12)-(19, 17), the box every text id
|
|
||||||
/// but the start menu's is drawn in; or the map's own tiles again once it is gone.
|
|
||||||
fn dialogue_box(&mut self, drawn: bool) {
|
|
||||||
use super::state::poke::frame;
|
|
||||||
let at = |x: u16, y: u16| ram::wTileMap + y * 20 + x;
|
|
||||||
for y in 12..=17u16 {
|
|
||||||
for x in 0..20u16 {
|
|
||||||
let tile = if !drawn {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
match (x, y) {
|
|
||||||
(0, 12) => frame::TOP_LEFT,
|
|
||||||
(19, 12) => frame::TOP_RIGHT,
|
|
||||||
(0, 17) => frame::BOTTOM_LEFT,
|
|
||||||
(19, 17) => frame::BOTTOM_RIGHT,
|
|
||||||
(_, 12) | (_, 17) => frame::HORIZONTAL,
|
|
||||||
(0, _) | (19, _) => frame::VERTICAL,
|
|
||||||
_ => 0x7f,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
self.memory.set(at(x, y), tile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The box opening: the font bit, the border, and -- `delay` samples later, the way the
|
|
||||||
/// cartridge loads the font's tiles first -- `DisplayTextID`'s argument.
|
|
||||||
fn open_box(&mut self, argument: u8, delay: usize) -> Vec<RewardEvent> {
|
|
||||||
self.memory.set(ram::wFontLoaded, 1);
|
|
||||||
self.dialogue_box(true);
|
|
||||||
let mut events = Vec::new();
|
|
||||||
for _ in 0..delay {
|
|
||||||
events.extend(self.sample());
|
|
||||||
}
|
|
||||||
self.memory.set(ram::wSpriteIndex, argument);
|
|
||||||
events.extend(self.sample());
|
|
||||||
events
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close_box(&mut self) -> Vec<RewardEvent> {
|
|
||||||
self.memory.set(ram::wFontLoaded, 0);
|
|
||||||
self.dialogue_box(false);
|
|
||||||
self.sample()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One conversation as the cartridge draws it: a sample with the box closed (the frame the A
|
|
||||||
/// press was read on), `DisplayTextIDInit` setting the font bit and `DisplayTextID` copying
|
|
||||||
/// `argument` into `wSpriteIndex`, a few frames of text, and `CloseTextDisplay`.
|
|
||||||
fn talk(&mut self, argument: u8) -> Vec<RewardEvent> {
|
|
||||||
let mut events = self.sample();
|
|
||||||
events.extend(self.open_box(argument, 20));
|
|
||||||
events.extend(self.sample());
|
|
||||||
events.extend(self.close_box());
|
|
||||||
events
|
|
||||||
}
|
|
||||||
|
|
||||||
fn talk_events(&mut self, argument: u8) -> Vec<RewardEvent> {
|
|
||||||
self.talk(argument).into_iter().filter(|event| event.kind == kind::TALK).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `wToggleableObjectList` for this map: `(sprite slot, global index)` pairs and `$ff`.
|
|
||||||
fn toggle_list(&mut self, entries: &[(u8, u8)]) {
|
|
||||||
for (index, (slot, global)) in entries.iter().enumerate() {
|
|
||||||
self.memory.set(ram::wToggleableObjectList + index as u16 * 2, *slot);
|
|
||||||
self.memory.set(ram::wToggleableObjectList + index as u16 * 2 + 1, *global);
|
|
||||||
}
|
|
||||||
self.memory.set(ram::wToggleableObjectList + entries.len() as u16 * 2, 0xff);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_bit(&mut self, base: u16, index: u16, on: bool) {
|
|
||||||
let address = base + index / 8;
|
|
||||||
let mask = 1 << (index % 8);
|
|
||||||
let byte = self.memory.bytes[address as usize];
|
|
||||||
self.memory.set(address, if on { byte | mask } else { byte & !mask });
|
|
||||||
}
|
|
||||||
|
|
||||||
fn item_events(&mut self) -> Vec<RewardEvent> {
|
|
||||||
self.sample().into_iter().filter(|event| event.kind == kind::ITEM).collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn indoors_is_the_cartridges_building_tilesets_and_nothing_else() {
|
|
||||||
// CheckIfInOutsideMap's outside (OVERWORLD, PLATEAU) and BikeRidingTilesets (OVERWORLD,
|
|
||||||
// FOREST, UNDERGROUND, SHIP_PORT, CAVERN) are the two tables; indoor is neither.
|
|
||||||
let outdoor = [0, 3, 11, 14, 17, 23];
|
|
||||||
for tileset in 0..24u8 {
|
|
||||||
assert_eq!(engage::indoor(tileset), !outdoor.contains(&tileset), "tileset {tileset}");
|
|
||||||
}
|
|
||||||
assert!(!engage::indoor(24), "a tileset past the table is not a building");
|
|
||||||
assert!(!engage::indoor(0xff));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_conversation_the_fly_opens_indoors_pays_once_when_its_box_closes() {
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(maps::PEWTER_GYM, GYM);
|
|
||||||
f.visit(5, 5);
|
|
||||||
f.sprite(1, 5, 4, (0, 0));
|
|
||||||
f.face(FACING_UP);
|
|
||||||
|
|
||||||
// Nothing on the frames the box is open: the payout waits for it to close.
|
|
||||||
let mut events = f.sample();
|
|
||||||
events.extend(f.open_box(1, 20));
|
|
||||||
assert_eq!(count_of_kind(&events, kind::TALK), 0, "not while the box is open");
|
|
||||||
let events = f.close_box();
|
|
||||||
assert_eq!(kinds(&events), ["talk"]);
|
|
||||||
assert_eq!(events[0].value, 0.10);
|
|
||||||
assert_eq!(events[0].stimulation_ms, 100);
|
|
||||||
assert_eq!(labels(&events), [format!("TALKED TO #1 IN AREA {}", maps::PEWTER_GYM)]);
|
|
||||||
|
|
||||||
// Talking to the same person again, as often as the fly likes, is not a farm.
|
|
||||||
for _ in 0..5 {
|
|
||||||
assert!(f.talk_events(1).is_empty(), "one payout per (map, object) for the ledger's life");
|
|
||||||
}
|
|
||||||
|
|
||||||
// A second person on the same map is a second key.
|
|
||||||
f.sprite(2, 4, 5, (0, 0));
|
|
||||||
f.face(FACING_LEFT);
|
|
||||||
assert_eq!(kinds(&f.talk_events(2)), ["talk"]);
|
|
||||||
|
|
||||||
// A sign is a text id past the sprite slots, on the tile the player faces.
|
|
||||||
f.memory.set(ram::wNumSigns, 1);
|
|
||||||
f.memory.set(ram::wSignCoords, 6);
|
|
||||||
f.memory.set(ram::wSignCoords + 1, 5);
|
|
||||||
f.memory.set(ram::wSignTextIDs, 7);
|
|
||||||
f.face(FACING_DOWN);
|
|
||||||
let sign = f.talk_events(7);
|
|
||||||
assert_eq!(labels(&sign), [format!("READ SIGN #7 IN AREA {}", maps::PEWTER_GYM)]);
|
|
||||||
assert!(f.talk_events(7).is_empty());
|
|
||||||
assert_eq!(f.reward.statistics().counts[kind::TALK], 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_same_slot_on_another_indoor_map_is_another_conversation() {
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(maps::PEWTER_GYM, GYM);
|
|
||||||
f.visit(5, 5);
|
|
||||||
f.sprite(1, 5, 4, (0, 0));
|
|
||||||
f.face(FACING_UP);
|
|
||||||
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
|
|
||||||
f.on_map(maps::OAKS_LAB, HOUSE);
|
|
||||||
f.visit(5, 5);
|
|
||||||
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn talking_outdoors_in_a_forest_or_in_a_cave_pays_nothing() {
|
|
||||||
for (map, tileset) in [
|
|
||||||
(maps::PEWTER_CITY, 0),
|
|
||||||
(maps::VIRIDIAN_FOREST, FOREST),
|
|
||||||
(maps::MT_MOON_1F, CAVERN),
|
|
||||||
] {
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(map, tileset);
|
|
||||||
f.visit(5, 5);
|
|
||||||
f.sprite(1, 5, 4, (0, 0));
|
|
||||||
f.face(FACING_UP);
|
|
||||||
assert!(f.talk_events(1).is_empty(), "map {map}, tileset {tileset}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn text_the_fly_did_not_open_pays_nothing() {
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(maps::PEWTER_GYM, GYM);
|
|
||||||
f.visit(5, 5);
|
|
||||||
f.sprite(1, 5, 4, (0, 0));
|
|
||||||
f.face(FACING_UP);
|
|
||||||
|
|
||||||
// A script that took the joypad before it drew the box: a trainer walking up, a guard.
|
|
||||||
f.memory.set(ram::wJoyIgnore, 0xff);
|
|
||||||
assert!(f.talk_events(1).is_empty(), "the joypad was the cartridge's");
|
|
||||||
f.memory.set(ram::wJoyIgnore, 0);
|
|
||||||
|
|
||||||
// Simulated input, and scripted movement.
|
|
||||||
f.memory.set(ram::wSimulatedJoypadStatesIndex, 3);
|
|
||||||
assert!(f.talk_events(1).is_empty(), "the buttons were simulated");
|
|
||||||
f.memory.set(ram::wSimulatedJoypadStatesIndex, 0);
|
|
||||||
f.memory.set(ram::wStatusFlags5, 0x80);
|
|
||||||
assert!(f.talk_events(1).is_empty(), "the movement was scripted");
|
|
||||||
f.memory.set(ram::wStatusFlags5, 0);
|
|
||||||
|
|
||||||
// A trigger tile: the box opens on the frame a step ends, with the walk counter still
|
|
||||||
// running on the sample before it -- the overworld never reads A mid-step.
|
|
||||||
f.memory.set(ram::wWalkCounter, 1);
|
|
||||||
assert!(f.talk_events(1).is_empty(), "a step onto a trigger tile is not a press");
|
|
||||||
f.memory.set(ram::wWalkCounter, 0);
|
|
||||||
|
|
||||||
// Text about someone the fly is not facing: a script naming a sprite across the room.
|
|
||||||
f.sprite(2, 9, 9, (0, 0));
|
|
||||||
assert!(f.talk_events(2).is_empty(), "not the thing in front");
|
|
||||||
// A text id that is no sign in front of the fly.
|
|
||||||
assert!(f.talk_events(9).is_empty(), "no sign there");
|
|
||||||
// The start menu is text id 0.
|
|
||||||
assert!(f.talk_events(0).is_empty(), "the start menu is not a conversation");
|
|
||||||
|
|
||||||
// A script that opens the box with the joypad taken and hands it back mid-conversation:
|
|
||||||
// the frames after it are the fly's, but the box did not open on one of them.
|
|
||||||
f.memory.set(ram::wJoyIgnore, 0xff);
|
|
||||||
f.sample();
|
|
||||||
f.open_box(1, 0);
|
|
||||||
f.memory.set(ram::wJoyIgnore, 0);
|
|
||||||
f.sample();
|
|
||||||
f.sample();
|
|
||||||
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0, "no open edge after a ready frame");
|
|
||||||
|
|
||||||
// START in front of someone: the menu's box is not the dialogue box, and `wSpriteIndex`
|
|
||||||
// still names the last person spoken to for the frames before the menu's own id arrives.
|
|
||||||
f.memory.set(ram::wSpriteIndex, 1);
|
|
||||||
f.sample();
|
|
||||||
f.memory.set(ram::wFontLoaded, 1);
|
|
||||||
for _ in 0..20 {
|
|
||||||
f.sample();
|
|
||||||
}
|
|
||||||
f.memory.set(ram::wSpriteIndex, 0);
|
|
||||||
f.sample();
|
|
||||||
f.memory.set(ram::wFontLoaded, 0);
|
|
||||||
assert_eq!(count_of_kind(&f.sample(), kind::TALK), 0, "the start menu is not a conversation");
|
|
||||||
|
|
||||||
// The argument never arrives inside the window: nothing is guessed.
|
|
||||||
f.memory.set(ram::wSpriteIndex, 0);
|
|
||||||
f.sample();
|
|
||||||
f.open_box(1, 60);
|
|
||||||
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0, "a second of frames, then no more");
|
|
||||||
|
|
||||||
// None of that was recorded: the person is still worth one conversation.
|
|
||||||
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_conversation_that_ends_somewhere_else_pays_nothing() {
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(maps::PEWTER_GYM, GYM);
|
|
||||||
f.visit(5, 5);
|
|
||||||
f.sprite(1, 5, 4, (0, 0));
|
|
||||||
f.face(FACING_UP);
|
|
||||||
f.sample();
|
|
||||||
f.open_box(1, 20);
|
|
||||||
// The script warped the fly out while the box was up.
|
|
||||||
f.on_map(maps::PEWTER_CITY, 0);
|
|
||||||
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0);
|
|
||||||
|
|
||||||
// A rollback mid-conversation, likewise: the watch is transient.
|
|
||||||
f.on_map(maps::PEWTER_GYM, GYM);
|
|
||||||
f.sample();
|
|
||||||
f.open_box(1, 20);
|
|
||||||
f.reward.clear_transient();
|
|
||||||
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_item_ball_is_an_item_not_a_conversation() {
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(maps::OAKS_LAB, HOUSE);
|
|
||||||
f.visit(5, 5);
|
|
||||||
f.sprite(1, 5, 4, (0x14, 0));
|
|
||||||
f.face(FACING_UP);
|
|
||||||
assert!(f.talk_events(1).is_empty());
|
|
||||||
// A trainer is a person: `(class, number)`, and numbers start at one.
|
|
||||||
f.sprite(2, 4, 5, (0xcb, 2));
|
|
||||||
f.face(FACING_LEFT);
|
|
||||||
assert_eq!(kinds(&f.talk_events(2)), ["talk"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_clerk_across_a_counter_is_in_reach_only_where_the_tileset_has_counters() {
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(maps::VIRIDIAN_MART, MART);
|
|
||||||
f.visit(5, 5);
|
|
||||||
f.sprite(1, 5, 3, (0, 0));
|
|
||||||
f.face(FACING_UP);
|
|
||||||
assert!(f.talk_events(1).is_empty(), "two tiles away with no counter tiles in the header");
|
|
||||||
f.memory.set(ram::wTilesetTalkingOverTiles, 0x18);
|
|
||||||
assert_eq!(kinds(&f.talk_events(1)), ["talk"], "IsSpriteOrSignInFrontOfPlayer's long range");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_rollback_or_a_restore_cannot_replay_a_conversation() {
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(maps::PEWTER_GYM, GYM);
|
|
||||||
f.visit(5, 5);
|
|
||||||
f.sprite(1, 5, 4, (0, 0));
|
|
||||||
f.face(FACING_UP);
|
|
||||||
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
|
|
||||||
|
|
||||||
f.reward.clear_transient();
|
|
||||||
let state = f.reward.export_state();
|
|
||||||
assert!(
|
|
||||||
state["seen"]
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.contains(&json!(format!("talk:{}:sprite:1", maps::PEWTER_GYM))),
|
|
||||||
"the ledger is the checkpointed `seen` set, not the macros' session ledger"
|
|
||||||
);
|
|
||||||
let mut restored = PokemonRedReward::new();
|
|
||||||
restored.import_state(&state).unwrap();
|
|
||||||
f.reward = restored;
|
|
||||||
f.visit(5, 5);
|
|
||||||
assert!(f.talk_events(1).is_empty());
|
|
||||||
assert_eq!(f.reward.statistics().counts[kind::TALK], 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_item_ball_pays_once_when_its_bit_rises_and_never_again() {
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(maps::VIRIDIAN_FOREST, FOREST);
|
|
||||||
f.sprite(3, 5, 4, (0x14, 0)); // a Potion
|
|
||||||
f.toggle_list(&[(3, 0x2a)]);
|
|
||||||
f.visit(5, 5);
|
|
||||||
|
|
||||||
// PickUpItem: GiveItem, then HideObject sets the ball's global bit.
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
|
|
||||||
let events = f.item_events();
|
|
||||||
assert_eq!(labels(&events), ["FOUND ITEM #20"]);
|
|
||||||
assert_eq!(events[0].value, 0.15);
|
|
||||||
assert_eq!(events[0].stimulation_ms, 120);
|
|
||||||
assert!(f.item_events().is_empty(), "a bit that stays set pays once");
|
|
||||||
|
|
||||||
// A rollback to a slot where the ball is still there, and the fly takes it again.
|
|
||||||
f.reward.clear_transient();
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x2a, false);
|
|
||||||
f.sample();
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
|
|
||||||
assert!(f.item_events().is_empty(), "once per item for the run");
|
|
||||||
assert_eq!(f.reward.statistics().counts[kind::ITEM], 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_an_item_balls_bit_pays_and_only_after_a_pickup() {
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(maps::PEWTER_CITY, 0);
|
|
||||||
f.sprite(1, 2, 2, (0, 0)); // a person a script hides
|
|
||||||
f.sprite(2, 3, 3, (0xcb, 1)); // a trainer
|
|
||||||
f.sprite(4, 6, 6, (0x14, 0)); // a ball
|
|
||||||
f.toggle_list(&[(1, 0x03), (2, 0x04), (4, 0x05)]);
|
|
||||||
f.visit(5, 5);
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x03, true);
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x04, true);
|
|
||||||
assert!(f.item_events().is_empty(), "a hidden person or trainer is not an item");
|
|
||||||
// A bag too full to take the ball leaves the bit clear, and nothing pays.
|
|
||||||
assert!(f.item_events().is_empty());
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x05, true);
|
|
||||||
assert_eq!(kinds(&f.item_events()), ["item"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_hidden_item_pays_once_per_index() {
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(maps::PEWTER_GYM, GYM);
|
|
||||||
f.visit(5, 5);
|
|
||||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, true);
|
|
||||||
assert_eq!(labels(&f.item_events()), ["FOUND A HIDDEN ITEM"]);
|
|
||||||
assert!(f.item_events().is_empty());
|
|
||||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 18, true);
|
|
||||||
assert_eq!(kinds(&f.item_events()), ["item"]);
|
|
||||||
f.reward.clear_transient();
|
|
||||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, false);
|
|
||||||
f.sample();
|
|
||||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, true);
|
|
||||||
assert!(f.item_events().is_empty(), "a rollback cannot replay a hidden item");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn items_already_taken_are_seeded_and_the_two_script_shown_balls_are_not() {
|
|
||||||
let mut f = Fixture::new();
|
|
||||||
f.memory.set(ram::wCurMap, maps::REDS_HOUSE_2F);
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x10, true);
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x87, true);
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x88, true);
|
|
||||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 4, true);
|
|
||||||
assert!(f.item_events().is_empty(), "the seed pays nothing");
|
|
||||||
let seen = f.reward.export_state()["seen"].clone();
|
|
||||||
let seen: Vec<&str> = seen.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
|
|
||||||
for key in ["item:16", "hidden:4", "items:seeded"] {
|
|
||||||
assert!(seen.contains(&key), "{key}");
|
|
||||||
}
|
|
||||||
assert!(!seen.contains(&"item:135") && !seen.contains(&"item:136"));
|
|
||||||
|
|
||||||
// Giovanni beaten: the script shows the Silph Scope's ball (its bit clears), and the fly
|
|
||||||
// takes it.
|
|
||||||
f.on_map(0x8a, 22);
|
|
||||||
f.sprite(9, 25, 2, (0x48, 0));
|
|
||||||
f.toggle_list(&[(9, 0x87)]);
|
|
||||||
f.sample();
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x87, false);
|
|
||||||
f.sample();
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x87, true);
|
|
||||||
assert_eq!(kinds(&f.item_events()), ["item"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn boundary_pays_nothing_indoors_but_still_records_the_exit() {
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(maps::REDS_HOUSE_1F, 1);
|
|
||||||
f.warps(&[(4, 4)]);
|
|
||||||
assert!(boundary_values(&f.visit(3, 4)).is_empty(), "no payout beside an indoor door");
|
|
||||||
assert!(boundary_values(&f.visit(4, 4)).is_empty(), "nor on it");
|
|
||||||
assert!(
|
|
||||||
f.reward.exit_visited(MapExit::Warp { map: maps::REDS_HOUSE_1F, x: 4, y: 4 }),
|
|
||||||
"the ledger still knows the door, so the macros see what they always saw"
|
|
||||||
);
|
|
||||||
assert_eq!(f.reward.statistics().counts[kind::BOUNDARY], 0);
|
|
||||||
|
|
||||||
// Outdoors, in the forest and in a cave, exits pay exactly what they did.
|
|
||||||
let outdoors =
|
|
||||||
[(maps::PALLET_TOWN, 0), (maps::VIRIDIAN_FOREST, FOREST), (maps::MT_MOON_1F, CAVERN)];
|
|
||||||
for (map, tileset) in outdoors {
|
|
||||||
f.on_map(map, tileset);
|
|
||||||
assert_eq!(boundary_values(&f.visit(3, 4)), [0.05], "map {map}");
|
|
||||||
assert_eq!(boundary_values(&f.visit(4, 4)), [0.10], "map {map}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_v6_state_restores_under_v7_with_empty_talk_and_seeded_item_ledgers() {
|
|
||||||
// A v6 run: some play, a pickup the v6 adapter did not pay for, and a v6 export -- which is
|
|
||||||
// a v7 export without any of the keys v7 writes.
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(maps::VIRIDIAN_FOREST, FOREST);
|
|
||||||
f.sprite(3, 5, 4, (0x14, 0));
|
|
||||||
f.toggle_list(&[(3, 0x2a)]);
|
|
||||||
f.visit(5, 5);
|
|
||||||
f.catch(0xb0, Some(3));
|
|
||||||
let mut v6 = f.reward.export_state();
|
|
||||||
let seen: Vec<Value> = v6["seen"]
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.filter(|key| {
|
|
||||||
let key = key.as_str().unwrap();
|
|
||||||
!(key.starts_with("talk:") || key.starts_with("item") || key.starts_with("hidden:"))
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
v6["seen"] = json!(seen);
|
|
||||||
v6["counts"].as_object_mut().unwrap().remove("talk");
|
|
||||||
v6["counts"].as_object_mut().unwrap().remove("item");
|
|
||||||
assert_eq!(v6["version"], json!(STATE_VERSION), "v6 and v7 share a schema version");
|
|
||||||
|
|
||||||
// Under v6 the fly took the ball.
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
|
|
||||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, true);
|
|
||||||
|
|
||||||
let mut restored = PokemonRedReward::new();
|
|
||||||
restored.import_state(&v6).unwrap();
|
|
||||||
assert_eq!(restored.statistics().counts[kind::TALK], 0);
|
|
||||||
assert_eq!(restored.statistics().counts[kind::ITEM], 0);
|
|
||||||
assert_eq!(restored.statistics().counts[kind::CATCH], 1, "nothing else moves");
|
|
||||||
f.reward = restored;
|
|
||||||
assert!(f.sample().is_empty(), "no retroactive payout for anything taken under v6");
|
|
||||||
|
|
||||||
// A rollback to a v6-era slot where the ball is still on the ground: taking it again is
|
|
||||||
// the same pickup, and it does not pay.
|
|
||||||
f.reward.clear_transient();
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x2a, false);
|
|
||||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, false);
|
|
||||||
f.sample();
|
|
||||||
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
|
|
||||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, true);
|
|
||||||
assert!(f.item_events().is_empty());
|
|
||||||
|
|
||||||
// The talk ledger starts empty: the first conversation under v7 pays.
|
|
||||||
f.on_map(maps::PEWTER_GYM, GYM);
|
|
||||||
f.visit(5, 5);
|
|
||||||
f.sprite(1, 5, 4, (0, 0));
|
|
||||||
f.face(FACING_UP);
|
|
||||||
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_warp_is_classified_by_the_header_still_loaded_while_the_map_id_has_moved_on() {
|
|
||||||
// `WarpFound2` writes the destination into `wCurMap` and then plays the map-change sound for
|
|
||||||
// thirty-odd frames before `EnterMap` loads the new header (`tests/rom_engage.rs` measured
|
|
||||||
// it at Route 2's gate door). On those frames the tileset and the warp table are still the
|
|
||||||
// map the fly is leaving, and the exit it is standing on is that map's.
|
|
||||||
let mut f = Fixture::booted();
|
|
||||||
f.on_map(maps::ROUTE_2, 0);
|
|
||||||
f.warps(&[(3, 11)]);
|
|
||||||
f.visit(3, 10);
|
|
||||||
// Town -> building: the id is the gate's, the header is still Route 2's, and Route 2's
|
|
||||||
// door is an outdoor exit, so its on-exit half pays as it always did.
|
|
||||||
f.memory.set(ram::wCurMap, 0x2f);
|
|
||||||
assert_eq!(boundary_values(&f.visit(3, 11)), [0.10]);
|
|
||||||
|
|
||||||
// Building -> town: the id is Pewter's, the header still the museum's, whose door is an
|
|
||||||
// indoor exit and pays nothing.
|
|
||||||
f.on_map(0x34, 10);
|
|
||||||
f.warps(&[(10, 7)]);
|
|
||||||
f.visit(10, 6);
|
|
||||||
f.memory.set(ram::wCurMap, maps::PEWTER_CITY);
|
|
||||||
assert!(boundary_values(&f.visit(10, 7)).is_empty());
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,10 @@
|
||||||
|
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use flybrain_gb::adapter::{MapExit, MemoryReader};
|
use flybrain_gb::adapter::MemoryReader;
|
||||||
use flybrain_gb::emulator::{AUDIO_SILENCE_LEVEL, CPU_TICKS_PER_SECOND};
|
use flybrain_gb::emulator::{AUDIO_SILENCE_LEVEL, CPU_TICKS_PER_SECOND};
|
||||||
use flybrain_gb::pokemon_red::symbols::ram;
|
use flybrain_gb::pokemon_red::symbols::ram;
|
||||||
use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM, engage};
|
use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM};
|
||||||
use flybrain_gb::{
|
use flybrain_gb::{
|
||||||
DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, FRAMEBUFFER_LEN, GameAdapter,
|
DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, FRAMEBUFFER_LEN, GameAdapter,
|
||||||
buttons,
|
buttons,
|
||||||
|
|
@ -444,10 +444,7 @@ fn read_u32(bytes: &[u8], offset: &mut usize) -> Option<u32> {
|
||||||
/// The boundary rule, against the cartridge rather than a synthetic trace.
|
/// The boundary rule, against the cartridge rather than a synthetic trace.
|
||||||
///
|
///
|
||||||
/// `docs/design/room-escape.md` section 2, Verification: "from the bedroom archive, the first
|
/// `docs/design/room-escape.md` section 2, Verification: "from the bedroom archive, the first
|
||||||
/// payouts are boundary events near the stairs". Since `pokered-unique8-v7` the bedroom is
|
/// payouts are boundary events near the stairs". Two things are checked here that no synthetic
|
||||||
/// *indoors* (`engage::indoor`: tileset `REDS_HOUSE_2`, neither outside nor bike-ridable), so
|
|
||||||
/// the same walk now proves the other half of the rule: the stairs enter the ledger where they
|
|
||||||
/// always did, and nothing is paid for them. Three things are checked here that no synthetic
|
|
||||||
/// WRAM trace can check:
|
/// WRAM trace can check:
|
||||||
///
|
///
|
||||||
/// 1. **the warp table layout.** `RedsHouse2F_Object` declares exactly one warp,
|
/// 1. **the warp table layout.** `RedsHouse2F_Object` declares exactly one warp,
|
||||||
|
|
@ -456,11 +453,9 @@ fn read_u32(bytes: &[u8], offset: &mut usize) -> Option<u32> {
|
||||||
/// layout were X-then-Y, or the stride were not four, this assertion is what fails.
|
/// layout were X-then-Y, or the stride were not four, this assertion is what fails.
|
||||||
/// 2. **that the rule fires at the right place.** The fly spawns at (3, 6) with the stairs at
|
/// 2. **that the rule fires at the right place.** The fly spawns at (3, 6) with the stairs at
|
||||||
/// (7, 1), four tiles and five rows away, so the stairs are not baselined by the first
|
/// (7, 1), four tiles and five rows away, so the stairs are not baselined by the first
|
||||||
/// playable sample and have to be walked to -- and the ledger records them from beside them.
|
/// playable sample and have to be walked to.
|
||||||
/// 3. **that the cartridge calls the bedroom a building**: `wCurMapTileset` reads `REDS_HOUSE_2`
|
|
||||||
/// on the running game, so the rule pays nothing there.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_boundary_rule_records_the_bedroom_stairs_and_pays_nothing_indoors() {
|
fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() {
|
||||||
let mut emulator = skip_without_rom!(boot());
|
let mut emulator = skip_without_rom!(boot());
|
||||||
assert_eq!(emulator.rom_sha256(), SUPPORTED_ROM, "FLY_ROM is not the pinned cartridge");
|
assert_eq!(emulator.rom_sha256(), SUPPORTED_ROM, "FLY_ROM is not the pinned cartridge");
|
||||||
let mut adapter = PokemonRedReward::new();
|
let mut adapter = PokemonRedReward::new();
|
||||||
|
|
@ -503,8 +498,6 @@ fn the_boundary_rule_records_the_bedroom_stairs_and_pays_nothing_indoors() {
|
||||||
0,
|
0,
|
||||||
"an indoor map has no connected edges"
|
"an indoor map has no connected edges"
|
||||||
);
|
);
|
||||||
assert_eq!(emulator.read_wram(ram::wCurMapTileset), 4, "REDS_HOUSE_2");
|
|
||||||
assert!(engage::indoor(4), "and the rule calls it a building");
|
|
||||||
let (spawn_x, spawn_y) =
|
let (spawn_x, spawn_y) =
|
||||||
(emulator.read_wram(ram::wXCoord), emulator.read_wram(ram::wYCoord));
|
(emulator.read_wram(ram::wXCoord), emulator.read_wram(ram::wYCoord));
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -516,7 +509,6 @@ fn the_boundary_rule_records_the_bedroom_stairs_and_pays_nothing_indoors() {
|
||||||
let mut seed: u64 = 0x5eed_1234_5678_9abc;
|
let mut seed: u64 = 0x5eed_1234_5678_9abc;
|
||||||
let mut boundary: Vec<(u8, u8, f64)> = Vec::new();
|
let mut boundary: Vec<(u8, u8, f64)> = Vec::new();
|
||||||
let mut kinds: Vec<&'static str> = Vec::new();
|
let mut kinds: Vec<&'static str> = Vec::new();
|
||||||
let mut found_at: Option<(u8, u8)> = None;
|
|
||||||
for frame in 0..24_000u32 {
|
for frame in 0..24_000u32 {
|
||||||
seed = seed
|
seed = seed
|
||||||
.wrapping_mul(6_364_136_223_846_793_005)
|
.wrapping_mul(6_364_136_223_846_793_005)
|
||||||
|
|
@ -539,19 +531,26 @@ fn the_boundary_rule_records_the_bedroom_stairs_and_pays_nothing_indoors() {
|
||||||
boundary.push((x, y, event.value));
|
boundary.push((x, y, event.value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if found_at.is_none() && adapter.exit_visited(MapExit::Warp { map: 0x26, x: 7, y: 1 }) {
|
|
||||||
found_at = Some((x, y));
|
|
||||||
}
|
|
||||||
if adapter.map_id() != Some(0x26) {
|
if adapter.map_id() != Some(0x26) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("boundary: payouts in the bedroom {boundary:?}, all kinds {kinds:?}");
|
eprintln!("boundary: payouts in the bedroom {boundary:?}, all kinds {kinds:?}");
|
||||||
let (x, y) = found_at.expect("the stairs were never found");
|
let (x, y, value) = *boundary.first().expect("the stairs were never found");
|
||||||
assert!(
|
assert!(
|
||||||
x.abs_diff(7) + y.abs_diff(1) <= 1,
|
x.abs_diff(7) + y.abs_diff(1) <= 1,
|
||||||
"the stairs entered the ledger at ({x}, {y}), not next to the stairs at (7, 1)"
|
"the first boundary payout was at ({x}, {y}), not next to the stairs at (7, 1)"
|
||||||
);
|
);
|
||||||
assert!(boundary.is_empty(), "an indoor exit pays nothing, got {boundary:?}");
|
assert!(
|
||||||
|
value == 0.05 || value == 0.10,
|
||||||
|
"a boundary payout is the adjacent value or twice it, got {value}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
boundary.len() <= 2,
|
||||||
|
"one warp can pay at most twice in a lifetime, got {boundary:?}"
|
||||||
|
);
|
||||||
|
for (x, y, _) in &boundary {
|
||||||
|
assert!(x.abs_diff(7) + y.abs_diff(1) <= 1, "payout away from the stairs at ({x}, {y})");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ 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,20 +70,19 @@ use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use flybrain_core::agent::{
|
use flybrain_core::agent::{
|
||||||
AgentConfig, NeuralAgent,
|
AgentConfig, NeuralAgent, RewardEvent as NeuralReward, TickOptions,
|
||||||
};
|
};
|
||||||
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;
|
use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask};
|
||||||
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;
|
||||||
|
|
@ -104,12 +103,25 @@ fn emulator(rom: &[u8]) -> Emulator {
|
||||||
.expect("binjgb should accept the cartridge")
|
.expect("binjgb should accept the cartridge")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a macro owned the buttons of a frame: read as the executor hands the mask over.
|
/// The neural half of a ratchet recovery, exactly as `simloop.rs` wires it.
|
||||||
struct MacroOwned(bool);
|
struct AgentRecovery<'a> {
|
||||||
|
agent: &'a mut NeuralAgent,
|
||||||
|
}
|
||||||
|
|
||||||
impl FrameObserver for MacroOwned {
|
impl NeuralRecovery for AgentRecovery<'_> {
|
||||||
fn executed(&mut self, _frame: &LegacyFrame, parts: &mut Parts<'_>, _executed: &Executed) {
|
fn clear_decoder_holds(&mut self) {
|
||||||
self.0 = parts.macros.as_deref().is_some_and(|layer| layer.running().is_some());
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,9 +244,11 @@ 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 frame is `flysim::frame::LegacyFrame`, the one the stream runs, so the arm measures the
|
/// The order is `simloop.rs`'s (steps 2 to 10), as `NeuralAgent::tick` expresses it: the frame and
|
||||||
/// wiring under test rather than a second implementation of it. (Before FND-01 the arms ticked the
|
/// the payouts handed to a tick are the ones the previous tick's buttons produced. The macro layer
|
||||||
/// brain through `NeuralAgent::tick`, one frame behind the stream's order.)
|
/// is consulted at exactly the two points the loop consults it — after the decode, before the
|
||||||
|
/// 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>,
|
||||||
|
|
@ -256,39 +270,48 @@ 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() };
|
||||||
|
|
||||||
if let Start::Fresh { warmup_ms, .. } = start {
|
match start {
|
||||||
|
Start::Fresh { state, warmup_ms } => {
|
||||||
|
emulator.import_state(state).expect("the booted state should import");
|
||||||
agent_config.warmup_ms = *warmup_ms;
|
agent_config.warmup_ms = *warmup_ms;
|
||||||
}
|
}
|
||||||
|
Start::Live { checkpoint } => {
|
||||||
|
emulator
|
||||||
|
.import_state(&checkpoint.runtime.emulator)
|
||||||
|
.expect("the checkpoint's emulator state should import");
|
||||||
|
adapter
|
||||||
|
.import_state(&checkpoint.runtime.reward)
|
||||||
|
.expect("the checkpoint's reward ledger should import");
|
||||||
|
let snapshot = (!checkpoint.runtime.ratchet_game.is_empty()).then(|| {
|
||||||
|
flybrain_gb::ratchet::Snapshot {
|
||||||
|
game: checkpoint.runtime.ratchet_game.clone(),
|
||||||
|
frame: checkpoint.runtime.ratchet_frame.clone(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ratchet
|
||||||
|
.import(Some(checkpoint.runtime.ratchet), snapshot, adapter.rank_ladder().len())
|
||||||
|
.expect("the checkpoint's ratchet state should import");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
|
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
|
||||||
if threads > 1 {
|
if threads > 1 {
|
||||||
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
|
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
|
||||||
}
|
}
|
||||||
let mut frame = LegacyFrame::new()
|
|
||||||
.with_trace(FrameTrace::from_env().expect("FLY_TRACE should name a writable file"));
|
|
||||||
match start {
|
match start {
|
||||||
Start::Fresh { state, warmup_ms: _ } => {
|
Start::Fresh { .. } => {
|
||||||
emulator.import_state(state).expect("the booted state should import");
|
agent.warmup(Some(emulator.framebuffer())).expect("warm-up");
|
||||||
frame.frame_buffer.copy_from_slice(emulator.framebuffer());
|
}
|
||||||
agent.warmup(Some(&frame.frame_buffer)).expect("warm-up");
|
Start::Live { checkpoint } => {
|
||||||
|
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
|
||||||
|
|
@ -305,62 +328,136 @@ 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));
|
||||||
|
|
||||||
// One observation before the first frame, as the sim loop takes after a restore, so frame one
|
// The scene has not been observed yet, so the first frame is decided on an empty palette,
|
||||||
// is decided on a real palette.
|
// which presses nothing. That is one frame, and it is the honest starting state.
|
||||||
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 mut parts = Parts {
|
let rewards: Vec<NeuralReward> = payouts
|
||||||
agent: &mut agent,
|
.iter()
|
||||||
emulator: &mut emulator,
|
.map(|event| {
|
||||||
adapter: &mut adapter,
|
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms))
|
||||||
ratchet: &mut ratchet,
|
})
|
||||||
macros: macros.as_mut(),
|
.collect();
|
||||||
};
|
let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true };
|
||||||
let mut owned = MacroOwned(false);
|
|
||||||
let transition = frame.transition(&mut parts, &mut owned).expect("a frame");
|
// The blocked-direction cooldown's input, as `simloop.rs` computes it.
|
||||||
let ms = transition.ms;
|
let ms = agent.network.ms;
|
||||||
let executed = &transition.executed;
|
let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms)
|
||||||
if let Some(layer) = parts.macros.as_deref() {
|
.then(|| agent.decoder.current().map(str::to_string))
|
||||||
if let Some(silence) = executed.silence {
|
.flatten();
|
||||||
|
// 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 executed.events.iter().filter(|event| event.outcome.is_none()) {
|
for event in decision.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 owned.0 {
|
if layer.running().is_some() {
|
||||||
arm.macro_frames += 1;
|
arm.macro_frames += 1;
|
||||||
}
|
}
|
||||||
*arm.scenes.entry(layer.scene_name()).or_insert(0) += 1;
|
|
||||||
}
|
}
|
||||||
if executed.mask == 0 {
|
if 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;
|
||||||
for event in &transition.evaluated.rewards {
|
frame.copy_from_slice(emulator.framebuffer());
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
arm.digest = hash(arm.digest, u64::from(executed.mask));
|
if let Some(layer) = macros.as_mut() {
|
||||||
if let Some((map, x, y)) = frame.location {
|
let ledger = AdapterLedger(&adapter);
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
let progress = transition.evaluated.progress;
|
// Step 10: the ratchet, with the adapter's own policy.
|
||||||
|
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 boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
|
let safe = adapter.safe_for_snapshot();
|
||||||
if boundary.rollback.is_some() {
|
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best;
|
||||||
|
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,
|
AgentConfig, GAMEBOY_MS_PER_FRAME, NeuralAgent, RewardEvent as NeuralReward, TickOptions,
|
||||||
};
|
};
|
||||||
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,6 +496,28 @@ 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.
|
||||||
|
|
@ -644,97 +666,6 @@ 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.
|
||||||
///
|
///
|
||||||
|
|
@ -766,81 +697,174 @@ 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 =
|
let (hold_ms, blocked_ms) = {
|
||||||
config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group").hold_ms;
|
let group = config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group");
|
||||||
|
(group.hold_ms, group.blocked_ms)
|
||||||
|
};
|
||||||
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
|
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
|
||||||
let mut trace = Trace::default();
|
let mut trace = Trace::default();
|
||||||
if let Start::Fresh { warmup_ms, .. } = &start {
|
|
||||||
|
let start_map = match &start {
|
||||||
|
Start::Fresh { state, map, warmup_ms } => {
|
||||||
|
emulator.import_state(state).expect("the starting state should import");
|
||||||
agent_config.warmup_ms = *warmup_ms;
|
agent_config.warmup_ms = *warmup_ms;
|
||||||
|
*map
|
||||||
}
|
}
|
||||||
|
Start::Live { checkpoint, .. } => {
|
||||||
|
emulator
|
||||||
|
.import_state(&checkpoint.runtime.emulator)
|
||||||
|
.expect("the checkpoint's emulator state should import");
|
||||||
|
adapter
|
||||||
|
.import_state(&checkpoint.runtime.reward)
|
||||||
|
.expect("the checkpoint's reward ledger should import");
|
||||||
|
let snapshot = (!checkpoint.runtime.ratchet_game.is_empty()).then(|| {
|
||||||
|
flybrain_gb::ratchet::Snapshot {
|
||||||
|
game: checkpoint.runtime.ratchet_game.clone(),
|
||||||
|
frame: checkpoint.runtime.ratchet_frame.clone(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ratchet
|
||||||
|
.import(Some(checkpoint.runtime.ratchet), snapshot, adapter.rank_ladder().len())
|
||||||
|
.expect("the checkpoint's ratchet state should import");
|
||||||
|
u32::from(emulator.read8(ram::wCurMap))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
|
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
|
||||||
if threads > 1 {
|
if threads > 1 {
|
||||||
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
|
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
|
||||||
}
|
}
|
||||||
// The stream's own frame (`flysim::frame::LegacyFrame`), in raw mode: no macro layer.
|
match &start {
|
||||||
let mut frame = LegacyFrame::new();
|
Start::Fresh { .. } => agent.warmup(Some(emulator.framebuffer())).expect("warm-up"),
|
||||||
let start_map = match &start {
|
|
||||||
Start::Fresh { state, map, .. } => {
|
|
||||||
emulator.import_state(state).expect("the starting state should import");
|
|
||||||
frame.frame_buffer.copy_from_slice(emulator.framebuffer());
|
|
||||||
agent.warmup(Some(&frame.frame_buffer)).expect("warm-up");
|
|
||||||
*map
|
|
||||||
}
|
|
||||||
Start::Live { checkpoint, rng } => {
|
Start::Live { checkpoint, rng } => {
|
||||||
let mut checkpoint = (*checkpoint).clone();
|
let mut state = checkpoint.agent.clone();
|
||||||
checkpoint.agent.network.rng = *rng;
|
state.network.rng = *rng;
|
||||||
frame
|
agent.import_state(&state).expect("the checkpoint's agent state should import");
|
||||||
.restore(
|
let (width, height) = (agent.frame.width, agent.frame.height);
|
||||||
&mut Parts {
|
agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height);
|
||||||
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;
|
||||||
let mut escape = Escape {
|
// The sim loop's own order (`simloop.rs`, steps 2 to 10), as `NeuralAgent::tick` expresses it:
|
||||||
before: None,
|
// the frame and the payouts handed to a tick are the ones the previous tick's buttons produced.
|
||||||
hold_start: (frame.location, began_ms),
|
let mut frame = emulator.framebuffer().to_vec();
|
||||||
run_winner: None,
|
let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new();
|
||||||
run_length: 0.0,
|
let mut location = adapter.location();
|
||||||
start_map,
|
// The blocked-direction cooldown's window, restarted by a move *or* by a new winner, exactly
|
||||||
exits,
|
// as `simloop.rs` restarts it: a direction that has just won has not had a hold to move in yet.
|
||||||
hold_ms,
|
let mut blocked_since_ms = began_ms;
|
||||||
trace,
|
let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string);
|
||||||
};
|
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;
|
||||||
escape.trace.tiles = tiles_at_start;
|
trace.tiles = tiles_at_start;
|
||||||
|
|
||||||
while agent.network.ms < until {
|
while agent.network.ms < until {
|
||||||
let mut parts = Parts {
|
let rewards: Vec<NeuralReward> = payouts
|
||||||
agent: &mut agent,
|
.iter()
|
||||||
emulator: &mut emulator,
|
.map(|event| {
|
||||||
adapter: &mut adapter,
|
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms))
|
||||||
ratchet: &mut ratchet,
|
})
|
||||||
macros: None,
|
.collect();
|
||||||
};
|
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;
|
|
||||||
|
|
||||||
let location = frame.location;
|
// The blocked-direction cooldown's input, computed the way `simloop.rs` computes it: the
|
||||||
if location != location_before {
|
// channel the readout is holding, once the adapter's location has stood still for a whole
|
||||||
|
// hold. `blocked_ms == 0` is the rule switched off, and reports nothing.
|
||||||
|
let ms = agent.network.ms;
|
||||||
|
let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms)
|
||||||
|
.then(|| agent.decoder.current().map(str::to_string))
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let before = agent.decoder.export_state();
|
||||||
|
let result = agent.tick_blocked(&frame, &options, blocked.as_deref()).expect("a tick");
|
||||||
|
let after = agent.decoder.export_state();
|
||||||
|
let held = agent.decoder.current().map(str::to_string);
|
||||||
|
if held != held_channel {
|
||||||
|
held_channel = held;
|
||||||
|
blocked_since_ms = ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
if after.next_decision != before.next_decision {
|
||||||
|
trace.decisions += 1;
|
||||||
|
let winner = after.current.clone().expect("a decision names a winner");
|
||||||
|
// The raw argmax, recomputed from the decoder's own inputs: the rates the decode saw
|
||||||
|
// (`tick` decodes on the post-step rates and nothing changes them afterwards), the
|
||||||
|
// calibrated baseline, and the fatigue as it stood *before* the decision. Comparing it
|
||||||
|
// with the winner is what "the incumbent won by hysteresis" means.
|
||||||
|
let adjusted = |channel: &str| {
|
||||||
|
let index =
|
||||||
|
DIRECTIONS.iter().position(|name| *name == channel).expect("a direction");
|
||||||
|
let role = ROLES[index];
|
||||||
|
let rate = agent.network.rates.get_or_zero(role);
|
||||||
|
let base = after.baseline.get_or_zero(role);
|
||||||
|
(rate + 1.0) / (base + 1.0) / (1.0 + before.fatigue.get_or_zero(channel))
|
||||||
|
};
|
||||||
|
let mut argmax = DIRECTIONS[0];
|
||||||
|
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);
|
||||||
|
|
@ -852,15 +876,40 @@ fn brain_trace(
|
||||||
*trace.exit_tile_frames.entry(tile).or_insert(0) += 1;
|
*trace.exit_tile_frames.entry(tile).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The ratchet, with the adapter's own policy and the checkpoint's own budget.
|
// Step 10: the ratchet, with the adapter's own policy and the checkpoint's own budget.
|
||||||
let progress = transition.evaluated.progress;
|
let progress = adapter.progress();
|
||||||
let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
|
let safe = adapter.safe_for_snapshot();
|
||||||
if boundary.rollback.is_some() {
|
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best;
|
||||||
escape.trace.recoveries += 1;
|
let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot {
|
||||||
|
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;
|
||||||
escape.hold_start = (frame.location, ms);
|
hold_start = (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) {
|
||||||
|
|
@ -876,7 +925,6 @@ 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,12 +189,11 @@ 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_step({from:?}, {:#04x})` = {:?}, neighbours {:?}",
|
"- `next_hop({:?}, {:#04x})` = {:?}, neighbours {:?}",
|
||||||
|
geography::region_at(player.map, player.y),
|
||||||
objective.map,
|
objective.map,
|
||||||
geography::next_step(from, objective.map),
|
geography::next_hop(geography::region_at(player.map, player.y), objective.map),
|
||||||
geography::neighbours(player.map)
|
geography::neighbours(player.map)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -809,8 +808,6 @@ 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!("```");
|
||||||
|
|
@ -826,9 +823,18 @@ 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));
|
||||||
legacy.execute(Some(&mut *layer), &active, 0, *ms, gb, &*adapter);
|
let mask = {
|
||||||
|
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;
|
||||||
legacy.stub_advance(Some(&mut *layer), gb, adapter, *ms).expect("a frame should complete");
|
adapter.sample(gb, *ms);
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
@ -1347,12 +1353,7 @@ 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(
|
fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) {
|
||||||
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;
|
||||||
|
|
@ -1386,13 +1387,6 @@ fn route_survey(
|
||||||
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.
|
||||||
|
|
@ -1440,9 +1434,6 @@ fn route_survey(
|
||||||
let pad = format!("{:?} {names:?}", observed.scene);
|
let pad = format!("{:?} {names:?}", observed.scene);
|
||||||
if pad != last_pad {
|
if pad != last_pad {
|
||||||
println!("f{frame:<6} {:?} pad {pad}", player.map(|p| (p.map, p.x, p.y)));
|
println!("f{frame:<6} {:?} pad {pad}", player.map(|p| (p.map, p.x, p.y)));
|
||||||
if let Some(line) = battle_line(gb) {
|
|
||||||
println!(" {line}");
|
|
||||||
}
|
|
||||||
last_pad = pad;
|
last_pad = pad;
|
||||||
}
|
}
|
||||||
let mut mask = 0u8;
|
let mut mask = 0u8;
|
||||||
|
|
@ -1521,40 +1512,10 @@ fn route_survey(
|
||||||
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;
|
||||||
|
|
@ -1593,8 +1554,6 @@ fn route_survey(
|
||||||
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:?}")
|
||||||
}
|
}
|
||||||
|
|
@ -1648,9 +1607,7 @@ fn route_survey(
|
||||||
// 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");
|
||||||
// `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;
|
||||||
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))
|
||||||
|
|
@ -1720,28 +1677,6 @@ 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.");
|
||||||
|
|
@ -1833,7 +1768,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, &checkpoint);
|
route_survey(&mut gb, &mut adapter, &mut ms);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1863,8 +1798,6 @@ 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()]);
|
||||||
|
|
@ -1874,11 +1807,18 @@ 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));
|
||||||
legacy.execute(Some(&mut layer), &active, 0, ms, &mut gb, &adapter);
|
let mask = {
|
||||||
|
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;
|
||||||
legacy
|
adapter.sample(&mut gb, ms);
|
||||||
.stub_advance(Some(&mut layer), &mut gb, &mut adapter, ms)
|
{
|
||||||
.expect("a frame should complete");
|
let ledger = AdapterLedger(&adapter);
|
||||||
|
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
|
||||||
|
|
@ -1946,10 +1886,19 @@ 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 bytes = save_state(&checkpoint, &mut gb, &adapter, frame, &save);
|
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(&save), &bytes)
|
||||||
|
.expect("the checkpoint should be writable");
|
||||||
println!(
|
println!(
|
||||||
"\nWrote this state to `{}` ({bytes} bytes).",
|
"\nWrote this state to `{}` ({} 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
|
||||||
|
|
@ -2115,36 +2064,3 @@ fn main() {
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row 60: the battle bytes a `MOVE n` button's effect rests on, in one line -- the fly's moves
|
|
||||||
/// with PP and whether the cartridge would answer each with nothing, both sides' stat stages
|
|
||||||
/// (7 is normal, 1 is -6), the enemy's stats, status and HP.
|
|
||||||
fn battle_line(gb: &mut Emulator) -> Option<String> {
|
|
||||||
let battle = state::battle(gb)?;
|
|
||||||
let own = battle.own?;
|
|
||||||
let moves: Vec<String> = own
|
|
||||||
.moves
|
|
||||||
.iter()
|
|
||||||
.flatten()
|
|
||||||
.map(|entry| {
|
|
||||||
let nothing = state::move_without_effect(gb, entry.id);
|
|
||||||
let row = state::move_data(gb, entry.id).map(|data| (data.effect, data.power));
|
|
||||||
format!("{:#04x} pp{} row{row:?} nothing={nothing:?}", entry.id, entry.pp)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let stages = |gb: &mut Emulator, base: u16| -> Vec<u8> { (0..6).map(|i| gb.read8(base + i)).collect() };
|
|
||||||
let own_stages = stages(gb, ram::wPlayerMonStatMods);
|
|
||||||
let enemy_stages = stages(gb, ram::wEnemyMonStatMods);
|
|
||||||
let enemy_stats: Vec<u16> = (0..4)
|
|
||||||
.map(|i| u16::from(gb.read8(ram::wEnemyMonAttack + 2 * i)) * 256 + u16::from(gb.read8(ram::wEnemyMonAttack + 2 * i + 1)))
|
|
||||||
.collect();
|
|
||||||
Some(format!(
|
|
||||||
"menu={:?} own hp {}/{} moves [{}] stages {own_stages:?} | enemy {:?} stages {enemy_stages:?} stats {enemy_stats:?} status {:#04x}",
|
|
||||||
battle.menu,
|
|
||||||
own.hp,
|
|
||||||
own.max_hp,
|
|
||||||
moves.join(", "),
|
|
||||||
battle.enemy.map(|enemy| (enemy.species, enemy.level, enemy.hp, enemy.max_hp)),
|
|
||||||
gb.read8(ram::wEnemyMonStatus),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,9 @@
|
||||||
//! | `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 is `flysim::frame::LegacyFrame`, the one the stream runs, restored the way the stream
|
//! The frame order is `simloop.rs`'s, as `examples/palette_bench.rs` expresses it, so what this
|
||||||
//! restores it, so what this measures is the loop that ships rather than a second implementation
|
//! measures is the loop that ships rather than a second implementation of it. Without a
|
||||||
//! 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.
|
||||||
|
|
||||||
|
|
@ -48,19 +46,18 @@ 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};
|
use flybrain_core::agent::{AgentConfig, NeuralAgent, RewardEvent as NeuralReward, TickOptions};
|
||||||
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;
|
use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask};
|
||||||
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;
|
||||||
|
|
@ -157,6 +154,28 @@ 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)>);
|
||||||
|
|
@ -228,12 +247,6 @@ struct Trace {
|
||||||
/// The pad's composition, measured rather than read off the table: "which sub-state offers
|
/// The pad's composition, measured rather than read off the table: "which sub-state offers
|
||||||
/// `BACK`" is a question about the build under test and not about the document.
|
/// `BACK`" is a question about the build under test and not about the document.
|
||||||
battle_pads: BTreeMap<&'static str, BTreeSet<String>>,
|
battle_pads: BTreeMap<&'static str, BTreeSet<String>>,
|
||||||
/// Row 60: every battle as `(frames, macro starts, won)`, the one running, payouts by kind,
|
|
||||||
/// and `MOVE n` starts against how many chose a move the cartridge answers with nothing.
|
|
||||||
battles: Vec<(u64, u64, bool)>,
|
|
||||||
battle_now: Option<(u64, u64, bool)>,
|
|
||||||
payouts_by_kind: BTreeMap<&'static str, (u64, f64)>,
|
|
||||||
move_starts: (u64, u64),
|
|
||||||
wall_seconds: f64,
|
wall_seconds: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,161 +306,6 @@ 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(
|
||||||
|
|
@ -461,6 +319,8 @@ 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();
|
||||||
|
|
@ -473,28 +333,34 @@ 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"));
|
||||||
}
|
}
|
||||||
// The stream's own restore, into the stream's own frame: a fresh process's readout transient
|
agent.import_state(&checkpoint.agent).expect("the checkpoint's agent should import");
|
||||||
// (`restore: legacy-transient-reset`), which is what the fly has after the service restarts.
|
let (width, height) = (agent.frame.width, agent.frame.height);
|
||||||
let mut frame = LegacyFrame::new()
|
agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height);
|
||||||
.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();
|
||||||
|
|
@ -519,23 +385,21 @@ 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 rank = adapter.progress().rank;
|
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 running: Option<Running> = None;
|
||||||
// A periodic one-liner for a run that is going nowhere: what the fly is standing on, what it
|
// A periodic one-liner for a run that is going nowhere: what the fly is standing on, what it
|
||||||
// faces, and which text box the detector is looking at. Off unless asked for, because it is a
|
// faces, and which text box the detector is looking at. Off unless asked for, because it is a
|
||||||
// diagnostic and the tables above are the report.
|
// diagnostic and the tables above are the report.
|
||||||
let trace_every_ms = env_f64("FLY_TRAP_TRACE_SECONDS", 0.0) * 1000.0;
|
let trace_every_ms = env_f64("FLY_TRAP_TRACE_SECONDS", 0.0) * 1000.0;
|
||||||
let mut next_trace = began_ms;
|
let mut next_trace = began_ms;
|
||||||
let mut hunt = Hunt {
|
|
||||||
stub,
|
|
||||||
stub_hold: 0,
|
|
||||||
stub_next_ms: f64::NEG_INFINITY,
|
|
||||||
hold_ms,
|
|
||||||
running: None,
|
|
||||||
// The scene of the frames in a row, for "stuck in a text box" against "in and out of one".
|
// The scene of the frames in a row, for "stuck in a text box" against "in and out of one".
|
||||||
scene_run: ("", 0, began_ms),
|
let mut scene_run: (&'static str, u64, f64) = ("", 0, began_ms);
|
||||||
dialog_map: None,
|
let mut trace = Trace {
|
||||||
battle_sub: None,
|
|
||||||
trace: Trace {
|
|
||||||
steps: Vec::new(),
|
steps: Vec::new(),
|
||||||
starts: Vec::new(),
|
starts: Vec::new(),
|
||||||
episodes: Vec::new(),
|
episodes: Vec::new(),
|
||||||
|
|
@ -559,73 +423,184 @@ fn run(
|
||||||
battle_frames: BTreeMap::new(),
|
battle_frames: BTreeMap::new(),
|
||||||
battle_starts: BTreeMap::new(),
|
battle_starts: BTreeMap::new(),
|
||||||
battle_pads: BTreeMap::new(),
|
battle_pads: BTreeMap::new(),
|
||||||
battles: Vec::new(),
|
|
||||||
battle_now: None,
|
|
||||||
payouts_by_kind: BTreeMap::new(),
|
|
||||||
move_starts: (0, 0),
|
|
||||||
wall_seconds: 0.0,
|
wall_seconds: 0.0,
|
||||||
seeded: seeded_note,
|
seeded: seeded_note,
|
||||||
refusals: BTreeMap::new(),
|
refusals: BTreeMap::new(),
|
||||||
refusal_run: (None, 0),
|
refusal_run: (None, 0),
|
||||||
longest_refusal_run: (0, ""),
|
longest_refusal_run: (0, ""),
|
||||||
},
|
|
||||||
};
|
};
|
||||||
let mut rank = rank;
|
|
||||||
|
|
||||||
// One observation before the first frame, as the sim loop takes after a restore.
|
|
||||||
if let Some(layer) = macros.as_mut() {
|
if let Some(layer) = macros.as_mut() {
|
||||||
let ledger = AdapterLedger(&adapter);
|
let ledger = AdapterLedger(&adapter);
|
||||||
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
|
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
while agent.network.ms < until {
|
while agent.network.ms < until {
|
||||||
let mut parts = Parts {
|
let rewards: Vec<NeuralReward> = payouts
|
||||||
agent: &mut agent,
|
.iter()
|
||||||
emulator: &mut emulator,
|
.map(|event| {
|
||||||
adapter: &mut adapter,
|
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms))
|
||||||
ratchet: &mut ratchet,
|
})
|
||||||
macros: macros.as_mut(),
|
.collect();
|
||||||
|
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 transition = frame.transition(&mut parts, &mut hunt).expect("a frame");
|
let held = agent.decoder.current().map(str::to_string);
|
||||||
let ms = transition.ms;
|
if held != held_channel {
|
||||||
hunt.trace.frames += 1;
|
held_channel = held;
|
||||||
for payout in &transition.evaluated.rewards {
|
blocked_since_ms = ms;
|
||||||
let entry = hunt.trace.payouts_by_kind.entry(payout.kind).or_insert((0, 0.0));
|
}
|
||||||
*entry = (entry.0 + 1, entry.1 + payout.value);
|
|
||||||
|
let ms = agent.network.ms;
|
||||||
|
let mut mask = to_button_mask(&active);
|
||||||
|
// Read before `decide`, because `decide` is what starts the macro whose scene this is.
|
||||||
|
let layer_scene = macros.as_ref().map_or("", MacroLayer::scene_name);
|
||||||
|
let dialog_map = (layer_scene == "dialog" || layer_scene == "unknown")
|
||||||
|
.then(|| flybrain_gb::pokemon_red::state::player(&mut emulator).map(|p| p.map))
|
||||||
|
.flatten();
|
||||||
|
let battle_sub = battle_sub_state(&mut emulator);
|
||||||
|
if let Some(sub) = battle_sub {
|
||||||
|
*trace.battle_frames.entry(sub).or_insert(0) += 1;
|
||||||
|
let pad = trace.battle_pads.entry(sub).or_default();
|
||||||
|
for channel in bound.as_deref().unwrap_or_default() {
|
||||||
|
pad.insert(channel.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(layer) = macros.as_mut() {
|
||||||
|
let ledger = AdapterLedger(&adapter);
|
||||||
|
let decision = layer.decide(&active, mask, ms, &mut emulator, &ledger);
|
||||||
|
mask = decision.mask;
|
||||||
|
for event in &decision.events {
|
||||||
|
match event.outcome {
|
||||||
|
None => {
|
||||||
|
trace.starts.push((ms, event.name));
|
||||||
|
// Which press answered a box, and on which map: 991 `YES` in twenty brain
|
||||||
|
// minutes is a fact about one conversation, and this is what says which.
|
||||||
|
if let Some(map) = dialog_map {
|
||||||
|
*trace.dialog_macros.entry((event.name, map)).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
if let Some(sub) = battle_sub {
|
||||||
|
*trace.battle_starts.entry((event.name, sub)).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
running = Some(Running {
|
||||||
|
name: event.name,
|
||||||
|
from: location,
|
||||||
|
tiles: location.into_iter().collect(),
|
||||||
|
frames: 0,
|
||||||
|
reach: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(outcome) => {
|
||||||
|
*trace.outcomes.entry(outcome.as_str()).or_insert(0) += 1;
|
||||||
|
if outcome.as_str() == "refused" {
|
||||||
|
*trace.refusals.entry(event.name).or_insert(0) += 1;
|
||||||
|
let key = Some((event.name, location));
|
||||||
|
trace.refusal_run = if trace.refusal_run.0 == key {
|
||||||
|
(key, trace.refusal_run.1 + 1)
|
||||||
|
} else {
|
||||||
|
(key, 1)
|
||||||
|
};
|
||||||
|
if trace.refusal_run.1 > trace.longest_refusal_run.0 {
|
||||||
|
trace.longest_refusal_run = (trace.refusal_run.1, event.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(run) = running.take() {
|
||||||
|
let net = match (run.from, location) {
|
||||||
|
(Some((map, x, y)), Some((at, ax, ay))) if map == at => {
|
||||||
|
ax.abs_diff(x) + ay.abs_diff(y)
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
trace.episodes.push(Episode {
|
||||||
|
name: run.name,
|
||||||
|
outcome: outcome.as_str(),
|
||||||
|
frames: run.frames,
|
||||||
|
tiles: run.tiles.len(),
|
||||||
|
net,
|
||||||
|
reach: run.reach,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
use flybrain_gb::MemoryReader;
|
let text = flybrain_gb::pokemon_red::state::text_box(&mut emulator);
|
||||||
let trace = &mut hunt.trace;
|
let (corners, border) =
|
||||||
let fighting =
|
flybrain_gb::pokemon_red::state::dialog_border(&mut emulator);
|
||||||
parts.emulator.read8(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle) != 0;
|
match (text.open, corners, border) {
|
||||||
let won = transition
|
(true, true, true) => trace.font_corners_border += 1,
|
||||||
.evaluated
|
(true, true, false) => trace.font_corners_no_border += 1,
|
||||||
.rewards
|
(true, false, _) => trace.font_no_corners += 1,
|
||||||
.iter()
|
(false, true, _) => trace.corners_no_font += 1,
|
||||||
.any(|payout| matches!(payout.kind, "battle" | "trainer"));
|
(false, false, _) => {}
|
||||||
match (fighting, trace.battle_now.as_mut()) {
|
|
||||||
(true, Some(battle)) => {
|
|
||||||
battle.0 += 1;
|
|
||||||
battle.2 |= won;
|
|
||||||
}
|
}
|
||||||
(true, None) => trace.battle_now = Some((1, 0, won)),
|
|
||||||
(false, Some(_)) => {
|
|
||||||
let mut battle = trace.battle_now.take().expect("a battle");
|
|
||||||
battle.2 |= won;
|
|
||||||
trace.battles.push(battle);
|
|
||||||
}
|
}
|
||||||
(false, None) => {}
|
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);
|
||||||
|
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 = parts.macros.as_deref().map_or("", MacroLayer::scene_name);
|
let scene = macros.as_ref().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(parts.emulator).err();
|
let refusal = flybrain_gb::pokemon_red::state::map_grid(&mut emulator).err();
|
||||||
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(parts.emulator);
|
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut 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| {
|
||||||
|
|
@ -633,18 +608,22 @@ 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(parts.emulator);
|
let why = flybrain_gb::pokemon_red::scene::why_unknown(&mut 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 location = frame.location;
|
let now = adapter.location();
|
||||||
if let Some((map, x, y)) = location {
|
if now.is_some() && now != location {
|
||||||
hunt.trace.steps.push((ms, map, x, y));
|
location = now;
|
||||||
|
blocked_since_ms = ms;
|
||||||
}
|
}
|
||||||
if let Some(run) = hunt.running.as_mut() {
|
if let Some((map, x, y)) = location {
|
||||||
|
trace.steps.push((ms, map, x, y));
|
||||||
|
}
|
||||||
|
if let Some(run) = 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);
|
||||||
|
|
@ -656,18 +635,47 @@ fn run(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let progress = transition.evaluated.progress;
|
let progress = adapter.progress();
|
||||||
if progress.rank != rank {
|
if progress.rank != rank {
|
||||||
rank = progress.rank;
|
rank = progress.rank;
|
||||||
hunt.trace.rungs.push((rank, progress.rank_label, ms - began_ms));
|
trace.rungs.push((rank, progress.rank_label, ms - began_ms));
|
||||||
}
|
}
|
||||||
let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
|
let safe = adapter.safe_for_snapshot();
|
||||||
if boundary.rollback.is_some() {
|
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best;
|
||||||
hunt.trace.recoveries += 1;
|
let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot {
|
||||||
hunt.running = None;
|
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);
|
||||||
|
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(),
|
||||||
|
|
@ -979,7 +987,6 @@ fn main() {
|
||||||
println!("| {name} | {sub} | {n} |");
|
println!("| {name} | {sub} | {n} |");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
battle_report(&trace);
|
|
||||||
println!("\n| scene | frames | longest run | run began (brain min) |");
|
println!("\n| scene | frames | longest run | run began (brain min) |");
|
||||||
println!("| --- | ---: | ---: | ---: |");
|
println!("| --- | ---: | ---: | ---: |");
|
||||||
for (scene, frames) in &trace.scenes {
|
for (scene, frames) in &trace.scenes {
|
||||||
|
|
@ -1010,43 +1017,3 @@ fn main() {
|
||||||
WINDOW_MS / 1000.0
|
WINDOW_MS / 1000.0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Row 60's numbers: payouts by kind, every battle's length and whether it paid a win, and the
|
|
||||||
/// `MOVE n` starts that chose a move the cartridge answers with nothing.
|
|
||||||
fn battle_report(trace: &Trace) {
|
|
||||||
println!("\n| payout kind | n | total |");
|
|
||||||
println!("| --- | ---: | ---: |");
|
|
||||||
for (kind, (n, total)) in &trace.payouts_by_kind {
|
|
||||||
println!("| {kind} | {n} | {total:.2} |");
|
|
||||||
}
|
|
||||||
let mut lengths: Vec<u64> = trace.battles.iter().map(|battle| battle.0).collect();
|
|
||||||
lengths.sort_unstable();
|
|
||||||
let at = |q: f64| -> u64 {
|
|
||||||
if lengths.is_empty() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
lengths[((lengths.len() - 1) as f64 * q).round() as usize]
|
|
||||||
};
|
|
||||||
println!("\n| battles | n |");
|
|
||||||
println!("| --- | ---: |");
|
|
||||||
println!("| ended | {} |", trace.battles.len());
|
|
||||||
println!("| won (a battle or trainer payout) | {} |", trace.battles.iter().filter(|b| b.2).count());
|
|
||||||
println!(
|
|
||||||
"| still running at the end | {} |",
|
|
||||||
trace.battle_now.map_or("no".to_string(), |b| format!("{} frames, {} macros", b.0, b.1))
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"| frames, median / p90 / max | {} / {} / {} |",
|
|
||||||
at(0.5),
|
|
||||||
at(0.9),
|
|
||||||
lengths.last().copied().unwrap_or(0)
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"| macros per battle, max | {} |",
|
|
||||||
trace.battles.iter().map(|b| b.1).max().unwrap_or(0)
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"| `MOVE n` starts / on a move without effect | {} / {} |",
|
|
||||||
trace.move_starts.0, trace.move_starts.1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,705 +0,0 @@
|
||||||
//! 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
//! 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,8 +27,6 @@ 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;
|
||||||
|
|
@ -39,7 +37,6 @@ 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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,7 @@ mod tests {
|
||||||
wall_ms: 1_700_000_000_000,
|
wall_ms: 1_700_000_000_000,
|
||||||
rom_sha256: "ab".repeat(32),
|
rom_sha256: "ab".repeat(32),
|
||||||
emulator_frame: 12_345,
|
emulator_frame: 12_345,
|
||||||
compatibility: "kernel/pokered-unique8-v7/fingerprint".to_string(),
|
compatibility: "kernel/pokered-unique8-v6/fingerprint".to_string(),
|
||||||
speed: 1.0,
|
speed: 1.0,
|
||||||
buttons: 0,
|
buttons: 0,
|
||||||
rank_since_ms: 4_242.0,
|
rank_since_ms: 4_242.0,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@
|
||||||
//!
|
//!
|
||||||
//! 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. It lives in `crate::frame::LegacyFrame`, the one copy every
|
//! inside the same frame it produced:
|
||||||
//! 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)
|
||||||
|
|
@ -31,13 +30,14 @@ 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;
|
use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask};
|
||||||
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};
|
use flybrain_gb::emulator::{DEFAULT_AUDIO_FRAMES, Emulator, FRAMEBUFFER_LEN};
|
||||||
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,8 +45,6 @@ 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};
|
||||||
|
|
@ -58,7 +56,6 @@ 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)]
|
||||||
|
|
@ -276,26 +273,25 @@ pub fn booting_snapshot(seq: u64, wall_ms: u64, mode: MacroMode) -> Snapshot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stream's only look inside the frame: the per-phase profile (`crate::profile`).
|
/// The neural half of a ratchet recovery, wired to `flybrain-core`.
|
||||||
struct Laps<'a> {
|
struct AgentRecovery<'a> {
|
||||||
profiler: &'a mut Profiler,
|
agent: &'a mut NeuralAgent,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FrameObserver for Laps<'_> {
|
impl NeuralRecovery for AgentRecovery<'_> {
|
||||||
fn after(&mut self, phase: FramePhase, agent: &mut NeuralAgent) {
|
fn clear_decoder_holds(&mut self) {
|
||||||
match phase {
|
let ms = self.agent.network.ms;
|
||||||
FramePhase::Ticked => {
|
self.agent.decoder.clear_holds(ms);
|
||||||
self.profiler.lap(Phase::Step);
|
|
||||||
if self.profiler.enabled() {
|
|
||||||
self.profiler.absorb_brain(agent.network.timings());
|
|
||||||
agent.network.reset_timings();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clear_eligibility(&mut self) {
|
||||||
|
let ms = self.agent.network.ms;
|
||||||
|
self.agent.network.plasticity.clear_eligibility(ms);
|
||||||
}
|
}
|
||||||
FramePhase::Executed => self.profiler.lap(Phase::Decode),
|
|
||||||
FramePhase::Emulated => self.profiler.lap(Phase::Emulate),
|
fn set_visual_frame(&mut self, frame: &[u8]) {
|
||||||
FramePhase::Advanced => self.profiler.lap(Phase::Retina),
|
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
|
||||||
FramePhase::Committed => self.profiler.lap(Phase::Rewards),
|
self.agent.network.set_visual_frame(frame, width, height);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,11 +336,24 @@ pub struct Sim {
|
||||||
next_generation: u64,
|
next_generation: u64,
|
||||||
best_archived_rank: Option<u32>,
|
best_archived_rank: Option<u32>,
|
||||||
|
|
||||||
/// The frame order and its state: the remainder, the frame counter, the frame on screen, the
|
/// Fractional millisecond carried into the next frame, exactly as `NeuralAgent` keeps it.
|
||||||
/// mask and the readout's blocked-direction window (`crate::frame`).
|
remainder: f64,
|
||||||
frame: LegacyFrame,
|
frame_counter: u64,
|
||||||
|
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
|
||||||
|
|
@ -376,9 +385,6 @@ 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,
|
||||||
|
|
@ -560,14 +566,16 @@ impl Sim {
|
||||||
writer_thread: None,
|
writer_thread: None,
|
||||||
next_generation: 1,
|
next_generation: 1,
|
||||||
best_archived_rank: None,
|
best_archived_rank: None,
|
||||||
// The per-frame trace, off unless `FLY_TRACE` names a file (`crate::trace`).
|
remainder: 0.0,
|
||||||
frame: LegacyFrame::new().with_trace(
|
frame_counter: 0,
|
||||||
FrameTrace::from_env()
|
frame_buffer: vec![0u8; FRAMEBUFFER_LEN],
|
||||||
.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,
|
||||||
|
|
@ -581,7 +589,6 @@ 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 {
|
||||||
|
|
@ -679,7 +686,7 @@ impl Sim {
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
origin = %candidate.origin,
|
origin = %candidate.origin,
|
||||||
frame = self.frame.frame_counter,
|
frame = self.frame_counter,
|
||||||
brain_ms = self.agent.network.ms,
|
brain_ms = self.agent.network.ms,
|
||||||
rank = self.rank,
|
rank = self.rank,
|
||||||
"restored"
|
"restored"
|
||||||
|
|
@ -742,21 +749,46 @@ 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
|
||||||
let mut parts = Parts {
|
.import_state(&checkpoint.agent)
|
||||||
agent: &mut self.agent,
|
.map_err(|error| anyhow!("{error}"))?;
|
||||||
emulator: &mut self.emulator,
|
self.emulator
|
||||||
adapter: self.adapter.as_mut(),
|
.import_state(&runtime.emulator)
|
||||||
ratchet: &mut self.ratchet,
|
.map_err(|error| anyhow!("{error}"))?;
|
||||||
macros: self.macros.as_mut(),
|
if !runtime.reward.is_null() {
|
||||||
};
|
self.adapter
|
||||||
self.frame.restore(&mut parts, &checkpoint)?;
|
.import_state(&runtime.reward)
|
||||||
|
.map_err(|error| anyhow!("{error}"))?;
|
||||||
}
|
}
|
||||||
|
let snapshot = if runtime.ratchet_game.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(flybrain_gb::ratchet::Snapshot {
|
||||||
|
game: runtime.ratchet_game.clone(),
|
||||||
|
frame: runtime.ratchet_frame.clone(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
self.ratchet
|
||||||
|
.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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -764,8 +796,16 @@ 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<()> {
|
||||||
let raw = self.frame.initialize(&mut self.emulator, &mut self.agent)?;
|
self.emulator
|
||||||
|
.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(())
|
||||||
}
|
}
|
||||||
|
|
@ -887,27 +927,117 @@ 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 (`crate::frame`).
|
/// One frame, in the prototype's order.
|
||||||
fn step_frame(&mut self) -> Result<()> {
|
fn step_frame(&mut self) -> Result<()> {
|
||||||
// Prepare through commit: ticks, decode, the executor's mask, one emulator frame, rewards,
|
// 2. Step the brain: 16 or 17 integer ticks, the remainder carried and checkpointed.
|
||||||
// the scene and the location, then the stimulations and the reinforcement.
|
self.remainder += self.agent.ms_per_frame;
|
||||||
let transition = {
|
let steps = self.remainder.floor();
|
||||||
let mut parts = Parts {
|
self.remainder -= steps;
|
||||||
agent: &mut self.agent,
|
self.agent.network.step(steps as u64);
|
||||||
emulator: &mut self.emulator,
|
self.profiler.lap(Phase::Step);
|
||||||
adapter: self.adapter.as_mut(),
|
if self.profiler.enabled() {
|
||||||
ratchet: &mut self.ratchet,
|
self.profiler.absorb_brain(self.agent.network.timings());
|
||||||
macros: self.macros.as_mut(),
|
self.agent.network.reset_timings();
|
||||||
};
|
}
|
||||||
let mut laps = Laps { profiler: &mut self.profiler };
|
|
||||||
self.frame.transition(&mut parts, &mut laps)?
|
// 3. Decode, 4. apply the buttons.
|
||||||
|
// 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.dc_blocker.process_into(&transition.audio, &mut self.pending_audio);
|
self.profiler.lap(Phase::Emulate);
|
||||||
// The feed events, in the order the phases produced them: the executor's starts and
|
self.frame_buffer.copy_from_slice(self.emulator.framebuffer());
|
||||||
// finishes, the rewards, then the observation's abandonment.
|
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
|
||||||
self.emit_macro_events(&transition.executed.events);
|
self.agent
|
||||||
for event in &transition.evaluated.rewards {
|
.network
|
||||||
|
.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);
|
||||||
|
|
@ -916,27 +1046,68 @@ impl Sim {
|
||||||
}
|
}
|
||||||
self.emit(new);
|
self.emit(new);
|
||||||
}
|
}
|
||||||
self.emit_macro_events(&transition.evaluated.abandoned);
|
|
||||||
|
|
||||||
// The milestone archive sits here, after the commit and before the ratchet captures,
|
self.profiler.lap(Phase::Rewards);
|
||||||
// which is the legacy order legacy-gameboy-v1 section 4 declares.
|
|
||||||
let ms = transition.ms;
|
// `docs/design/macros.md` section 2: the scene is sampled once per game frame, after the
|
||||||
let progress = transition.evaluated.progress;
|
// frame. So the palette the fly is offered on the next frame is the one for the frame it
|
||||||
|
// 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();
|
||||||
// `Ready(k+1)`: the ratchet captures, observes, and rolls the game back if it says so.
|
let capture_due = safe && u64::from(progress.rank) > self.ratchet.state.best;
|
||||||
let boundary = {
|
let captured = if capture_due {
|
||||||
let mut parts = Parts {
|
Some(flybrain_gb::ratchet::Snapshot {
|
||||||
agent: &mut self.agent,
|
game: self
|
||||||
emulator: &mut self.emulator,
|
.emulator
|
||||||
adapter: self.adapter.as_mut(),
|
.export_state()
|
||||||
ratchet: &mut self.ratchet,
|
.map_err(|error| anyhow!("capturing a ratchet snapshot: {error}"))?,
|
||||||
macros: self.macros.as_mut(),
|
frame: self.frame_buffer.clone(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
};
|
};
|
||||||
self.frame.boundary(&mut parts, &progress, ms)?
|
// The stall window's second progress signal (`docs/design/ladder.md`, the 2026-09-17
|
||||||
};
|
// rule as amended 2026-09-22): coverage is ground never stood on, and a fly crossing a
|
||||||
if let Some(rollback) = boundary.rollback {
|
// town it has already covered to reach the rung's own door earns none of it while it is
|
||||||
self.recovered(rollback.trigger, &rollback.events);
|
// 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(())
|
||||||
|
|
@ -969,14 +1140,32 @@ impl Sim {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The host's half of a rollback the frame has already applied: the ticker, the metric, the
|
fn recover(&mut self, reason: &str) -> Result<()> {
|
||||||
/// `recovering` status and a durable checkpoint.
|
let snapshot = flybrain_gb::ratchet::Snapshot {
|
||||||
fn recovered(&mut self, trigger: RollbackTrigger, events: &[crate::macros::MacroEvent]) {
|
game: self
|
||||||
// Two triggers, two stories on the ticker: a game over ended the run, a stall did not.
|
.ratchet
|
||||||
let reason = match trigger {
|
.game()
|
||||||
RollbackTrigger::GameOver => "Game over",
|
.ok_or_else(|| anyhow!("the ratchet asked to recover with no snapshot"))?
|
||||||
RollbackTrigger::Stall => "Stuck",
|
.to_vec(),
|
||||||
|
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(
|
||||||
|
|
@ -989,11 +1178,31 @@ impl Sim {
|
||||||
)
|
)
|
||||||
.value(f64::from(self.rank)),
|
.value(f64::from(self.rank)),
|
||||||
);
|
);
|
||||||
self.emit_macro_events(events);
|
self.location = self.adapter.location();
|
||||||
|
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 ------------------------------------------------------------------------
|
||||||
|
|
@ -1083,9 +1292,6 @@ 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 {
|
||||||
|
|
@ -1098,15 +1304,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -1116,25 +1313,13 @@ 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");
|
||||||
let event = self.emit(
|
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),
|
||||||
);
|
)
|
||||||
self.journal.record(&crate::journal::Entry {
|
.id
|
||||||
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.
|
||||||
|
|
@ -1205,7 +1390,6 @@ 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");
|
||||||
}
|
}
|
||||||
|
|
@ -1315,10 +1499,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -1367,15 +1547,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.frame.remainder;
|
agent_state.remainder = self.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.frame_counter,
|
emulator_frame: self.frame_counter,
|
||||||
compatibility: self.compatibility.clone(),
|
compatibility: self.compatibility.clone(),
|
||||||
speed: self.shared.config.loop_.speed,
|
speed: self.shared.config.loop_.speed,
|
||||||
buttons: self.frame.buttons,
|
buttons: self.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(),
|
||||||
|
|
@ -1384,7 +1564,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.frame_buffer.clone(),
|
framebuffer: self.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(),
|
||||||
};
|
};
|
||||||
|
|
@ -1457,7 +1637,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.frame_buffer.clone()),
|
Arc::new(self.frame_buffer.clone()),
|
||||||
Arc::new(audio),
|
Arc::new(audio),
|
||||||
Arc::new(bitset),
|
Arc::new(bitset),
|
||||||
count,
|
count,
|
||||||
|
|
@ -1483,8 +1663,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.frame_counter,
|
frame: self.frame_counter,
|
||||||
buttons: self.frame.buttons & 0xff,
|
buttons: self.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,
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@ pub struct FeedMacroOutcome {
|
||||||
|
|
||||||
/// Reward categories the feed reports counts for. The adapter's own interned kinds
|
/// Reward categories the feed reports counts for. The adapter's own interned kinds
|
||||||
/// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`,
|
/// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`,
|
||||||
/// `catch`, `talk`, `item`) map onto these.
|
/// `catch`) map onto these.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum RewardKind {
|
pub enum RewardKind {
|
||||||
|
|
@ -291,15 +291,6 @@ impl RewardKind {
|
||||||
// for it on the same frame, so the `pokedex` counter already moves. Mapping `catch`
|
// for it on the same frame, so the `pokedex` counter already moves. Mapping `catch`
|
||||||
// there as well would count one event twice.
|
// there as well would count one event twice.
|
||||||
"catch" => Self::Wildwin,
|
"catch" => Self::Wildwin,
|
||||||
// `talk` and `item` (the operator, 2026-09-23) are the fly finding what is in a place:
|
|
||||||
// a person or a sign it opened, an item it picked up. That is the family `explore`
|
|
||||||
// already counts -- new ground, a door found -- at the same quiet scale (0.05 to
|
|
||||||
// 0.15), so both publish there and the feed's closed kind set does not move. Not
|
|
||||||
// `area`, which counts maps and is a notable row; not `story`, which is the plot;
|
|
||||||
// not `wildwin`, which is a battle. The Pokémon Red ticker's copy for `explore` stays
|
|
||||||
// "new place" for all four, the operator's call (`apps/stage/src/games/pokemon-red.ts`).
|
|
||||||
"talk" => Self::Explore,
|
|
||||||
"item" => Self::Explore,
|
|
||||||
// The platformer.
|
// The platformer.
|
||||||
"band" => Self::Explore,
|
"band" => Self::Explore,
|
||||||
"coin" => Self::Wildwin,
|
"coin" => Self::Wildwin,
|
||||||
|
|
@ -758,8 +749,6 @@ mod tests {
|
||||||
assert_eq!(RewardKind::from_adapter("nonsense"), None);
|
assert_eq!(RewardKind::from_adapter("nonsense"), None);
|
||||||
assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore));
|
assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore));
|
||||||
assert_eq!(RewardKind::from_adapter("catch"), Some(RewardKind::Wildwin));
|
assert_eq!(RewardKind::from_adapter("catch"), Some(RewardKind::Wildwin));
|
||||||
assert_eq!(RewardKind::from_adapter("talk"), Some(RewardKind::Explore));
|
|
||||||
assert_eq!(RewardKind::from_adapter("item"), Some(RewardKind::Explore));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,385 +0,0 @@
|
||||||
//! `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" })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,15 @@
|
||||||
//! A `v6` checkpoint restored under `v7`: accepted with the opt-in, refused without it.
|
//! A `v5` checkpoint restored under `v6`: accepted with the opt-in, refused without it.
|
||||||
//!
|
//!
|
||||||
//! The unit tests in `flybrain-gb` cover the decision function and the adapter's own state
|
//! The unit tests in `flybrain-gb` cover the decision function and the adapter's own state
|
||||||
//! migration separately. This is the two of them against one artefact: a real `FLYSIM01`
|
//! migration separately. This is the two of them against one artefact: a real `FLYSIM01`
|
||||||
//! envelope carrying a `pokered-unique8-v6` compatibility string and a `v6` reward ledger —
|
//! envelope carrying a `pokered-unique8-v5` compatibility string and a `v5` reward ledger —
|
||||||
//! written, encoded, decoded, and then put through exactly what `Sim::try_restore` puts a
|
//! written, encoded, decoded, and then put through exactly what `Sim::try_restore` puts a
|
||||||
//! candidate through, and then one sample of a game in which items were already taken.
|
//! candidate through.
|
||||||
//!
|
//!
|
||||||
//! No ROM and no dataset, deliberately. Building a `Sim` would need both, and neither is part of
|
//! No ROM and no dataset, deliberately. Building a `Sim` would need both, and neither is part of
|
||||||
//! the question: what decides a restore is the compatibility string and `import_state`, and what
|
//! the question: what decides a restore is the compatibility string and `import_state`.
|
||||||
//! decides the seed is the first sample's read of the cartridge's own item bits.
|
|
||||||
//!
|
|
||||||
//! (`v5` -> `v6`, the catch rule's migration, was this same file; `v6` no longer migrates from
|
|
||||||
//! anything, so that pair is refused now like any other.)
|
|
||||||
|
|
||||||
use flybrain_gb::GameAdapter;
|
use flybrain_gb::GameAdapter;
|
||||||
use flybrain_gb::MemoryReader;
|
|
||||||
use flybrain_gb::pokemon_red::symbols::ram;
|
|
||||||
use flybrain_gb::compatibility::{RestoreDecision, accepted_adapters, decide};
|
use flybrain_gb::compatibility::{RestoreDecision, accepted_adapters, decide};
|
||||||
use flybrain_gb::pokemon_red::PokemonRedReward;
|
use flybrain_gb::pokemon_red::PokemonRedReward;
|
||||||
use flysim::store::{self, RuntimeState};
|
use flysim::store::{self, RuntimeState};
|
||||||
|
|
@ -30,30 +24,24 @@ fn compatibility(adapter: &str) -> String {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `v6` reward ledger: `STATE_VERSION` 4, every field `v6` wrote, and **no** `talk:`,
|
/// A `v5` reward ledger: `STATE_VERSION` 4, every field `v5` wrote, and **no** `catchCounts`.
|
||||||
/// `item:`, `hidden:` or `items:seeded` key in `seen`.
|
|
||||||
///
|
///
|
||||||
/// Written out by hand rather than exported from an adapter, because an exported one would be a
|
/// Written out by hand rather than exported from an adapter, because an exported one would be a
|
||||||
/// `v7` state with keys deleted — this is the shape the release box's checkpoints really carry,
|
/// `v6` state with the counter deleted — this is the shape the release box's checkpoints really
|
||||||
/// field for field, including a `boundary:` key earned indoors (Red's staircase, map 38) that
|
/// carry, field for field.
|
||||||
/// `v7` would not have paid for and keeps anyway.
|
fn v5_reward() -> serde_json::Value {
|
||||||
fn v6_reward() -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"version": 4,
|
"version": 4,
|
||||||
"seen": [
|
"seen": ["adventure", "map:0", "early:outside", "dex:3", "boundary:0:edge:n:near"],
|
||||||
"adventure", "map:0", "early:outside", "dex:3", "boundary:0:edge:n:near",
|
|
||||||
"boundary:38:1:7:on"
|
|
||||||
],
|
|
||||||
"tiles": ["0:5:6", "0:5:7"],
|
"tiles": ["0:5:6", "0:5:7"],
|
||||||
"tileCounts": { "0": 2 },
|
"tileCounts": { "0": 2 },
|
||||||
"wildWins": { "0:112:4": 2 },
|
"wildWins": { "0:112:4": 2 },
|
||||||
"catchCounts": { "176": 1 },
|
|
||||||
"replayBlocked": [],
|
"replayBlocked": [],
|
||||||
"counts": {
|
"counts": {
|
||||||
"milestone": 2, "exploration": 0, "map": 1, "species": 1,
|
"milestone": 2, "exploration": 0, "map": 1, "species": 1,
|
||||||
"trainer": 0, "battle": 2, "badge": 0, "boundary": 2, "catch": 1
|
"trainer": 0, "battle": 2, "badge": 0, "boundary": 1
|
||||||
},
|
},
|
||||||
"total": 2.45,
|
"total": 2.05,
|
||||||
"recent": [{ "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 }],
|
"recent": [{ "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 }],
|
||||||
"last": { "species": { "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 } },
|
"last": { "species": { "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 } },
|
||||||
"initialized": true,
|
"initialized": true,
|
||||||
|
|
@ -67,7 +55,7 @@ fn v6_reward() -> serde_json::Value {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn v6_checkpoint() -> Vec<u8> {
|
fn v5_checkpoint() -> Vec<u8> {
|
||||||
use flybrain_core::decoder::DecoderState;
|
use flybrain_core::decoder::DecoderState;
|
||||||
use flybrain_core::lif::LifState;
|
use flybrain_core::lif::LifState;
|
||||||
use flybrain_core::ordered::NumberMap;
|
use flybrain_core::ordered::NumberMap;
|
||||||
|
|
@ -117,12 +105,12 @@ fn v6_checkpoint() -> Vec<u8> {
|
||||||
wall_ms: 1_790_000_000_000,
|
wall_ms: 1_790_000_000_000,
|
||||||
rom_sha256: flybrain_gb::pokemon_red::SUPPORTED_ROM.to_string(),
|
rom_sha256: flybrain_gb::pokemon_red::SUPPORTED_ROM.to_string(),
|
||||||
emulator_frame: 1_000_000,
|
emulator_frame: 1_000_000,
|
||||||
compatibility: compatibility("pokered-unique8-v6"),
|
compatibility: compatibility("pokered-unique8-v5"),
|
||||||
speed: 1.0,
|
speed: 1.0,
|
||||||
buttons: 0,
|
buttons: 0,
|
||||||
rank_since_ms: 1_000.0,
|
rank_since_ms: 1_000.0,
|
||||||
last_event_id: 4_242,
|
last_event_id: 4_242,
|
||||||
reward: v6_reward(),
|
reward: v5_reward(),
|
||||||
ratchet: flybrain_gb::RatchetState { best: 3, attempts: 1, recoveries: 4, ..Default::default() },
|
ratchet: flybrain_gb::RatchetState { best: 3, attempts: 1, recoveries: 4, ..Default::default() },
|
||||||
emulator: vec![3; 64],
|
emulator: vec![3; 64],
|
||||||
framebuffer: vec![0; 32],
|
framebuffer: vec![0; 32],
|
||||||
|
|
@ -133,13 +121,13 @@ fn v6_checkpoint() -> Vec<u8> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_v6_checkpoint_is_refused_under_v7_without_the_opt_in() {
|
fn a_v5_checkpoint_is_refused_under_v6_without_the_opt_in() {
|
||||||
let checkpoint = store::decode(&v6_checkpoint()).expect("the fixture decodes");
|
let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes");
|
||||||
let adapter = PokemonRedReward::new();
|
let adapter = PokemonRedReward::new();
|
||||||
let current = compatibility(adapter.id());
|
let current = compatibility(adapter.id());
|
||||||
assert_ne!(checkpoint.runtime.compatibility, current, "v7 is not v6");
|
assert_ne!(checkpoint.runtime.compatibility, current, "v6 is not v5");
|
||||||
|
|
||||||
for opt_in in [None, Some(""), Some("pokered-unique8-v5"), Some("some-other-adapter")] {
|
for opt_in in [None, Some(""), Some("pokered-unique8-v4"), Some("some-other-adapter")] {
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
decide(
|
decide(
|
||||||
|
|
@ -155,18 +143,9 @@ fn a_v6_checkpoint_is_refused_under_v7_without_the_opt_in() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A flat 64 KiB address space: the one thing a sample reads.
|
|
||||||
struct Wram(Vec<u8>);
|
|
||||||
|
|
||||||
impl MemoryReader for Wram {
|
|
||||||
fn read8(&mut self, address: u16) -> u8 {
|
|
||||||
self.0[address as usize]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_seeded() {
|
fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_zero() {
|
||||||
let checkpoint = store::decode(&v6_checkpoint()).expect("the fixture decodes");
|
let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes");
|
||||||
let mut adapter = PokemonRedReward::new();
|
let mut adapter = PokemonRedReward::new();
|
||||||
let current = compatibility(adapter.id());
|
let current = compatibility(adapter.id());
|
||||||
|
|
||||||
|
|
@ -175,25 +154,25 @@ fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_se
|
||||||
&checkpoint.runtime.compatibility,
|
&checkpoint.runtime.compatibility,
|
||||||
¤t,
|
¤t,
|
||||||
adapter.migrates_from(),
|
adapter.migrates_from(),
|
||||||
&accepted_adapters(Some("pokered-unique8-v6")),
|
&accepted_adapters(Some("pokered-unique8-v5")),
|
||||||
),
|
),
|
||||||
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v6".to_string() }
|
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() }
|
||||||
);
|
);
|
||||||
|
|
||||||
// The migration itself: `import_state`, exactly as `Sim::try_restore` calls it.
|
// The migration itself: `import_state`, exactly as `Sim::try_restore` calls it.
|
||||||
adapter.import_state(&checkpoint.runtime.reward).expect("a v6 ledger is a valid v7 ledger");
|
adapter.import_state(&checkpoint.runtime.reward).expect("a v5 ledger is a valid v6 ledger");
|
||||||
|
|
||||||
let after = adapter.export_state();
|
let after = adapter.export_state();
|
||||||
assert_eq!(after["counts"]["talk"], serde_json::json!(0), "no conversation was ever paid");
|
assert_eq!(after["catchCounts"], serde_json::json!({}), "the new counter starts at 0");
|
||||||
assert_eq!(after["counts"]["item"], serde_json::json!(0), "nor any item");
|
assert_eq!(after["counts"]["catch"], serde_json::json!(0));
|
||||||
|
|
||||||
// And nothing else moved: every field the v6 state carried round-trips to the same value,
|
// And nothing else moved: every field the v5 state carried round-trips to the same value,
|
||||||
// and v7 adds no field at all -- its ledgers are keys in `seen`.
|
// and the only key v6 adds is the counter.
|
||||||
//
|
//
|
||||||
// `counts` is the one field that is not byte-identical, and it is not a change of meaning:
|
// `counts` is the one field that is not byte-identical, and it is not a change of meaning:
|
||||||
// it serializes every kind in the catalog, so a v7 state lists `talk` and `item` where a v6
|
// it serializes every kind in the catalog, so a v6 state lists `catch` where a v5 state had
|
||||||
// state had nothing to list. Every kind the v6 state did carry keeps its number.
|
// nothing to list. Every kind the v5 state did carry keeps its number.
|
||||||
let before = v6_reward();
|
let before = v5_reward();
|
||||||
for (key, value) in before.as_object().unwrap() {
|
for (key, value) in before.as_object().unwrap() {
|
||||||
if key == "counts" {
|
if key == "counts" {
|
||||||
for (kind, count) in value.as_object().unwrap() {
|
for (kind, count) in value.as_object().unwrap() {
|
||||||
|
|
@ -205,7 +184,7 @@ fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_se
|
||||||
.keys()
|
.keys()
|
||||||
.filter(|kind| !value.as_object().unwrap().contains_key(*kind))
|
.filter(|kind| !value.as_object().unwrap().contains_key(*kind))
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(added, vec!["talk", "item"], "v7 counts two more kinds and no others");
|
assert_eq!(added, vec!["catch"], "v6 counts one more kind and no others");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
assert_eq!(&after[key], value, "{key} must survive the migration byte for byte");
|
assert_eq!(&after[key], value, "{key} must survive the migration byte for byte");
|
||||||
|
|
@ -216,29 +195,7 @@ fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_se
|
||||||
.keys()
|
.keys()
|
||||||
.filter(|key| !before.as_object().unwrap().contains_key(*key))
|
.filter(|key| !before.as_object().unwrap().contains_key(*key))
|
||||||
.collect();
|
.collect();
|
||||||
assert!(added.is_empty(), "v7 adds no field: {added:?}");
|
assert_eq!(added, vec!["catchCounts"], "v6 adds one field and no others");
|
||||||
|
|
||||||
// The first sample of the restored game. Under v6 the fly took an item ball (global
|
|
||||||
// toggleable index 0x2a) and a hidden item (index 9); v6 paid for neither. The sample seeds
|
|
||||||
// both into the ledger and pays nothing, which is the "no retroactive payout" half.
|
|
||||||
let mut wram = Wram(vec![0; 0x10000]);
|
|
||||||
wram.0[ram::wStatusFlags6 as usize] = 1;
|
|
||||||
wram.0[ram::wPartyCount as usize] = 1;
|
|
||||||
wram.0[ram::wCurMapWidth as usize] = 10;
|
|
||||||
wram.0[ram::wCurMapHeight as usize] = 9;
|
|
||||||
wram.0[ram::wXCoord as usize] = 5;
|
|
||||||
wram.0[ram::wYCoord as usize] = 7;
|
|
||||||
wram.0[(ram::wToggleableObjectFlags + 0x2a / 8) as usize] |= 1 << (0x2a % 8);
|
|
||||||
wram.0[(ram::wObtainedHiddenItemsFlags + 1) as usize] |= 1 << 1;
|
|
||||||
assert!(adapter.sample(&mut wram, 2_000.0).is_empty(), "the seed pays nothing");
|
|
||||||
let seen = adapter.export_state()["seen"].clone();
|
|
||||||
for key in ["item:42", "hidden:9", "items:seeded"] {
|
|
||||||
assert!(seen.as_array().unwrap().contains(&serde_json::json!(key)), "{key} seeded");
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
!seen.as_array().unwrap().iter().any(|key| key.as_str().unwrap().starts_with("talk:")),
|
|
||||||
"the talk ledger starts empty"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The rest of what a restore reads is untouched by the migration.
|
// The rest of what a restore reads is untouched by the migration.
|
||||||
assert_eq!(adapter.progress().rank, 3);
|
assert_eq!(adapter.progress().rank, 3);
|
||||||
|
|
@ -249,11 +206,11 @@ fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_se
|
||||||
#[test]
|
#[test]
|
||||||
fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() {
|
fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() {
|
||||||
let adapter = PokemonRedReward::new();
|
let adapter = PokemonRedReward::new();
|
||||||
let accepted = accepted_adapters(Some("pokered-unique8-v6"));
|
let accepted = accepted_adapters(Some("pokered-unique8-v5"));
|
||||||
let current = compatibility(adapter.id());
|
let current = compatibility(adapter.id());
|
||||||
|
|
||||||
// A v6 string whose state format also moved: a different build, not a rule change.
|
// A v5 string whose state format also moved: a different build, not a rule change.
|
||||||
let other_abi = compatibility("pokered-unique8-v6").replace("199616", "199617");
|
let other_abi = compatibility("pokered-unique8-v5").replace("199616", "199617");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
decide(&other_abi, ¤t, adapter.migrates_from(), &accepted),
|
decide(&other_abi, ¤t, adapter.migrates_from(), &accepted),
|
||||||
RestoreDecision::Refuse(_)
|
RestoreDecision::Refuse(_)
|
||||||
|
|
@ -264,15 +221,4 @@ fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() {
|
||||||
decide(¤t, ¤t, adapter.migrates_from(), &[]),
|
decide(¤t, ¤t, adapter.migrates_from(), &[]),
|
||||||
RestoreDecision::Exact
|
RestoreDecision::Exact
|
||||||
);
|
);
|
||||||
|
|
||||||
// And v5 -> v7 is not a migration this adapter wrote, whatever the operator names.
|
|
||||||
assert!(matches!(
|
|
||||||
decide(
|
|
||||||
&compatibility("pokered-unique8-v5"),
|
|
||||||
¤t,
|
|
||||||
adapter.migrates_from(),
|
|
||||||
&accepted_adapters(Some("pokered-unique8-v5,pokered-unique8-v6")),
|
|
||||||
),
|
|
||||||
RestoreDecision::Refuse(_)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
//! `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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -267,7 +267,7 @@ async fn the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_kil
|
||||||
let (status, versions) = service.get("/status");
|
let (status, versions) = service.get("/status");
|
||||||
assert_eq!(status, 200);
|
assert_eq!(status, 200);
|
||||||
assert_eq!(versions["version"]["kernel"], json!("lif-1ms-f64-v2"));
|
assert_eq!(versions["version"]["kernel"], json!("lif-1ms-f64-v2"));
|
||||||
assert_eq!(versions["version"]["adapter"], json!("pokered-unique8-v7"));
|
assert_eq!(versions["version"]["adapter"], json!("pokered-unique8-v6"));
|
||||||
assert!(
|
assert!(
|
||||||
versions["version"]["dataset"].as_str().unwrap_or_default().len() > 32,
|
versions["version"]["dataset"].as_str().unwrap_or_default().len() > 32,
|
||||||
"the dataset fingerprint is in /status: {}",
|
"the dataset fingerprint is in /status: {}",
|
||||||
|
|
@ -437,17 +437,6 @@ 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,7 +43,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -93,8 +92,6 @@ 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,
|
||||||
|
|
@ -127,7 +124,6 @@ impl Run {
|
||||||
gb,
|
gb,
|
||||||
adapter,
|
adapter,
|
||||||
layer,
|
layer,
|
||||||
legacy: LegacyFrame::new(),
|
|
||||||
decoder,
|
decoder,
|
||||||
channels,
|
channels,
|
||||||
ms: 0.0,
|
ms: 0.0,
|
||||||
|
|
@ -165,14 +161,18 @@ 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));
|
||||||
self.legacy.execute(Some(&mut self.layer), &active, 0, self.ms, &mut self.gb, &self.adapter);
|
let mask = {
|
||||||
|
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 evaluated = self
|
let ms = self.ms;
|
||||||
.legacy
|
self.payouts.extend(self.adapter.sample(&mut self.gb, ms));
|
||||||
.stub_advance(Some(&mut self.layer), &mut self.gb, &mut self.adapter, self.ms)
|
let ledger = AdapterLedger(&self.adapter);
|
||||||
.expect("a frame should complete");
|
let _ = self.layer.observe(&mut self.gb, &ledger, ms);
|
||||||
self.payouts.extend(evaluated.rewards);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn catches(&self) -> Vec<&RewardEvent> {
|
fn catches(&self) -> Vec<&RewardEvent> {
|
||||||
|
|
|
||||||
|
|
@ -1,432 +0,0 @@
|
||||||
//! The engagement rewards against the real cartridge: a conversation indoors and an item ball.
|
|
||||||
//!
|
|
||||||
//! Gated on `FLY_ROM` *and* on a checkpoint, the way every ROM test in this workspace is, and
|
|
||||||
//! skips cleanly without either:
|
|
||||||
//!
|
|
||||||
//! ```sh
|
|
||||||
//! FLY_ROM="$HOME/roms/pokemon-red.gb" \
|
|
||||||
//! FLY_ENGAGE_CHECKPOINT=.local/checkpoints/<a rung-10 Pewter checkpoint> \
|
|
||||||
//! cargo test --release -p flysim --test rom_engage -- --nocapture
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! ## What only the cartridge can answer
|
|
||||||
//!
|
|
||||||
//! The synthetic traces in `pokemon_red/tests.rs` write `wFontLoaded`, `wSpriteIndex`,
|
|
||||||
//! `wToggleableObjectFlags` and the rest from the disassembly. They cannot say that an A press at
|
|
||||||
//! a person on this cartridge opens the box on a frame whose previous sample was the fly's own,
|
|
||||||
//! that `wSpriteIndex` names that person by the time the adapter samples, or that `PickUpItem`
|
|
||||||
//! raises the ball's bit on a frame the adapter sees. This test does, with the shipping adapter
|
|
||||||
//! sampling once a frame, and it restores the checkpoint's own `v6` reward ledger -- so it is also
|
|
||||||
//! the `v6` -> `v7` migration on real game state: the item keys are seeded from the cartridge's
|
|
||||||
//! bits and nothing already taken pays.
|
|
||||||
//!
|
|
||||||
//! ## How the fly is moved
|
|
||||||
//!
|
|
||||||
//! Not by the macro layer, and not by a brain: a small scripted walker with a breadth-first route
|
|
||||||
//! over the whole-map grid (`state::map_grid`), pressing one direction at a time, answering text
|
|
||||||
//! with B and battles with A. The question is what the adapter reads, not whether anything finds
|
|
||||||
//! its way there. From the Pewter checkpoint it walks south out of the city, down Route 2 into the
|
|
||||||
//! Viridian Forest north gate -- a building, where it talks to the old man twice -- and on into
|
|
||||||
//! the forest to the Antidote ball at (25, 11), which it picks up, and then, after rolling the
|
|
||||||
//! emulator back to before the pickup, picks up again.
|
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
|
|
||||||
use flybrain_gb::adapter::RewardEvent;
|
|
||||||
use flybrain_gb::pokemon_red::macros::state::{Facing, Walkable};
|
|
||||||
use flybrain_gb::pokemon_red::symbols::ram;
|
|
||||||
use flybrain_gb::pokemon_red::{PokemonRedReward, catalog, engage, state};
|
|
||||||
use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons};
|
|
||||||
|
|
||||||
const MS_PER_FRAME: f64 = 1000.0 / 59.7275;
|
|
||||||
const PEWTER_CITY: u8 = 0x02;
|
|
||||||
const ROUTE_2: u8 = 0x0d;
|
|
||||||
const NORTH_GATE: u8 = 0x2f;
|
|
||||||
const VIRIDIAN_FOREST: u8 = 0x33;
|
|
||||||
/// `constants/item_constants.asm`: `ANTIDOTE` is `$0b`.
|
|
||||||
const ANTIDOTE: &str = "FOUND ITEM #11";
|
|
||||||
|
|
||||||
fn rom() -> Option<Vec<u8>> {
|
|
||||||
let path = std::env::var_os("FLY_ROM")?;
|
|
||||||
match std::fs::read(&path) {
|
|
||||||
Ok(bytes) => Some(bytes),
|
|
||||||
Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn checkpoint() -> Option<flysim::store::Checkpoint> {
|
|
||||||
let path = std::env::var_os("FLY_ENGAGE_CHECKPOINT")?;
|
|
||||||
Some(
|
|
||||||
flysim::store::load(std::path::Path::new(&path))
|
|
||||||
.expect("the checkpoint should be a FLYSIM01 envelope"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Run {
|
|
||||||
gb: Emulator,
|
|
||||||
adapter: PokemonRedReward,
|
|
||||||
ms: f64,
|
|
||||||
frames: u64,
|
|
||||||
payouts: Vec<RewardEvent>,
|
|
||||||
/// `(wCurMap, wCurMapTileset)` on the frame each payout in `payouts` was made.
|
|
||||||
payout_maps: Vec<(u8, u8)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn delta(facing: Facing) -> (i16, i16) {
|
|
||||||
facing.delta()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mask(facing: Facing) -> u8 {
|
|
||||||
match facing {
|
|
||||||
Facing::Up => buttons::UP,
|
|
||||||
Facing::Down => buttons::DOWN,
|
|
||||||
Facing::Left => buttons::LEFT,
|
|
||||||
Facing::Right => buttons::RIGHT,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const FACINGS: [Facing; 4] = [Facing::Up, Facing::Down, Facing::Left, Facing::Right];
|
|
||||||
|
|
||||||
impl Run {
|
|
||||||
fn resume(rom: &[u8], checkpoint: &flysim::store::Checkpoint) -> Self {
|
|
||||||
let mut gb = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
|
|
||||||
.expect("binjgb should accept the cartridge");
|
|
||||||
gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state");
|
|
||||||
let mut adapter = PokemonRedReward::new();
|
|
||||||
// The checkpoint was written by `pokered-unique8-v6`: this is the migration.
|
|
||||||
adapter.import_state(&checkpoint.runtime.reward).expect("a v6 ledger is a v7 ledger");
|
|
||||||
Self { gb, adapter, ms: 0.0, frames: 0, payouts: Vec::new(), payout_maps: Vec::new() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn byte(&mut self, address: u16) -> u8 {
|
|
||||||
self.gb.read_wram(address)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn frame(&mut self, mask: u8) {
|
|
||||||
self.gb.set_buttons(mask);
|
|
||||||
self.gb.run_frame().expect("a frame should complete");
|
|
||||||
self.ms += MS_PER_FRAME;
|
|
||||||
self.frames += 1;
|
|
||||||
let ms = self.ms;
|
|
||||||
let events = self.adapter.sample(&mut self.gb, ms);
|
|
||||||
let here = (self.byte(ram::wCurMap), self.byte(ram::wCurMapTileset));
|
|
||||||
self.payout_maps.extend(events.iter().map(|_| here));
|
|
||||||
self.payouts.extend(events);
|
|
||||||
assert!(self.frames < 200_000, "the walker is lost: {}", self.whereabouts());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn whereabouts(&mut self) -> String {
|
|
||||||
format!(
|
|
||||||
"map {:#04x} at ({}, {}), battle {}, font {}",
|
|
||||||
self.byte(ram::wCurMap),
|
|
||||||
self.byte(ram::wXCoord),
|
|
||||||
self.byte(ram::wYCoord),
|
|
||||||
self.byte(ram::wIsInBattle),
|
|
||||||
self.byte(ram::wFontLoaded)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map(&mut self) -> u8 {
|
|
||||||
self.byte(ram::wCurMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn at(&mut self) -> (u8, u8) {
|
|
||||||
(self.byte(ram::wXCoord), self.byte(ram::wYCoord))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whatever is on screen that is not the fly's to walk through: a battle (A through it) or a
|
|
||||||
/// text box (B through it). Returns once the overworld is controllable again.
|
|
||||||
fn settle(&mut self) {
|
|
||||||
for tick in 0..20_000u32 {
|
|
||||||
let in_battle = self.byte(ram::wIsInBattle) != 0;
|
|
||||||
let open = self.byte(ram::wFontLoaded) & 1 != 0;
|
|
||||||
if !in_battle && !open && state::controllable(&mut self.gb) {
|
|
||||||
if self.byte(ram::wWalkCounter) == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.frame(buttons::NONE);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let press = if in_battle { buttons::A } else { buttons::B };
|
|
||||||
self.frame(if tick % 8 < 3 { press } else { buttons::NONE });
|
|
||||||
}
|
|
||||||
panic!("the screen never settled: {}", self.whereabouts());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Press `facing` until the player has moved one tile, the map has changed, or it is plain
|
|
||||||
/// that the step is refused (which turns the player to face that way).
|
|
||||||
fn step(&mut self, facing: Facing) {
|
|
||||||
let (map, from) = (self.map(), self.at());
|
|
||||||
for _ in 0..48 {
|
|
||||||
self.frame(mask(facing));
|
|
||||||
if self.map() != map || self.at() != from {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if self.byte(ram::wIsInBattle) != 0 || self.byte(ram::wFontLoaded) & 1 != 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.frame(buttons::NONE);
|
|
||||||
self.settle();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Breadth-first over the decoded map, people excluded, from where the player stands to the
|
|
||||||
/// nearest tile `goal` accepts; the first step of that route, or `None`.
|
|
||||||
fn route(&mut self, goal: &dyn Fn(u8, u8) -> bool) -> Option<Facing> {
|
|
||||||
let grid = state::map_grid(&mut self.gb).ok()?;
|
|
||||||
let people: Vec<(u8, u8)> =
|
|
||||||
state::npcs(&mut self.gb).iter().map(|npc| (npc.x, npc.y)).collect();
|
|
||||||
let (width, height) = (grid.width(), grid.height());
|
|
||||||
let start = self.at();
|
|
||||||
let mut first: Vec<Option<Facing>> = vec![None; usize::from(width) * usize::from(height)];
|
|
||||||
let mut seen = vec![false; first.len()];
|
|
||||||
let index = |x: u8, y: u8| usize::from(y) * usize::from(width) + usize::from(x);
|
|
||||||
let mut queue = VecDeque::from([start]);
|
|
||||||
seen[index(start.0, start.1)] = true;
|
|
||||||
while let Some((x, y)) = queue.pop_front() {
|
|
||||||
if (x, y) != start && goal(x, y) {
|
|
||||||
return first[index(x, y)];
|
|
||||||
}
|
|
||||||
for facing in FACINGS {
|
|
||||||
if grid.walled(x, y, facing) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let (dx, dy) = delta(facing);
|
|
||||||
let (Ok(nx), Ok(ny)) =
|
|
||||||
(u8::try_from(i16::from(x) + dx), u8::try_from(i16::from(y) + dy))
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if nx >= width || ny >= height || seen[index(nx, ny)] {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if grid.walkable(nx, ny) != Walkable::Yes || people.contains(&(nx, ny)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen[index(nx, ny)] = true;
|
|
||||||
first[index(nx, ny)] = if (x, y) == start { Some(facing) } else { first[index(x, y)] };
|
|
||||||
queue.push_back((nx, ny));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walk until standing on a tile `goal` accepts, on this map.
|
|
||||||
fn walk_to(&mut self, goal: &dyn Fn(u8, u8) -> bool) {
|
|
||||||
let map = self.map();
|
|
||||||
for _ in 0..400 {
|
|
||||||
self.settle();
|
|
||||||
assert_eq!(self.map(), map, "the walk left the map: {}", self.whereabouts());
|
|
||||||
let (x, y) = self.at();
|
|
||||||
if goal(x, y) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(facing) = self.route(goal) else {
|
|
||||||
// A grid refused on this frame, or a person in the way: let a frame go by.
|
|
||||||
self.frame(buttons::NONE);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
self.step(facing);
|
|
||||||
}
|
|
||||||
let grid = state::map_grid(&mut self.gb);
|
|
||||||
let detail = match &grid {
|
|
||||||
Ok(grid) => {
|
|
||||||
let (x, y) = self.at();
|
|
||||||
let mut rows = String::new();
|
|
||||||
for ty in 0..grid.height().min(24) {
|
|
||||||
for tx in 0..grid.width() {
|
|
||||||
rows.push(if (tx, ty) == (x, y) {
|
|
||||||
'@'
|
|
||||||
} else {
|
|
||||||
match grid.walkable(tx, ty) {
|
|
||||||
Walkable::Yes => '.',
|
|
||||||
Walkable::No => '#',
|
|
||||||
Walkable::Unknown => '?',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
rows.push('\n');
|
|
||||||
}
|
|
||||||
format!("reachable {}\n{rows}", grid.reachable_from(x, y))
|
|
||||||
}
|
|
||||||
Err(refusal) => format!("grid refused: {}", refusal.label()),
|
|
||||||
};
|
|
||||||
panic!("never reached the goal: {}; {detail}", self.whereabouts());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walk to `(x, y)` and keep pressing `out` until the map changes.
|
|
||||||
fn leave_by(&mut self, x: u8, y: u8, out: Facing) {
|
|
||||||
let map = self.map();
|
|
||||||
self.walk_to(&|tx, ty| (tx, ty) == (x, y));
|
|
||||||
for _ in 0..8 {
|
|
||||||
self.step(out);
|
|
||||||
if self.map() != map {
|
|
||||||
// The warp's fade and the new map's first frames.
|
|
||||||
for _ in 0..60 {
|
|
||||||
self.frame(buttons::NONE);
|
|
||||||
}
|
|
||||||
self.settle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
panic!("pressing {out:?} at ({x}, {y}) never left the map: {}", self.whereabouts());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stand beside `(x, y)`, face it, press A once and let the conversation run to its end.
|
|
||||||
/// Returns the payouts the conversation produced.
|
|
||||||
fn press_a_at(&mut self, x: u8, y: u8) -> Vec<RewardEvent> {
|
|
||||||
self.walk_to(&|tx, ty| tx.abs_diff(x) + ty.abs_diff(y) == 1);
|
|
||||||
let (px, py) = self.at();
|
|
||||||
let facing = FACINGS
|
|
||||||
.into_iter()
|
|
||||||
.find(|facing| {
|
|
||||||
let (dx, dy) = delta(*facing);
|
|
||||||
i16::from(px) + dx == i16::from(x) && i16::from(py) + dy == i16::from(y)
|
|
||||||
})
|
|
||||||
.expect("a neighbour faces the target one way");
|
|
||||||
// Turn in place: the step is refused because the thing is in the way.
|
|
||||||
self.step(facing);
|
|
||||||
assert_eq!(self.at(), (px, py), "turning must not move the player");
|
|
||||||
self.press_a_here()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Press A where the player stands and faces, and let whatever it opens run to its end.
|
|
||||||
fn press_a_here(&mut self) -> Vec<RewardEvent> {
|
|
||||||
for _ in 0..4 {
|
|
||||||
self.frame(buttons::NONE);
|
|
||||||
}
|
|
||||||
let before = self.payouts.len();
|
|
||||||
for _ in 0..6 {
|
|
||||||
self.frame(buttons::A);
|
|
||||||
}
|
|
||||||
self.frame(buttons::NONE);
|
|
||||||
self.settle();
|
|
||||||
for _ in 0..8 {
|
|
||||||
self.frame(buttons::NONE);
|
|
||||||
}
|
|
||||||
self.payouts[before..].to_vec()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn of_kind(&self, kind: &str) -> usize {
|
|
||||||
self.payouts.iter().filter(|event| event.kind == kind).count()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn kinds(events: &[RewardEvent]) -> Vec<&'static str> {
|
|
||||||
events.iter().map(|event| event.kind).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_conversation_indoors_and_an_item_ball_each_pay_exactly_once_on_the_cartridge() {
|
|
||||||
let Some(rom) = rom() else {
|
|
||||||
eprintln!("skipped: FLY_ROM is not set");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(checkpoint) = checkpoint() else {
|
|
||||||
eprintln!("skipped: no FLY_ENGAGE_CHECKPOINT");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut run = Run::resume(&rom, &checkpoint);
|
|
||||||
if run.map() != PEWTER_CITY {
|
|
||||||
eprintln!("skipped: the checkpoint is on map {:#04x}, not Pewter City", run.map());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let before = run.adapter.progress().counts;
|
|
||||||
|
|
||||||
// The restore's first sample: the v6 ledger holds no item keys, so this is the seed.
|
|
||||||
run.settle();
|
|
||||||
assert!(run.payouts.is_empty(), "the migration pays nothing: {:?}", run.payouts);
|
|
||||||
|
|
||||||
// Pewter City's south edge, onto Route 2.
|
|
||||||
let height = run.byte(ram::wCurMapHeight) * 2;
|
|
||||||
run.walk_to(&|_, y| y == height - 1);
|
|
||||||
let (x, y) = run.at();
|
|
||||||
run.leave_by(x, y, Facing::Down);
|
|
||||||
assert_eq!(run.map(), ROUTE_2, "{}", run.whereabouts());
|
|
||||||
|
|
||||||
// Route 2's door into the forest's north gate, `warp_event 3, 11`: the gate is south of
|
|
||||||
// the city, so its door is entered heading south, from the tile above it.
|
|
||||||
run.leave_by(3, 10, Facing::Down);
|
|
||||||
assert_eq!(run.map(), NORTH_GATE, "{}", run.whereabouts());
|
|
||||||
let tileset = run.byte(ram::wCurMapTileset);
|
|
||||||
assert!(engage::indoor(tileset), "the gate is a building (tileset {tileset})");
|
|
||||||
|
|
||||||
// The old man at (2, 5): one conversation, one payout, when the box closes.
|
|
||||||
let first = run.press_a_at(2, 5);
|
|
||||||
eprintln!("gate, first conversation: {first:?}");
|
|
||||||
let talks: Vec<&RewardEvent> =
|
|
||||||
first.iter().filter(|event| event.kind == catalog::kind::TALK).collect();
|
|
||||||
assert_eq!(talks.len(), 1, "one conversation, one payout: {first:?}");
|
|
||||||
assert_eq!(talks[0].value, 0.10);
|
|
||||||
assert!(talks[0].label.starts_with("TALKED TO #"), "{}", talks[0].label);
|
|
||||||
// The same man again, and again: not a farm.
|
|
||||||
for _ in 0..2 {
|
|
||||||
let again = run.press_a_at(2, 5);
|
|
||||||
assert!(
|
|
||||||
!again.iter().any(|event| event.kind == catalog::kind::TALK),
|
|
||||||
"a second conversation with the same person pays nothing: {again:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// On through the gate, `warp_event 4, 7`, into the forest.
|
|
||||||
run.leave_by(4, 7, Facing::Down);
|
|
||||||
assert_eq!(run.map(), VIRIDIAN_FOREST, "{}", run.whereabouts());
|
|
||||||
assert!(!engage::indoor(run.byte(ram::wCurMapTileset)), "the forest is not a building");
|
|
||||||
|
|
||||||
// The Antidote ball at (25, 11). Save the game just before, to take it twice.
|
|
||||||
run.walk_to(&|tx, ty| tx.abs_diff(25) + ty.abs_diff(11) == 1);
|
|
||||||
let slot = run.gb.export_state().expect("an emulator state");
|
|
||||||
let first = run.press_a_at(25, 11);
|
|
||||||
eprintln!("forest, the ball: {first:?}");
|
|
||||||
let items: Vec<&RewardEvent> =
|
|
||||||
first.iter().filter(|event| event.kind == catalog::kind::ITEM).collect();
|
|
||||||
assert_eq!(items.len(), 1, "one pickup, one payout: {first:?}");
|
|
||||||
assert_eq!(items[0].value, 0.15);
|
|
||||||
assert_eq!(items[0].label, ANTIDOTE);
|
|
||||||
assert!(
|
|
||||||
!first.iter().any(|event| event.kind == catalog::kind::TALK),
|
|
||||||
"a ball is not a conversation, and the forest is not indoors"
|
|
||||||
);
|
|
||||||
// The ball is gone: pressing A at the empty tile pays nothing.
|
|
||||||
let empty = run.press_a_here();
|
|
||||||
assert!(!empty.iter().any(|event| event.kind == catalog::kind::ITEM), "{empty:?}");
|
|
||||||
|
|
||||||
// A rollback to the slot with the ball still there, the way the ratchet restores one, and
|
|
||||||
// the fly takes it again: the same item, and it does not pay twice.
|
|
||||||
run.gb.import_state(&slot).expect("the slot restores");
|
|
||||||
run.adapter.clear_transient();
|
|
||||||
run.frame(buttons::NONE);
|
|
||||||
run.settle();
|
|
||||||
let again = run.press_a_at(25, 11);
|
|
||||||
eprintln!("forest, the ball after a rollback: {again:?}");
|
|
||||||
assert!(
|
|
||||||
!again.iter().any(|event| event.kind == catalog::kind::ITEM),
|
|
||||||
"once per item for the run: {again:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let after = run.adapter.progress().counts;
|
|
||||||
eprintln!(
|
|
||||||
"{:.1} brain minutes; talk {} -> {}, item {} -> {}; every payout (kind, (map, tileset)): {:?}",
|
|
||||||
run.ms / 60_000.0,
|
|
||||||
before[catalog::kind::TALK],
|
|
||||||
after[catalog::kind::TALK],
|
|
||||||
before[catalog::kind::ITEM],
|
|
||||||
after[catalog::kind::ITEM],
|
|
||||||
kinds(&run.payouts).iter().zip(&run.payout_maps).collect::<Vec<_>>()
|
|
||||||
);
|
|
||||||
// The gate's exits were stood beside and walked through, and none of them paid: an indoor
|
|
||||||
// exit pays nothing. What *does* show up under the gate's id is Route 2's door, the
|
|
||||||
// outdoor exit the fly took: for the thirty-odd frames of `PlayMapChangeSound` the cartridge
|
|
||||||
// has already written the new `wCurMap` while the header, the warp table and the tileset
|
|
||||||
// are still Route 2's, and the rule reads that frame as what it is -- an outdoor exit.
|
|
||||||
for (event, (map, tileset)) in run.payouts.iter().zip(&run.payout_maps) {
|
|
||||||
assert!(
|
|
||||||
!(event.kind == catalog::kind::BOUNDARY && engage::indoor(*tileset)),
|
|
||||||
"a boundary payout on an indoor map's own header (map {map}): {event:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(run.of_kind(catalog::kind::TALK), 1);
|
|
||||||
assert_eq!(run.of_kind(catalog::kind::ITEM), 1);
|
|
||||||
assert_eq!(after[catalog::kind::TALK], before[catalog::kind::TALK] + 1);
|
|
||||||
assert_eq!(after[catalog::kind::ITEM], before[catalog::kind::ITEM] + 1);
|
|
||||||
}
|
|
||||||
|
|
@ -42,7 +42,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -148,8 +147,6 @@ 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.
|
||||||
|
|
@ -405,7 +402,6 @@ impl Run {
|
||||||
gb,
|
gb,
|
||||||
adapter,
|
adapter,
|
||||||
layer,
|
layer,
|
||||||
frame: LegacyFrame::new(),
|
|
||||||
decoder,
|
decoder,
|
||||||
channels,
|
channels,
|
||||||
ms,
|
ms,
|
||||||
|
|
@ -524,7 +520,6 @@ impl Run {
|
||||||
gb,
|
gb,
|
||||||
adapter,
|
adapter,
|
||||||
layer,
|
layer,
|
||||||
frame: LegacyFrame::new(),
|
|
||||||
decoder,
|
decoder,
|
||||||
channels,
|
channels,
|
||||||
ms,
|
ms,
|
||||||
|
|
@ -820,15 +815,9 @@ 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 (started, blocked, done) = {
|
let (mask, started, blocked, done) = {
|
||||||
let decision = self.frame.execute(
|
let ledger = AdapterLedger(&self.adapter);
|
||||||
Some(&mut self.layer),
|
let decision = self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger);
|
||||||
&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()
|
||||||
|
|
@ -851,7 +840,7 @@ impl Run {
|
||||||
})
|
})
|
||||||
.map(|event| event.name)
|
.map(|event| event.name)
|
||||||
.collect();
|
.collect();
|
||||||
(started, blocked, done)
|
(decision.mask, 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.
|
||||||
|
|
@ -974,10 +963,15 @@ 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;
|
||||||
self.frame
|
let ms = self.ms;
|
||||||
.stub_advance(Some(&mut self.layer), &mut self.gb, &mut self.adapter, self.ms)
|
self.adapter.sample(&mut self.gb, 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) {
|
||||||
|
|
@ -3296,513 +3290,3 @@ fn the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint() {
|
||||||
"the fly never went up the room past the doormat rows: highest row {highest_row:?}"
|
"the fly never went up the room past the doormat rows: highest row {highest_row:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row60_checkpoint() -> Option<flysim::store::Checkpoint> {
|
|
||||||
std::env::var_os("FLY_ROW60_CHECKPOINT").map(|path| {
|
|
||||||
flysim::store::load(std::path::Path::new(&path))
|
|
||||||
.expect("the checkpoint should be a FLYSIM01 envelope")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Route 1, from the checkpoint taken mid-trap: Squirtle L5 (TACKLE, TAIL WHIP) against wild
|
|
||||||
/// Pidgey and Rattata.
|
|
||||||
///
|
|
||||||
/// **What was live** (2026-09-23, v0.5.5, rank 9 after the reset to milestone 1): `MOVE 2` 183
|
|
||||||
/// times and `MOVE 1` once, "Nothing happened!" on the screen, twenty-five brain minutes with no
|
|
||||||
/// reward. TAIL WHIP took the Pidgey's DEFENSE to where the cartridge refuses it (the stat at 1,
|
|
||||||
/// or the stage at -6) and the pad kept dealing it beside TACKLE; every battle ended with Squirtle
|
|
||||||
/// fainted and the fly back home (`infra/docs/macros-traps.md` row 60). The checkpoint itself is
|
|
||||||
/// the frame Squirtle fainted.
|
|
||||||
///
|
|
||||||
/// The driver is the real palette with the live readout's measured favourite: `MOVE 2` whenever
|
|
||||||
/// the pad deals it, otherwise a uniform choice per hold -- a harness choice, not the fly's. The
|
|
||||||
/// claims:
|
|
||||||
///
|
|
||||||
/// - **no `MOVE n` is dealt for a move the cartridge answers with nothing while another move
|
|
||||||
/// would do something**, on any frame of the fly's own turn, and the run does reach that state;
|
|
||||||
/// - **a wild battle is won** inside the budget, which the base never does with this driver.
|
|
||||||
///
|
|
||||||
/// ```sh
|
|
||||||
/// FLY_ROM=/path/to/pokemon-red.gb \
|
|
||||||
/// FLY_ROW60_CHECKPOINT=.local/checkpoints/release-rank9-row60.checkpoint \
|
|
||||||
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture tail_whip
|
|
||||||
/// ```
|
|
||||||
#[test]
|
|
||||||
fn tail_whip_at_its_limit_is_not_dealt_and_a_route_one_battle_is_won() {
|
|
||||||
use flybrain_gb::pokemon_red::macros::PokemonPalette;
|
|
||||||
use flybrain_gb::pokemon_red::state;
|
|
||||||
use flybrain_gb::pokemon_red::symbols::ram;
|
|
||||||
use flybrain_gb::{MacroPalette, MemoryReader, Started};
|
|
||||||
let rom = skip_without_rom!();
|
|
||||||
let Some(checkpoint) = row60_checkpoint() else {
|
|
||||||
eprintln!("skipped: no FLY_ROW60_CHECKPOINT");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
||||||
assert_ne!(run.gb.read8(ram::wIsInBattle), 0, "the checkpoint is inside the Route 1 battle");
|
|
||||||
|
|
||||||
let budget = 72_000u32;
|
|
||||||
let hold_frames = 48u32;
|
|
||||||
let mut palette = PokemonPalette::new(SEED);
|
|
||||||
let mut rng = 20_260_923u32;
|
|
||||||
let mut running = false;
|
|
||||||
let mut since_decision = hold_frames;
|
|
||||||
let mut ms = run.ms;
|
|
||||||
let mut at_a_limit = 0u32;
|
|
||||||
let mut dealt_without_effect = 0u32;
|
|
||||||
let mut starts = std::collections::BTreeMap::<&'static str, u32>::new();
|
|
||||||
// (frames, the enemy fainted, the fly's Pokemon standing at the end)
|
|
||||||
let mut battles: Vec<(u32, bool, bool)> = Vec::new();
|
|
||||||
let mut current: Option<(u32, bool, bool)> = Some((0, false, false));
|
|
||||||
for _ in 0..budget {
|
|
||||||
palette.clock(ms);
|
|
||||||
let observed = {
|
|
||||||
let ledger = AdapterLedger(&run.adapter);
|
|
||||||
palette.observe(&mut run.gb, &ledger)
|
|
||||||
};
|
|
||||||
let names: Vec<&str> = observed.bindings.iter().map(|binding| binding.name).collect();
|
|
||||||
if let Some(battle) = state::battle(&mut run.gb)
|
|
||||||
&& battle.own_turn
|
|
||||||
&& let Some(own) = battle.own
|
|
||||||
{
|
|
||||||
let mut nothing = [false; 4];
|
|
||||||
let mut useful = [false; 4];
|
|
||||||
for (slot, entry) in own.moves.iter().enumerate() {
|
|
||||||
if let Some(entry) = entry.filter(|entry| entry.id != 0) {
|
|
||||||
nothing[slot] = state::move_without_effect(&mut run.gb, entry.id) == Some(true);
|
|
||||||
useful[slot] = entry.pp > 0 && !nothing[slot];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if nothing.iter().any(|flag| *flag) && useful.iter().any(|flag| *flag) {
|
|
||||||
at_a_limit += 1;
|
|
||||||
for (slot, name) in ["MOVE 1", "MOVE 2", "MOVE 3", "MOVE 4"].iter().enumerate() {
|
|
||||||
if nothing[slot] && names.contains(name) {
|
|
||||||
dealt_without_effect += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut mask = 0u8;
|
|
||||||
{
|
|
||||||
let ledger = AdapterLedger(&run.adapter);
|
|
||||||
if running {
|
|
||||||
match palette.step(&mut run.gb, &ledger) {
|
|
||||||
Some(held) => mask = held,
|
|
||||||
None => running = false,
|
|
||||||
}
|
|
||||||
} else if since_decision >= hold_frames && !observed.bindings.is_empty() {
|
|
||||||
since_decision = 0;
|
|
||||||
rng ^= rng << 13;
|
|
||||||
rng ^= rng >> 17;
|
|
||||||
rng ^= rng << 5;
|
|
||||||
let binding = observed
|
|
||||||
.bindings
|
|
||||||
.iter()
|
|
||||||
.find(|binding| binding.name == "MOVE 2")
|
|
||||||
.unwrap_or(&observed.bindings[rng as usize % observed.bindings.len()]);
|
|
||||||
if let Started::Running(_) = palette.start(binding.slot, &mut run.gb, &ledger) {
|
|
||||||
*starts.entry(binding.name).or_default() += 1;
|
|
||||||
running = true;
|
|
||||||
match palette.step(&mut run.gb, &ledger) {
|
|
||||||
Some(held) => mask = held,
|
|
||||||
None => running = false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
since_decision += 1;
|
|
||||||
run.gb.set_buttons(mask);
|
|
||||||
run.gb.run_frame().expect("a frame should complete");
|
|
||||||
ms += MS_PER_FRAME;
|
|
||||||
run.adapter.sample(&mut run.gb, ms);
|
|
||||||
|
|
||||||
let fighting = run.gb.read8(ram::wIsInBattle) != 0;
|
|
||||||
match (&mut current, fighting) {
|
|
||||||
(Some((frames, fainted, standing)), true) => {
|
|
||||||
*frames += 1;
|
|
||||||
if let Some(battle) = state::battle(&mut run.gb) {
|
|
||||||
*fainted |= battle.enemy.is_some_and(|enemy| enemy.hp == 0);
|
|
||||||
if let Some(own) = battle.own {
|
|
||||||
*standing = own.hp > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(None, true) => current = Some((1, false, true)),
|
|
||||||
(Some(battle), false) => {
|
|
||||||
battles.push(*battle);
|
|
||||||
current = None;
|
|
||||||
}
|
|
||||||
(None, false) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let won = battles.iter().filter(|battle| battle.1 && battle.2).count();
|
|
||||||
eprintln!(
|
|
||||||
"{:.1} brain minutes: battles ended {} (won {won}), (frames, enemy fainted, standing) \
|
|
||||||
{battles:?}, still in one {current:?}; own-turn frames with a move without effect beside a \
|
|
||||||
useful one {at_a_limit}, the refused move dealt on {dealt_without_effect}; starts \
|
|
||||||
{starts:?}; rank {}",
|
|
||||||
(ms - run.ms) / 60_000.0,
|
|
||||||
battles.len(),
|
|
||||||
run.adapter.progress().rank,
|
|
||||||
);
|
|
||||||
assert!(at_a_limit > 0, "the run never reached the trap's own state");
|
|
||||||
assert_eq!(
|
|
||||||
dealt_without_effect, 0,
|
|
||||||
"a MOVE n the cartridge answers with nothing was dealt beside one it does not"
|
|
||||||
);
|
|
||||||
assert!(won > 0, "no wild battle was won in {budget} frames: {battles:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROUTE_4: u32 = 0x0f;
|
|
||||||
const MT_MOON_1F: u32 = 0x3b;
|
|
||||||
|
|
||||||
/// A rung-11 checkpoint just after the Boulder Badge, or `None` to skip.
|
|
||||||
///
|
|
||||||
/// Row 59's is the route survey's own: `examples/scene_probe.rs` driven from the row-58 checkpoint
|
|
||||||
/// with `FLY_PROBE_CATCH=route FLY_PROBE_PREFER="GO OBJECTIVE,TALK"` until rung 11, written by
|
|
||||||
/// `FLY_PROBE_SAVE_RANK=11` on the first safe overworld frame -- in the gym, beside BROCK.
|
|
||||||
fn badge_checkpoint() -> Option<flysim::store::Checkpoint> {
|
|
||||||
std::env::var_os("FLY_BADGE_CHECKPOINT").map(|path| {
|
|
||||||
flysim::store::load(std::path::Path::new(&path))
|
|
||||||
.expect("the checkpoint should be a FLYSIM01 envelope")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// From the badge: the fly takes the road to Mt. Moon instead of walking the Pewter end of Route 3.
|
|
||||||
///
|
|
||||||
/// **What the row-58 review measured** (the route survey carried past the badge, 2026-09-23):
|
|
||||||
/// from about frame 68,000, Pewter City (39, 17) and Route 3 (0, 9) in a ring, `GO OBJECTIVE`
|
|
||||||
/// done on Route 3 538 times and `GO ROUTE` done on Pewter 537. The live fly was due to reach
|
|
||||||
/// the same state as soon as it earned the badge.
|
|
||||||
///
|
|
||||||
/// **What was wrong** (`infra/docs/macros-traps.md` row 59), two things stacked:
|
|
||||||
///
|
|
||||||
/// - the map graph had Route 4 east of Route 3 and a Mt. Moon door on Route 3. The headers put
|
|
||||||
/// Route 4 north of Route 3 and both of Mt. Moon's doors on Route 4, so Route 3's north edge,
|
|
||||||
/// the one road to the mountain, named no map and was nobody's first hop; and Route 4's two
|
|
||||||
/// sides, which the mountain cuts apart, were one node;
|
|
||||||
/// - Route 3's first trainer closed his challenge onto five frames of plain overworld before the
|
|
||||||
/// battle was decided, and the walk he interrupted was written as a refusal on the first of
|
|
||||||
/// them: (11, 6), the one gap between the road's west end and the rest of it, walled for the
|
|
||||||
/// session.
|
|
||||||
///
|
|
||||||
/// The claims, none of them about which button the fly presses:
|
|
||||||
///
|
|
||||||
/// - the fly is on **Route 4** and at **Mt. Moon's door** (map `0x3b`) inside the budget;
|
|
||||||
/// - Pewter City and Route 3 are **not a ring**: under forty crossings between them, against
|
|
||||||
/// over a thousand on the base.
|
|
||||||
///
|
|
||||||
/// ```sh
|
|
||||||
/// FLY_ROM=/path/to/pokemon-red.gb \
|
|
||||||
/// FLY_BADGE_CHECKPOINT=.local/checkpoints/survey-rank11-row59.checkpoint \
|
|
||||||
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture the_road_to_mt_moon
|
|
||||||
/// ```
|
|
||||||
#[test]
|
|
||||||
fn the_road_to_mt_moon_is_not_a_ring_at_the_pewter_end_from_the_badge_checkpoint() {
|
|
||||||
let rom = skip_without_rom!();
|
|
||||||
let Some(checkpoint) = badge_checkpoint() else {
|
|
||||||
eprintln!("skipped: no FLY_BADGE_CHECKPOINT");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
||||||
assert!(run.adapter.progress().rank >= 11, "the checkpoint is past the badge");
|
|
||||||
|
|
||||||
let mut crossings = 0u32;
|
|
||||||
let mut on_route_4: Option<u32> = None;
|
|
||||||
let mut at_mt_moon: Option<u32> = None;
|
|
||||||
let mut previous = run.map();
|
|
||||||
for frame in 0..288_000u32 {
|
|
||||||
run.frame();
|
|
||||||
let map = run.map();
|
|
||||||
if map != previous {
|
|
||||||
if (previous == PEWTER_CITY && map == ROUTE_3) || (previous == ROUTE_3 && map == PEWTER_CITY) {
|
|
||||||
crossings += 1;
|
|
||||||
}
|
|
||||||
previous = map;
|
|
||||||
}
|
|
||||||
if on_route_4.is_none() && map == ROUTE_4 {
|
|
||||||
on_route_4 = Some(frame);
|
|
||||||
}
|
|
||||||
if at_mt_moon.is_none() && map == MT_MOON_1F {
|
|
||||||
at_mt_moon = Some(frame);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let progress = run.adapter.progress();
|
|
||||||
eprintln!(
|
|
||||||
"{:.1} brain minutes: Route 4 at {on_route_4:?}, Mt. Moon at {at_mt_moon:?}, Pewter / Route 3 \
|
|
||||||
crossings {crossings}, rank {} ({}), route {:?}, macros {:?}",
|
|
||||||
run.ms / 60_000.0,
|
|
||||||
progress.rank,
|
|
||||||
progress.rank_label,
|
|
||||||
run.route,
|
|
||||||
run.started
|
|
||||||
);
|
|
||||||
assert!(crossings < 40, "{crossings} crossings between Pewter City and Route 3: {:?}", run.started);
|
|
||||||
assert!(on_route_4.is_some(), "the fly never reached Route 4: {:?}", run.route);
|
|
||||||
assert!(at_mt_moon.is_some(), "the fly never reached Mt. Moon's door: {:?}", run.route);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Route 4's two sides, read off the cartridge: the warp table the geography's pieces are keyed
|
|
||||||
/// by, and the piece the fly is standing in on arrival from Route 3.
|
|
||||||
///
|
|
||||||
/// The table's door indices and tiles are the disassembly's (`data/maps/objects/Route4.asm`); this
|
|
||||||
/// is the same three warps read from `wWarpEntries` on the loaded map, and the piece named by the
|
|
||||||
/// decoded grid ([`geography::region_on`]) rather than by the doors.
|
|
||||||
#[test]
|
|
||||||
fn route_4s_doors_and_sides_are_the_cartridges_from_the_badge_checkpoint() {
|
|
||||||
use flybrain_gb::pokemon_red::macros::geography::{self, Region};
|
|
||||||
let rom = skip_without_rom!();
|
|
||||||
let Some(checkpoint) = badge_checkpoint() else {
|
|
||||||
eprintln!("skipped: no FLY_BADGE_CHECKPOINT");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
||||||
let mut read = None;
|
|
||||||
for _ in 0..216_000u32 {
|
|
||||||
run.frame();
|
|
||||||
if run.map() != ROUTE_4 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(player) = flybrain_gb::pokemon_red::state::player(&mut run.gb) else { continue };
|
|
||||||
let Ok(grid) = flybrain_gb::pokemon_red::state::map_grid(&mut run.gb) else { continue };
|
|
||||||
if u32::from(player.map) != ROUTE_4 || grid.map() != player.map {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let warps = flybrain_gb::pokemon_red::state::warps(&mut run.gb);
|
|
||||||
read = Some((player, warps, geography::region_on(player.map, player.x, player.y, Some(&grid))));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let Some((player, warps, region)) = read else {
|
|
||||||
panic!("the fly never stood on Route 4 with its grid decoded: {:?}", run.route);
|
|
||||||
};
|
|
||||||
eprintln!("on Route 4 at ({}, {}), piece {region:?}, warps {warps:?}", player.x, player.y);
|
|
||||||
let doors: Vec<(u8, u8, u8)> =
|
|
||||||
warps.iter().map(|warp| (warp.x, warp.y, warp.destination_map)).collect();
|
|
||||||
assert_eq!(
|
|
||||||
doors,
|
|
||||||
vec![(11, 5, 0x44), (18, 5, 0x3b), (24, 5, 0x3c)],
|
|
||||||
"the Pokécenter, the cave mouth and B1F's exit, in the table's order"
|
|
||||||
);
|
|
||||||
assert_eq!(region, Region::piece(0x0f, 0), "arrived from Route 3, on the cave mouth's side");
|
|
||||||
}
|
|
||||||
|
|
||||||
const MT_MOON_POKECENTER: u32 = 0x44;
|
|
||||||
|
|
||||||
/// The live checkpoint from inside row 59's ring (Route 4, rank 12 MT. MOON), or `None` to skip.
|
|
||||||
fn mt_moon_live_checkpoint() -> Option<flysim::store::Checkpoint> {
|
|
||||||
std::env::var_os("FLY_MT_MOON_CHECKPOINT").map(|path| {
|
|
||||||
flysim::store::load(std::path::Path::new(&path))
|
|
||||||
.expect("the checkpoint should be a FLYSIM01 envelope")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// From the live checkpoint taken inside the ring: the fly goes into Mt. Moon instead of in and
|
|
||||||
/// out of the Pokécenter beside it.
|
|
||||||
///
|
|
||||||
/// **What was live** (2026-09-23 22:20 UTC, v0.6.0, rank 12 MT. MOON, the objective Cerulean City):
|
|
||||||
/// on Route 4, per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103, five distinct
|
|
||||||
/// macros and two new tiles. Route 4 was one node on the map graph with Cerulean off its east
|
|
||||||
/// edge, which Mt. Moon cuts off from the cave mouth's side, so the objective aimed at ground no
|
|
||||||
/// walk could reach and the Pokécenter door was the way out that was left. The route survey from
|
|
||||||
/// this checkpoint on `main` walks Route 4 and the Pokécenter 930 times in 72,000 frames.
|
|
||||||
///
|
|
||||||
/// The claims, none of them about which button the fly presses, over twenty brain minutes on the
|
|
||||||
/// stub rotation: the fly is **inside Mt. Moon** (map `0x3b`), and Route 4's west side and its two
|
|
||||||
/// doors, the Pokécenter and the cave mouth, are **not a ring**: under twenty-five crossings in all.
|
|
||||||
/// The base makes 56 (18 through the Pokécenter's door, 38 through the cave's); the rotation walks
|
|
||||||
/// into the cave on the base too, and back out, and in, because from 1F the graph's Cerulean was
|
|
||||||
/// Route 4's east edge beside it.
|
|
||||||
///
|
|
||||||
/// ```sh
|
|
||||||
/// FLY_ROM=/path/to/pokemon-red.gb \
|
|
||||||
/// FLY_MT_MOON_CHECKPOINT=.local/checkpoints/release-rank12-route4.checkpoint \
|
|
||||||
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture the_fly_goes_into_mt_moon
|
|
||||||
/// ```
|
|
||||||
#[test]
|
|
||||||
fn the_fly_goes_into_mt_moon_from_the_live_route_4_checkpoint() {
|
|
||||||
let rom = skip_without_rom!();
|
|
||||||
let Some(checkpoint) = mt_moon_live_checkpoint() else {
|
|
||||||
eprintln!("skipped: no FLY_MT_MOON_CHECKPOINT");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
||||||
assert!(run.adapter.progress().rank >= 12, "the checkpoint is the rung the ring was on");
|
|
||||||
|
|
||||||
let mut crossings = 0u32;
|
|
||||||
let mut in_mt_moon: Option<u32> = None;
|
|
||||||
let mut on_route_4 = 0u32;
|
|
||||||
let mut previous = run.map();
|
|
||||||
for frame in 0..72_000u32 {
|
|
||||||
run.frame();
|
|
||||||
let map = run.map();
|
|
||||||
if map != previous {
|
|
||||||
let door = |other: u32| other == MT_MOON_POKECENTER || other == MT_MOON_1F;
|
|
||||||
if (previous == ROUTE_4 && door(map)) || (door(previous) && map == ROUTE_4) {
|
|
||||||
crossings += 1;
|
|
||||||
}
|
|
||||||
previous = map;
|
|
||||||
}
|
|
||||||
if in_mt_moon.is_none() && map == MT_MOON_1F {
|
|
||||||
in_mt_moon = Some(frame);
|
|
||||||
}
|
|
||||||
if map == ROUTE_4 {
|
|
||||||
on_route_4 += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eprintln!(
|
|
||||||
"{:.1} brain minutes: Mt. Moon at {in_mt_moon:?}, crossings of Route 4's west doors {crossings}, \
|
|
||||||
frames on Route 4 {on_route_4}, rank {}, route {:?}, macros {:?}",
|
|
||||||
run.ms / 60_000.0,
|
|
||||||
run.adapter.progress().rank,
|
|
||||||
run.route,
|
|
||||||
run.started
|
|
||||||
);
|
|
||||||
assert!(crossings < 25, "{crossings} crossings of Route 4's west doors: {:?}", run.started);
|
|
||||||
assert!(in_mt_moon.is_some(), "the fly never went into Mt. Moon: {:?}", run.route);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row61_checkpoint() -> Option<flysim::store::Checkpoint> {
|
|
||||||
std::env::var_os("FLY_ROW61_CHECKPOINT").map(|path| {
|
|
||||||
flysim::store::load(std::path::Path::new(&path))
|
|
||||||
.expect("the checkpoint should be a FLYSIM01 envelope")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Viridian Forest's south gate, from the checkpoint pulled during the ring.
|
|
||||||
///
|
|
||||||
/// **What was live** (2026-09-23, v0.5.5, rung 9 after the reset to milestone 1): for twenty
|
|
||||||
/// minutes `GO OBJECTIVE` into the forest's south gate and `GO OUT` straight back onto Route 2,
|
|
||||||
/// `GO WARP` back from the forest, `GO OBJECTIVE blocked` in the forest, no reward
|
|
||||||
/// (`infra/docs/macros-traps.md` row 61). The forest's only road to its north gate is a two-wide
|
|
||||||
/// corridor at x = 1-2 with a Bug Catcher standing on (2, 18) facing west. A walk up the corridor
|
|
||||||
/// steps onto (1, 18) and the trainer takes the joypad. His "!" bubble (about sixty frames, before
|
|
||||||
/// `wJoyIgnore` is set) and the five frames after his text (before `wCurOpponent` is) read as the
|
|
||||||
/// fly's own overworld: the push-back the walk had earned was written on the first frame after the
|
|
||||||
/// text, (1, 18) was walled for the session, and every later walk to the north gate had no road.
|
|
||||||
/// Row 59's thirty-frame settle alone keeps the wall out; this row reads `BIT_TRAINER_BATTLE`.
|
|
||||||
///
|
|
||||||
/// The driver is the route survey's: the real palette, one uniform choice per hold, xorshift
|
|
||||||
/// seeded 7 -- a harness choice, not the fly's. Before rows 59 and 61 it never reached the north
|
|
||||||
/// gate in 72,000 frames. The claims:
|
|
||||||
///
|
|
||||||
/// - **no button is offered on an overworld frame inside a trainer's challenge**
|
|
||||||
/// (`wStatusFlags7` bit 3 set), and the run does reach such frames;
|
|
||||||
/// - **no tile of the forest is walled by a trainer's challenge**: (1, 18) never enters the pushed
|
|
||||||
/// ledger;
|
|
||||||
/// - **the fly goes through the north gate onto Route 2 and into Pewter City** inside the budget.
|
|
||||||
///
|
|
||||||
/// ```sh
|
|
||||||
/// FLY_ROM=/path/to/pokemon-red.gb FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 \
|
|
||||||
/// FLY_ROW61_CHECKPOINT=<the rank-9 checkpoint pulled during the ring, under .local/checkpoints> \
|
|
||||||
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture forests_north_gate
|
|
||||||
/// ```
|
|
||||||
#[test]
|
|
||||||
fn a_trainers_challenge_does_not_wall_the_road_to_the_forests_north_gate() {
|
|
||||||
use flybrain_gb::pokemon_red::macros::PokemonPalette;
|
|
||||||
use flybrain_gb::pokemon_red::macros::cartridge::{PushedLedger, Tile};
|
|
||||||
use flybrain_gb::pokemon_red::state;
|
|
||||||
use flybrain_gb::{MacroPalette, MemoryReader, Started};
|
|
||||||
const FOREST: u8 = 0x33;
|
|
||||||
const NORTH_GATE: u8 = 0x2f;
|
|
||||||
const ROUTE_2: u8 = 0x0d;
|
|
||||||
const PEWTER: u8 = 0x02;
|
|
||||||
let rom = skip_without_rom!();
|
|
||||||
let Some(checkpoint) = row61_checkpoint() else {
|
|
||||||
eprintln!("skipped: no FLY_ROW61_CHECKPOINT");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
|
|
||||||
assert_eq!(run.map(), 0x32, "the checkpoint is the forest's south gate");
|
|
||||||
|
|
||||||
let budget = 40_000u32;
|
|
||||||
let hold_frames = 48u32;
|
|
||||||
let mut palette = PokemonPalette::new(SEED);
|
|
||||||
let mut rng = 7u32;
|
|
||||||
let mut running = false;
|
|
||||||
let mut since_decision = hold_frames;
|
|
||||||
let mut ms = run.ms;
|
|
||||||
let mut arrivals: Vec<(u32, u8)> = Vec::new();
|
|
||||||
let mut last = None;
|
|
||||||
let mut walled_at: Option<u32> = None;
|
|
||||||
// Overworld frames (the shared reading) inside a trainer's challenge, and those that dealt a pad.
|
|
||||||
let mut engaged = 0u32;
|
|
||||||
let mut engaged_dealt = 0u32;
|
|
||||||
for frame in 0..budget {
|
|
||||||
palette.clock(ms);
|
|
||||||
let observed = {
|
|
||||||
let ledger = AdapterLedger(&run.adapter);
|
|
||||||
palette.observe(&mut run.gb, &ledger)
|
|
||||||
};
|
|
||||||
// `wStatusFlags7` bit 3, `BIT_TRAINER_BATTLE`, read here rather than through the seam.
|
|
||||||
if run.gb.read8(flybrain_gb::pokemon_red::symbols::ram::wStatusFlags7) & (1 << 3) != 0
|
|
||||||
&& flybrain_gb::pokemon_red::scene::detect(&mut run.gb)
|
|
||||||
== flybrain_gb::pokemon_red::macros::state::Scene::Overworld
|
|
||||||
{
|
|
||||||
engaged += 1;
|
|
||||||
if !observed.bindings.is_empty() {
|
|
||||||
engaged_dealt += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut mask = 0u8;
|
|
||||||
{
|
|
||||||
let ledger = AdapterLedger(&run.adapter);
|
|
||||||
if running {
|
|
||||||
match palette.step(&mut run.gb, &ledger) {
|
|
||||||
Some(held) => mask = held,
|
|
||||||
None => running = false,
|
|
||||||
}
|
|
||||||
} else if since_decision >= hold_frames && !observed.bindings.is_empty() {
|
|
||||||
since_decision = 0;
|
|
||||||
rng ^= rng << 13;
|
|
||||||
rng ^= rng >> 17;
|
|
||||||
rng ^= rng << 5;
|
|
||||||
let binding = &observed.bindings[rng as usize % observed.bindings.len()];
|
|
||||||
if let Started::Running(_) = palette.start(binding.slot, &mut run.gb, &ledger) {
|
|
||||||
running = true;
|
|
||||||
match palette.step(&mut run.gb, &ledger) {
|
|
||||||
Some(held) => mask = held,
|
|
||||||
None => running = false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = palette.take_finished();
|
|
||||||
since_decision += 1;
|
|
||||||
run.gb.set_buttons(mask);
|
|
||||||
run.gb.run_frame().expect("a frame should complete");
|
|
||||||
ms += MS_PER_FRAME;
|
|
||||||
run.adapter.sample(&mut run.gb, ms);
|
|
||||||
|
|
||||||
if walled_at.is_none() && palette.fences().0.pushed(FOREST, Tile::new(1, 18)) {
|
|
||||||
walled_at = Some(frame);
|
|
||||||
}
|
|
||||||
if let Some(player) = state::player(&mut run.gb)
|
|
||||||
&& last != Some(player.map)
|
|
||||||
{
|
|
||||||
last = Some(player.map);
|
|
||||||
arrivals.push((frame, player.map));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let first = |map: u8| arrivals.iter().find(|(_, at)| *at == map).map(|(frame, _)| *frame);
|
|
||||||
let north_gate = first(NORTH_GATE);
|
|
||||||
let route_2_north = north_gate
|
|
||||||
.and_then(|gate| arrivals.iter().find(|(frame, map)| *frame > gate && *map == ROUTE_2))
|
|
||||||
.map(|(frame, _)| *frame);
|
|
||||||
let pewter = first(PEWTER);
|
|
||||||
eprintln!(
|
|
||||||
"{:.1} brain minutes: challenge overworld frames {engaged}, a pad dealt on {engaged_dealt}; \
|
|
||||||
(1, 18) walled at {walled_at:?}; north gate {north_gate:?}, Route 2 \
|
|
||||||
after it {route_2_north:?}, Pewter City {pewter:?}; pushed {:?}; rank {}; arrivals {}",
|
|
||||||
(ms - run.ms) / 60_000.0,
|
|
||||||
palette.fences().0,
|
|
||||||
run.adapter.progress().rank,
|
|
||||||
arrivals.len(),
|
|
||||||
);
|
|
||||||
assert!(engaged > 0, "the run never reached a trainer's challenge");
|
|
||||||
assert_eq!(engaged_dealt, 0, "a pad was dealt inside a trainer's challenge");
|
|
||||||
assert_eq!(walled_at, None, "a trainer's challenge walled the forest's corridor");
|
|
||||||
assert!(north_gate.is_some(), "the fly never reached the forest's north gate: {arrivals:?}");
|
|
||||||
assert!(route_2_north.is_some(), "nor Route 2 through it: {arrivals:?}");
|
|
||||||
assert!(pewter.is_some(), "nor Pewter City: {arrivals:?}");
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -209,22 +209,6 @@ EXTRA_RAM = (
|
||||||
# services/flysim/tools/resolve_wram.py is the second reading of it, from ram/wram.asm at
|
# services/flysim/tools/resolve_wram.py is the second reading of it, from ram/wram.asm at
|
||||||
# this commit, bracketed by wFontLoaded and wForcePlayerToChooseMon.
|
# this commit, bracketed by wFontLoaded and wForcePlayerToChooseMon.
|
||||||
'wCapturedMonSpecies',
|
'wCapturedMonSpecies',
|
||||||
# The engagement rewards (`docs/rewards-learning.md`, the operator 2026-09-23).
|
|
||||||
# `talk`: DisplayTextID copies its argument -- the sprite slot, or a sign's text id --
|
|
||||||
# into wSpriteIndex before it looks the text up, and the overworld's A press only
|
|
||||||
# reaches it while wWalkCounter is zero. `item`: an item ball is a sprite whose
|
|
||||||
# wMapSpriteExtraData entry is (item id, 0) -- LoadMapHeader writes that shape for
|
|
||||||
# an ITEM-flagged object_event and no other -- found in the map's
|
|
||||||
# wToggleableObjectList; PickUpItem hides it by setting its global bit in
|
|
||||||
# wToggleableObjectFlags, and FoundHiddenItemText sets the hidden item's bit in
|
|
||||||
# wObtainedHiddenItemsFlags, each only after GiveItem succeeded.
|
|
||||||
# services/flysim/tools/resolve_wram.py is the second reading of all six.
|
|
||||||
'wSpriteIndex',
|
|
||||||
'wWalkCounter',
|
|
||||||
'wMapSpriteExtraData',
|
|
||||||
'wToggleableObjectFlags',
|
|
||||||
'wToggleableObjectList',
|
|
||||||
'wObtainedHiddenItemsFlags',
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,38 +57,6 @@ WANTED = {
|
||||||
# this one caught", and the only signal that needs no second rule to tell a
|
# this one caught", and the only signal that needs no second rule to tell a
|
||||||
# catch apart from a gift, a trade or an evolution.
|
# catch apart from a gift, a trade or an evolution.
|
||||||
'wCapturedMonSpecies': 'the species a ball just caught, 0 for none',
|
'wCapturedMonSpecies': 'the species a ball just caught, 0 for none',
|
||||||
# The engagement rewards (`docs/rewards-learning.md`, the operator 2026-09-23).
|
|
||||||
# DisplayTextID stores its argument here -- a sprite slot, or a sign's text id --
|
|
||||||
# before it looks the text up, so it names what a conversation is with.
|
|
||||||
'wSpriteIndex': "DisplayTextID's argument, a sprite slot or a text id",
|
|
||||||
# Non-zero for the frames of a step; the overworld only reads an A press at zero.
|
|
||||||
'wWalkCounter': 'frames left in the step the player is taking',
|
|
||||||
# Two bytes per sprite slot: (item id, 0) for an ITEM-flagged object_event,
|
|
||||||
# (trainer class, trainer number) for a TRAINER one, zeroes otherwise.
|
|
||||||
'wMapSpriteExtraData': 'per sprite slot: item id, or trainer class and number',
|
|
||||||
# One bit per global toggleable object; PickUpItem's HideObject sets an item
|
|
||||||
# ball's bit once GiveItem has succeeded.
|
|
||||||
'wToggleableObjectFlags': 'global hidden bits of every toggleable object',
|
|
||||||
# The current map's (sprite slot, global toggleable index) pairs, $ff-terminated.
|
|
||||||
'wToggleableObjectList': "this map's toggleable sprites and their global indices",
|
|
||||||
# One bit per hidden item, set by FoundHiddenItemText once GiveItem succeeded.
|
|
||||||
'wObtainedHiddenItemsFlags': 'hidden items already found',
|
|
||||||
# Row 60 (`docs/design/macros.md` 12.23): a move the cartridge will answer
|
|
||||||
# with "Nothing happened!" is not dealt. StatModifierUpEffect and
|
|
||||||
# StatModifierDownEffect (engine/battle/effects.asm) refuse on the stage byte
|
|
||||||
# (1 is -6, 7 normal, 13 is +6) and on the modified stat itself (1 or 999);
|
|
||||||
# SleepEffect, PoisonEffect and ParalyzeEffect on the target's status byte and
|
|
||||||
# type. Each is the battle_struct field or stage array those routines read.
|
|
||||||
'wPlayerMonStatMods': "the active Pokemon's six stat stages, 7 is normal",
|
|
||||||
'wEnemyMonStatMods': "the enemy's six stat stages, 7 is normal",
|
|
||||||
'wEnemyMonStatus': "the enemy's status condition byte",
|
|
||||||
'wEnemyMonType1': "the enemy's first type (wEnemyMonType2 follows it)",
|
|
||||||
'wEnemyMonAttack': "the enemy's modified Attack, Defense, Speed, Special",
|
|
||||||
'wBattleMonAttack': "the active Pokemon's modified Attack, Defense, Speed, Special",
|
|
||||||
# Mist and a substitute turn a stat-lowering move away, a substitute a
|
|
||||||
# poisoning one, and a target that must recharge is put to sleep whatever
|
|
||||||
# its status (MoveHitTest, CheckTargetSubstitute, SleepEffect).
|
|
||||||
'wEnemyBattleStatus2': "the enemy's Mist, substitute and recharge bits",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -108,33 +76,9 @@ WANTED = {
|
||||||
COUNTED = {
|
COUNTED = {
|
||||||
'NUM_HMS': ('constants/item_constants.asm', r'^\s*add_hm\s+\w+'),
|
'NUM_HMS': ('constants/item_constants.asm', r'^\s*add_hm\s+\w+'),
|
||||||
'NUM_TMS': ('constants/item_constants.asm', r'^\s*add_tm\s+\w+'),
|
'NUM_TMS': ('constants/item_constants.asm', r'^\s*add_tm\s+\w+'),
|
||||||
# The engagement rewards need two more, for the same reason: each is an
|
|
||||||
# `EQU const_value` over an enumeration, and each is the size of a declaration
|
|
||||||
# the cursor has to cross. `ds NUM_STATS` sits between wFontLoaded and
|
|
||||||
# wTrainerClass, the bracket of wWalkCounter; `flag_array NUM_CITY_MAPS` sits
|
|
||||||
# between wNumHoFTeams and wStatusFlags5, the bracket of the toggleable-object
|
|
||||||
# and hidden-item flags. Both are counted only up to the line that defines
|
|
||||||
# them, which is where `const_value` is read.
|
|
||||||
'NUM_STATS': (
|
|
||||||
'constants/battle_constants.asm', r'^\s*const\s+STAT_\w+', r'^DEF NUM_STATS\b'
|
|
||||||
),
|
|
||||||
'NUM_CITY_MAPS': (
|
|
||||||
'constants/map_constants.asm', r'^\s*map_const\s+\w+', r'^DEF NUM_CITY_MAPS\b'
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def count_in(root: Path, path: str, pattern: str, stop: str | None = None) -> int:
|
|
||||||
"""Lines of `path` matching `pattern`, up to the first line matching `stop`."""
|
|
||||||
text = (root / path).read_text()
|
|
||||||
if stop is not None:
|
|
||||||
match = re.search(stop, text, re.M)
|
|
||||||
if match is None:
|
|
||||||
raise SystemExit(f'{path}: no line matches {stop}')
|
|
||||||
text = text[: match.start()]
|
|
||||||
return len(re.findall(pattern, text, re.M))
|
|
||||||
|
|
||||||
|
|
||||||
def pinned(text: str) -> dict[str, int]:
|
def pinned(text: str) -> dict[str, int]:
|
||||||
"""Every address `symbols.rs` carries today, by symbol name."""
|
"""Every address `symbols.rs` carries today, by symbol name."""
|
||||||
return {
|
return {
|
||||||
|
|
@ -151,20 +95,19 @@ def constants(root: Path) -> dict[str, int]:
|
||||||
BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left
|
BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left
|
||||||
out, which kills the cursor at any declaration that uses it.
|
out, which kills the cursor at any declaration that uses it.
|
||||||
"""
|
"""
|
||||||
counted = {name: count_in(root, *spec) for name, spec in COUNTED.items()}
|
counted = {
|
||||||
|
name: len(re.findall(pattern, (root / path).read_text(), re.M))
|
||||||
|
for name, (path, pattern) in COUNTED.items()
|
||||||
|
}
|
||||||
pending: dict[str, str] = {}
|
pending: dict[str, str] = {}
|
||||||
sources = sorted((root / 'constants').glob('*.asm')) + sorted(
|
sources = sorted((root / 'constants').glob('*.asm')) + sorted(
|
||||||
(root / 'constants').glob('*.inc')
|
(root / 'constants').glob('*.inc')
|
||||||
)
|
)
|
||||||
for path in sources:
|
for path in sources:
|
||||||
text = path.read_text()
|
|
||||||
for name, value in re.findall(
|
for name, value in re.findall(
|
||||||
r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', text, re.M
|
r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M
|
||||||
):
|
):
|
||||||
pending.setdefault(name, value.strip())
|
pending.setdefault(name, value.strip())
|
||||||
# A name counted by `const` or `rb` is an expression over the running
|
|
||||||
# counter at its own line, so it overrides the raw `EQU const_value - 1`.
|
|
||||||
pending.update(enumerated(text))
|
|
||||||
out: dict[str, int] = dict(counted)
|
out: dict[str, int] = dict(counted)
|
||||||
while pending:
|
while pending:
|
||||||
progressed = False
|
progressed = False
|
||||||
|
|
@ -186,91 +129,6 @@ def constants(root: Path) -> dict[str, int]:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def enumerated(text: str) -> dict[str, str]:
|
|
||||||
"""The names one constants file defines by counting, as expressions.
|
|
||||||
|
|
||||||
rgbasm keeps two running counters the decomp enumerates with: `const_value`
|
|
||||||
(`const_def`, `const`, `const_skip`, `const_next` in macros/const.asm) and
|
|
||||||
`_RS` (`rsreset`, `rsset`, `DEF NAME rb/rw n`, `rb_skip`). This follows both
|
|
||||||
in file order and writes each name down as the *expression* the counter held
|
|
||||||
at its line, never as a number: [`constants`]' passes evaluate it with the
|
|
||||||
rest, so a count over a constant this tool cannot resolve stays unresolved
|
|
||||||
rather than becoming a guess. A counter form it does not know (a
|
|
||||||
`shift_const`, a non-literal step) kills that counter until the next reset.
|
|
||||||
"""
|
|
||||||
out: dict[str, str] = {}
|
|
||||||
value: str | None = None
|
|
||||||
step = '1'
|
|
||||||
rs: str | None = None
|
|
||||||
for raw in text.splitlines():
|
|
||||||
line = raw.split(';')[0].strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
match = re.fullmatch(r'const_def(?:\s+([^,]+?))?(?:\s*,\s*(.+))?', line)
|
|
||||||
if match:
|
|
||||||
value = f'({match.group(1) or "0"})'
|
|
||||||
step = f'({match.group(2) or "1"})'
|
|
||||||
continue
|
|
||||||
match = re.fullmatch(r'(?:const|const_export)\s+(\w+)', line)
|
|
||||||
if match:
|
|
||||||
if value is not None:
|
|
||||||
out[match.group(1)] = value
|
|
||||||
value = f'({value} + {step})'
|
|
||||||
continue
|
|
||||||
match = re.fullmatch(r'const_skip(?:\s+(.+))?', line)
|
|
||||||
if match:
|
|
||||||
if value is not None:
|
|
||||||
value = f'({value} + {step} * ({match.group(1) or "1"}))'
|
|
||||||
continue
|
|
||||||
match = re.fullmatch(r'const_next\s+(.+)', line)
|
|
||||||
if match:
|
|
||||||
value = f'({match.group(1)})'
|
|
||||||
continue
|
|
||||||
if line.startswith(('shift_const', 'dw_const')):
|
|
||||||
value = None
|
|
||||||
continue
|
|
||||||
if line == 'rsreset':
|
|
||||||
rs = '(0)'
|
|
||||||
continue
|
|
||||||
match = re.fullmatch(r'rsset\s+(.+)', line)
|
|
||||||
if match:
|
|
||||||
rs = f'({match.group(1)})' if '_RS' not in match.group(1) else None
|
|
||||||
continue
|
|
||||||
match = re.fullmatch(r'(rb|rw)_skip(?:\s+(.+))?', line)
|
|
||||||
if match:
|
|
||||||
if rs is not None:
|
|
||||||
unit = 1 if match.group(1) == 'rb' else 2
|
|
||||||
rs = f'({rs} + {unit} * ({match.group(2) or "1"}))'
|
|
||||||
continue
|
|
||||||
match = re.fullmatch(r'(?:DEF|def)\s+(\w+)\s+(rb|rw)(?:\s+(.+))?', line)
|
|
||||||
if match:
|
|
||||||
if rs is not None:
|
|
||||||
out[match.group(1)] = rs
|
|
||||||
unit = 1 if match.group(2) == 'rb' else 2
|
|
||||||
rs = f'({rs} + {unit} * ({match.group(3) or "1"}))'
|
|
||||||
continue
|
|
||||||
match = re.fullmatch(r'(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+(.+)', line)
|
|
||||||
if match is None:
|
|
||||||
# Anything else may be a macro that moves a counter this does not
|
|
||||||
# follow (`add_tm` advances `const_value`), so both stop here.
|
|
||||||
if not line.startswith(('ASSERT', 'assert', 'EXPORT', 'export')):
|
|
||||||
value = None
|
|
||||||
rs = None
|
|
||||||
continue
|
|
||||||
if re.search(r'\b(?:_RS|const_value)\b', match.group(2)):
|
|
||||||
expression = match.group(2)
|
|
||||||
if '_RS' in expression:
|
|
||||||
if rs is None:
|
|
||||||
continue
|
|
||||||
expression = re.sub(r'\b_RS\b', rs, expression)
|
|
||||||
if 'const_value' in expression:
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
expression = re.sub(r'\bconst_value\b', value, expression)
|
|
||||||
out[match.group(1)] = expression
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def number(token: str) -> int:
|
def number(token: str) -> int:
|
||||||
token = token.strip()
|
token = token.strip()
|
||||||
if token.startswith('$'):
|
if token.startswith('$'):
|
||||||
|
|
@ -318,27 +176,16 @@ def size_of(expression: str, known: dict[str, int]) -> int:
|
||||||
return value * scale
|
return value * scale
|
||||||
|
|
||||||
|
|
||||||
def macro_sizes(
|
def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]:
|
||||||
root: Path, known: dict[str, int], fields: dict[str, list[tuple[str, int]]] | None = None
|
"""Sizes of the RAM struct macros, counted from their own declarations."""
|
||||||
) -> dict[str, int]:
|
|
||||||
"""Sizes of the RAM struct macros, counted from their own declarations.
|
|
||||||
|
|
||||||
With `fields`, also each macro's `\\1Name::` field labels and their offsets,
|
|
||||||
for a macro whose whole body was sized: `battle_struct wEnemyMon` declares
|
|
||||||
`wEnemyMonStatus` at the offset its own lines put it.
|
|
||||||
"""
|
|
||||||
out: dict[str, int] = {}
|
out: dict[str, int] = {}
|
||||||
for path in sorted((root / 'macros').glob('*.asm')):
|
for path in sorted((root / 'macros').glob('*.asm')):
|
||||||
text = path.read_text()
|
text = path.read_text()
|
||||||
for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S):
|
for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S):
|
||||||
name, body = match.group(1), match.group(2)
|
name, body = match.group(1), match.group(2)
|
||||||
total = 0
|
total = 0
|
||||||
offsets: list[tuple[str, int]] = []
|
|
||||||
for line in body.splitlines():
|
for line in body.splitlines():
|
||||||
line = line.split(';')[0].strip()
|
line = line.split(';')[0].strip()
|
||||||
field = re.match(r'^\\1(\w+)::', line)
|
|
||||||
if field is not None and total is not None:
|
|
||||||
offsets.append((field.group(1), total))
|
|
||||||
# A struct macro labels each field with its argument
|
# A struct macro labels each field with its argument
|
||||||
# (`\\1YCoord:: db`), so the label is stripped and the
|
# (`\\1YCoord:: db`), so the label is stripped and the
|
||||||
# declaration after it is what reserves the bytes.
|
# declaration after it is what reserves the bytes.
|
||||||
|
|
@ -362,8 +209,6 @@ def macro_sizes(
|
||||||
break
|
break
|
||||||
if total is not None:
|
if total is not None:
|
||||||
out[name] = total
|
out[name] = total
|
||||||
if fields is not None:
|
|
||||||
fields[name] = offsets
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -371,8 +216,7 @@ def walk(
|
||||||
root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False
|
root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False
|
||||||
) -> tuple[dict[str, int], list[str], int]:
|
) -> tuple[dict[str, int], list[str], int]:
|
||||||
"""Resolve every symbol of wram.asm the anchored cursor can reach exactly."""
|
"""Resolve every symbol of wram.asm the anchored cursor can reach exactly."""
|
||||||
fields: dict[str, list[tuple[str, int]]] = {}
|
macros = macro_sizes(root, known)
|
||||||
macros = macro_sizes(root, known, fields)
|
|
||||||
lines = (root / 'ram/wram.asm').read_text().splitlines()
|
lines = (root / 'ram/wram.asm').read_text().splitlines()
|
||||||
cursor: int | None = None
|
cursor: int | None = None
|
||||||
resolved: dict[str, int] = {}
|
resolved: dict[str, int] = {}
|
||||||
|
|
@ -489,21 +333,6 @@ def walk(
|
||||||
continue
|
continue
|
||||||
if line.endswith('::') or re.fullmatch(r'\.\w+', line):
|
if line.endswith('::') or re.fullmatch(r'\.\w+', line):
|
||||||
continue
|
continue
|
||||||
# A struct macro's own field labels, at the offsets its body puts them:
|
|
||||||
# held back like any other label until the next pinned address agrees.
|
|
||||||
invocation = re.fullmatch(r'(\w+)\s+(w\w+)', line)
|
|
||||||
if invocation is not None and cursor is not None:
|
|
||||||
for field, offset in fields.get(invocation.group(1), []):
|
|
||||||
name = invocation.group(2) + field
|
|
||||||
if name not in table:
|
|
||||||
pending_run[name] = cursor + offset
|
|
||||||
elif table[name] != cursor + offset:
|
|
||||||
problems.append(
|
|
||||||
f'{name}: wram.asm gives ${cursor + offset:04x}, '
|
|
||||||
f'symbols.rs pins ${table[name]:04x}'
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
checked += 1
|
|
||||||
try:
|
try:
|
||||||
if cursor is not None:
|
if cursor is not None:
|
||||||
cursor += declaration_size(line, known, macros)
|
cursor += declaration_size(line, known, macros)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue