diff --git a/apps/stage/src/games/pokemon-red.ts b/apps/stage/src/games/pokemon-red.ts index d2968c9..e8ae7a8 100644 --- a/apps/stage/src/games/pokemon-red.ts +++ b/apps/stage/src/games/pokemon-red.ts @@ -79,8 +79,11 @@ export const pokemonRed: GameConfig = { }, // Ticker rows are noun phrases, so a row reads as a log line rather than as narration. + // `explore` carries four adapter rules since pokered-unique8-v7 -- new ground, an exit, a + // conversation indoors, an item picked up (docs/rewards-learning.md) -- so its copy names + // what all four are rather than the first of them. rewardCopy: { - explore: { label: 'new place', tier: 'quiet', dedupeMs: 20_000, collapsedNoun: 'new places' }, + explore: { label: 'new find', tier: 'quiet', dedupeMs: 20_000, collapsedNoun: 'new finds' }, area: { label: 'new area', tier: 'notable', dedupeMs: 20_000, collapsedNoun: 'new areas' }, wildwin: { label: 'wild win', tier: 'quiet', dedupeMs: 20_000, collapsedNoun: 'wild wins' }, trainer: { label: 'trainer beaten', tier: 'notable', dedupeMs: 0 }, diff --git a/apps/stage/tests/unit/ticker.test.ts b/apps/stage/tests/unit/ticker.test.ts index b139a3a..a2ab050 100644 --- a/apps/stage/tests/unit/ticker.test.ts +++ b/apps/stage/tests/unit/ticker.test.ts @@ -70,7 +70,7 @@ test('exploration ticks collapse into one counted row inside the dedupe window', assert.equal(queue.items().length, 1, 'three ticks, one row'); const row = queue.items()[0]; assert.equal(row?.count, 3); - assert.equal(row?.label, '3 new places'); + assert.equal(row?.label, '3 new finds'); assert.ok(Math.abs((row?.amount ?? 0) - 0.14) < 1e-9, 'values sum'); }); diff --git a/docs/architecture-tour.md b/docs/architecture-tour.md index 9f0f01b..d121287 100644 --- a/docs/architecture-tour.md +++ b/docs/architecture-tour.md @@ -89,7 +89,9 @@ decoder preset to use. 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 rewards: story flags, exploration coverage (capped per map), new areas, Pokédex entries, trainer - flags, decaying wild wins, badges. Version `pokered-unique8-v5`. + flags, decaying wild wins, badges, exits found outdoors, catches, conversations indoors and items + picked up (`docs/rewards-learning.md`). Version `pokered-unique8-v7`; a deploy that names the + previous version in `FLY_ACCEPT_ADAPTERS` migrates its checkpoints instead of refusing them. - Ratchet (`ratchet.rs`): a 38-rung ladder (boot, bedroom, Pallet Town, Oak's lab, starter, parcel, 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 diff --git a/docs/design/flysim.md b/docs/design/flysim.md index 5e7f0b5..ba17c1d 100644 --- a/docs/design/flysim.md +++ b/docs/design/flysim.md @@ -387,6 +387,17 @@ on-screen ticker cannot disagree with what the sim did. absent from a `v5` state and restores empty, which is the truth about a run that was never paid 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; `infra/bin/fly-reset-to-milestone ` keeps it and rewinds it. It archives both stores to a dated directory, rewrites `milestone-.checkpoint` with the ratchet's `attempts` and diff --git a/docs/design/macros-wram.md b/docs/design/macros-wram.md index 70bd517..13d0ae8 100644 --- a/docs/design/macros-wram.md +++ b/docs/design/macros-wram.md @@ -133,6 +133,28 @@ a trade and a Pokémon withdrawn from the PC. Reading a catch off it would need tell those apart. The cartridge's own flag needs none, which is why the row above is the one the 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 `HandleMenuInput` is shared by every menu in the game, so which menu is up is read from where it diff --git a/docs/integration.md b/docs/integration.md index 00f82b3..02ae563 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -177,6 +177,10 @@ achievements. | 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 | +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 `R`, then `m = tanh(R)`. PAM stimulation ran 80 to 400 ms depending on reward kind, with overlapping pulses taking their maximum. diff --git a/docs/rewards-learning.md b/docs/rewards-learning.md index 1975629..b5869db 100644 --- a/docs/rewards-learning.md +++ b/docs/rewards-learning.md @@ -1,6 +1,6 @@ # Rewards and learning -The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v6`. The code of record is +The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v7`. The code of record is `services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the 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 @@ -23,8 +23,10 @@ change what the fly can do. | `trainer` | `trainer` | +0.5 | 200 ms | Each named `EVENT_BEAT_*` flag once, except the flags classified as story milestones | | `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 | -| `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger | +| `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger. **Nothing on an indoor map** (since v7): the exit is still recorded, and pays 0 | | `catch` | `wildwin` | +0.30, +0.10 | 150 ms | A wild Pokémon kept by a ball: +0.30 for a species this run had never owned, +0.10 for a repeat; at most three payouts per species for the lifetime of the ledger | +| `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")` is `None` by test. The values in one frame sum into `R`, and the network reinforces once with @@ -43,6 +45,18 @@ currently reads "wild win". The event's own label, `CAUGHT #`, is what log, `/status` and the checkpoint. Changing the ticker copy means opening the feed's closed kind 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. What did move is the one +word that would have been untrue: the Pokémon Red ticker's `explore` row said "new place", which is +not what a conversation or an item is, and now says **"new find"** ("3 new finds" collapsed), which +is true of all four. The event labels -- `TALKED TO # IN AREA `, `READ SIGN # IN +AREA `, `FOUND 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 The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not only for @@ -82,6 +96,86 @@ 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 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 opens with the joypad taken, or on the frame a step onto a trigger tile ends; +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::sprite:` or +`talk::sign:` -- 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:` or `hidden:`, 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 Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other @@ -110,6 +204,10 @@ therefore replays none of it. ## 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 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 @@ -177,7 +275,15 @@ body picks the macro; the descending neurons press the buttons.** ## Honesty -The catalog now includes catches. The honesty panel's copy is not data-driven from the catalog -- +The catalog now includes conversations and items (v7). Paying for a conversation is the closest +the catalog has come to paying for a *button*: A is what opens one. It is still a reward, not a +press. Nothing in the adapter presses A, chooses when, or tells the fly who is there; the payout is +read out of WRAM after a conversation the fly's own buttons -- or the macro the mushroom body chose +-- opened and finished, and it is once per person or sign for the life of the run, so the thing +that is learned is "the people in a building are worth a visit", not "press A". It is also why the +rule demands evidence that the fly opened the box: text a script started pays nothing. + +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 there was nothing to regenerate and the copy is unchanged. The sentences below are where the argument lives. diff --git a/infra/05-deploy.sh b/infra/05-deploy.sh index b80557a..c6cd3db 100644 --- a/infra/05-deploy.sh +++ b/infra/05-deploy.sh @@ -300,8 +300,9 @@ if [ -n "$RELEASE_TARBALL" ]; then # # 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 - # may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 for the deploy - # that adds the catch reward. It only applies when the adapter segment is the + # may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 for the deploy + # that adds the engagement rewards (v7; v5 -> v6 was the catch reward's). + # It only applies when the adapter segment is the # ONLY difference between the two strings and the new build's adapter says it # 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 @@ -354,7 +355,7 @@ The difference is usually an adapter/ladder or dataset version bump. Three ways * deploy a build whose string matches (check out the commit the running release was built from), or * 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 - string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5). The run is kept; flysim applies the same + string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v6). The run is kept; flysim applies the same rule at restore. See docs/design/flysim.md, \"Restoring across an adapter version\", or * accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which archives ${state_dir}'s checkpoints to ${state_dir}. (kept, not deleted) and diff --git a/infra/docs/runbook.md b/infra/docs/runbook.md index a1a1cc6..81b7073 100644 --- a/infra/docs/runbook.md +++ b/infra/docs/runbook.md @@ -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. # (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: -FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 infra/05-deploy.sh +FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 infra/05-deploy.sh # 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 # same rule at restore. Anything else about the string differing is still a refusal. diff --git a/infra/env/example.env b/infra/env/example.env index 21afecc..4dae070 100644 --- a/infra/env/example.env +++ b/infra/env/example.env @@ -335,8 +335,9 @@ FLY_FEED_VIA=direct # 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 # 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. -# FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 +# 05-deploy writes it into /etc/fly/fly.env only while it is set. The v0.6.0 +# deploy (pokered-unique8-v7, the engagement rewards) is the one that needs: +# FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 # --- push mode -------------------------------------------------------------- # local: flypush.service stays disabled, everything else identical to prod. diff --git a/services/flysim/crates/fly-session-types/examples/update_fixtures.rs b/services/flysim/crates/fly-session-types/examples/update_fixtures.rs index d5b8e5b..d7e69cd 100644 --- a/services/flysim/crates/fly-session-types/examples/update_fixtures.rs +++ b/services/flysim/crates/fly-session-types/examples/update_fixtures.rs @@ -52,7 +52,7 @@ pub fn example_composition() -> LegacyComposition { byte_length: 1_048_576, format: "gb-rom".to_owned(), }, - adapter: "pokered-unique8-v6".to_owned(), + adapter: "pokered-unique8-v7".to_owned(), symbol_provenance: pokered.to_owned(), mode: "macros".to_owned(), macro_channels: decoder_vector("macros")["macroChannels"] @@ -71,7 +71,7 @@ pub fn example_composition() -> LegacyComposition { audio_sample_rate: 48_000, }, flysim_compatibility: format!( - "{}/pokered-unique8-v6/{}/{}/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:{pokered}/statefmt:199616-x86_64-unknown-linux-gnu", + "{}/pokered-unique8-v7/{}/{}/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:{pokered}/statefmt:199616-x86_64-unknown-linux-gnu", gameboy::KERNEL_VERSION, gameboy::FAFB_V783_FINGERPRINT, gameboy::PLASTICITY_VERSION, diff --git a/services/flysim/crates/fly-session-types/fixtures/gameboy-legacy.json b/services/flysim/crates/fly-session-types/fixtures/gameboy-legacy.json index d0023fc..c600e03 100644 --- a/services/flysim/crates/fly-session-types/fixtures/gameboy-legacy.json +++ b/services/flysim/crates/fly-session-types/fixtures/gameboy-legacy.json @@ -492,7 +492,7 @@ "byteLength": "1048576", "format": "gb-rom" }, - "adapter": "pokered-unique8-v6", + "adapter": "pokered-unique8-v7", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "mode": "macros", "macroChannels": [ @@ -560,9 +560,9 @@ "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" + "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" }, - "digest": "77d8a88ff7fea51eb29b1ae75fc9cf8c17c184e6a1e5a585e86399f41fd9c9b0", + "digest": "44916db0a0846d3338f50d4f24d9d4e0214fa239af7d15e4174213cd93b07e28", "recipeLines": [ "fly-session/composition-v1", "session=", diff --git a/services/flysim/crates/fly-session-types/fixtures/invalid.json b/services/flysim/crates/fly-session-types/fixtures/invalid.json index c0ef279..ccb9569 100644 --- a/services/flysim/crates/fly-session-types/fixtures/invalid.json +++ b/services/flysim/crates/fly-session-types/fixtures/invalid.json @@ -5202,7 +5202,7 @@ "byteLength": "1048576", "format": "gb-rom" }, - "adapter": "pokered-unique8-v6", + "adapter": "pokered-unique8-v7", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "mode": "macros", "macroChannels": [ @@ -5243,7 +5243,7 @@ "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" + "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" }, @@ -5267,7 +5267,7 @@ "byteLength": "1048576", "format": "gb-rom" }, - "adapter": "pokered-unique8-v6", + "adapter": "pokered-unique8-v7", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "mode": "raw", "macroChannels": [ @@ -5308,7 +5308,7 @@ "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" + "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" }, @@ -5332,7 +5332,7 @@ "byteLength": "1048576", "format": "gb-rom" }, - "adapter": "pokered-unique8-v6", + "adapter": "pokered-unique8-v7", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "mode": "macros", "macroChannels": [] @@ -5368,7 +5368,7 @@ "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" + "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" }, @@ -5392,7 +5392,7 @@ "byteLength": "1048576", "format": "gb-rom" }, - "adapter": "pokered-unique8-v6", + "adapter": "pokered-unique8-v7", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "mode": "macros", "macroChannels": [ @@ -5433,7 +5433,7 @@ "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" + "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" }, @@ -5457,7 +5457,7 @@ "byteLength": "1048576", "format": "gb-rom" }, - "adapter": "pokered-unique8-v6", + "adapter": "pokered-unique8-v7", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "mode": "macros", "macroChannels": [ @@ -5498,7 +5498,7 @@ "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" + "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" }, @@ -5522,7 +5522,7 @@ "byteLength": "1048576", "format": "gb-rom" }, - "adapter": "pokered-unique8-v6", + "adapter": "pokered-unique8-v7", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "mode": "macros", "macroChannels": [ @@ -5563,7 +5563,7 @@ "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" + "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" }, @@ -5587,7 +5587,7 @@ "byteLength": "1048576", "format": "gb-rom" }, - "adapter": "pokered-unique8-v6", + "adapter": "pokered-unique8-v7", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "mode": "macros", "macroChannels": [ @@ -5628,7 +5628,7 @@ "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" + "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" }, @@ -5652,7 +5652,7 @@ "byteLength": "1048576", "format": "gb-rom" }, - "adapter": "pokered-unique8-v6", + "adapter": "pokered-unique8-v7", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "mode": "macros", "macroChannels": [ @@ -5693,7 +5693,7 @@ "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" + "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" }, "reason": "the FLYSIM01 string and the declaration describe the same fly" }, @@ -5717,7 +5717,7 @@ "byteLength": "1048576", "format": "gb-rom" }, - "adapter": "pokered-unique8-v6", + "adapter": "pokered-unique8-v7", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "mode": "macros", "macroChannels": [ @@ -5758,7 +5758,7 @@ "episodePolicy": "legacy-ratchet-rollback-v1", "restore": "legacy-transient-reset", "checkpointFormatOfRecord": "FLYSESS1", - "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" + "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" }, "reason": "FLYSIM01 stays until RETIRE-01" }, diff --git a/services/flysim/crates/fly-session-types/fixtures/valid.json b/services/flysim/crates/fly-session-types/fixtures/valid.json index acc44de..3e58927 100644 --- a/services/flysim/crates/fly-session-types/fixtures/valid.json +++ b/services/flysim/crates/fly-session-types/fixtures/valid.json @@ -3337,7 +3337,7 @@ "byteLength": "1048576", "format": "gb-rom" }, - "adapter": "pokered-unique8-v6", + "adapter": "pokered-unique8-v7", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "mode": "macros", "macroChannels": [ @@ -3405,11 +3405,11 @@ "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" + "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" }, "note": "placeholder ROM digest; real macros-mode decoder digest and channels", - "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": "77d8a88ff7fea51eb29b1ae75fc9cf8c17c184e6a1e5a585e86399f41fd9c9b0" + "canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"82b6601f0390b742a67eca44ef935b49e16a73315db70f5644f1cbd21f7c8a52\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v7\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[\"macro_go_objective\",\"macro_go_out\",\"macro_go_warp\",\"macro_go_route\",\"macro_go_item\",\"macro_go_npc\",\"macro_go_frontier\",\"macro_go_shop\",\"macro_go_heal\",\"macro_talk\",\"macro_menu\",\"macro_next\",\"macro_yes\",\"macro_no\",\"macro_close\",\"macro_confirm\",\"macro_back\",\"macro_move_1\",\"macro_move_2\",\"macro_move_3\",\"macro_move_4\",\"macro_switch\",\"macro_item\",\"macro_throw_ball\",\"macro_run\",\"macro_buy_potion\",\"macro_buy_ball\",\"macro_buy_antidote\",\"macro_buy_repel\",\"macro_heal\",\"macro_leave\"],\"mode\":\"macros\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}", + "digest": "44916db0a0846d3338f50d4f24d9d4e0214fa239af7d15e4174213cd93b07e28" }, { "name": "an example legacy composition in raw mode", @@ -3431,7 +3431,7 @@ "byteLength": "1048576", "format": "gb-rom" }, - "adapter": "pokered-unique8-v6", + "adapter": "pokered-unique8-v7", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "mode": "raw", "macroChannels": [] @@ -3467,11 +3467,11 @@ "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" + "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" }, "note": "", - "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": "ac42e702ec6c9b09482b1ee335743ef85fe599a72e2c34a4d32aea67d67d0201" + "canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"6234e4a0363cfe82b8d515943c4f645a3960eda8fa5bf9465009061f80d50812\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v7\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[],\"mode\":\"raw\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}", + "digest": "568eceeb052ebf4fbd0c10661e227783cecc8ac7fee75ae6ba84335c684bfb7c" }, { "name": "a rank climb: slot saved, then the milestone capture", diff --git a/services/flysim/crates/flybrain-gb/README.md b/services/flysim/crates/flybrain-gb/README.md index 91cc6b4..fa9f917 100644 --- a/services/flysim/crates/flybrain-gb/README.md +++ b/services/flysim/crates/flybrain-gb/README.md @@ -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 | | `adapter` | `GameAdapter`, `RewardEvent`, `ProgressSnapshot`, `MemoryReader`, `adapter_for` | -| `pokemon_red` | The `pokered-unique8-v5` reward adapter, its catalog and its generated symbol table | +| `pokemon_red` | The `pokered-unique8-v7` reward adapter, its catalog and its generated symbol table | | `platformer` | The `sml-progress-v1` Super Mario Land adapter, its catalog and its RAM map | | `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 | diff --git a/services/flysim/crates/flybrain-gb/src/adapter.rs b/services/flysim/crates/flybrain-gb/src/adapter.rs index 9e24815..ef83db6 100644 --- a/services/flysim/crates/flybrain-gb/src/adapter.rs +++ b/services/flysim/crates/flybrain-gb/src/adapter.rs @@ -56,10 +56,10 @@ impl MemoryReader for &mut dyn MemoryReader { /// One reward payout in one frame. /// /// `kind` is an adapter-owned interned name (Pokémon: `milestone`, -/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`); it is the -/// key the statistics counters and the on-screen ticker group by. Field names -/// serialize exactly as the prototype's `RewardEvent` did, so a checkpoint -/// written by either implementation reads in the other. +/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`, +/// `talk`, `item`); it is the key the statistics counters and the on-screen ticker group +/// by. Field names serialize exactly as the prototype's `RewardEvent` did, so a +/// checkpoint written by either implementation reads in the other. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct RewardEvent { pub kind: &'static str, @@ -241,7 +241,7 @@ impl std::error::Error for AdapterError {} /// A game, as the sim loop sees it. pub trait GameAdapter: Send { /// Adapter version string, pinned into the checkpoint compatibility string. - /// Pokémon: `pokered-unique8-v6`. + /// Pokémon: `pokered-unique8-v7`. fn id(&self) -> &'static str; /// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration diff --git a/services/flysim/crates/flybrain-gb/src/compatibility.rs b/services/flysim/crates/flybrain-gb/src/compatibility.rs index b3fc959..3b01985 100644 --- a/services/flysim/crates/flybrain-gb/src/compatibility.rs +++ b/services/flysim/crates/flybrain-gb/src/compatibility.rs @@ -30,7 +30,7 @@ pub const PROTOTYPE_PLASTICITY_VERSION: &str = "fly-kc-mbon-rstdp-v2"; pub struct Compatibility<'a> { /// `kernelVersion(config)` from the neural library. pub neural_kernel_version: &'a str, - /// The adapter's version string, e.g. `pokered-unique8-v6`. + /// The adapter's version string, e.g. `pokered-unique8-v7`. pub adapter: &'a str, /// The dataset's seven SHA-256 digests joined with `:`. 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. /// /// Read by flysim at restore and by `infra/05-deploy.sh`'s compatibility gate. Comma- or -/// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v5`. +/// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v6`. pub const ACCEPT_ADAPTERS_ENV: &str = "FLY_ACCEPT_ADAPTERS"; /// What a build may do with a checkpoint whose compatibility string is not its own. @@ -180,7 +180,7 @@ mod tests { assert_eq!( fixture().prototype_string(), concat!( - "lif-1ms-f64-v2/pokered-unique8-v6/aa:bb:cc:dd:ee:ff:00/", + "lif-1ms-f64-v2/pokered-unique8-v7/aa:bb:cc:dd:ee:ff:00/", "fly-kc-mbon-rstdp-v2/", "binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/", "pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", @@ -194,37 +194,37 @@ mod tests { #[test] fn an_identical_string_restores_without_any_opt_in() { - let current = with_adapter("pokered-unique8-v6"); + let current = with_adapter("pokered-unique8-v7"); assert_eq!(decide(¤t, ¤t, &[], &[]), RestoreDecision::Exact); } #[test] - fn a_v5_checkpoint_restores_under_v6_only_with_the_opt_in() { - let old = with_adapter("pokered-unique8-v5"); - let new = with_adapter("pokered-unique8-v6"); - let migrates = ["pokered-unique8-v5"]; + fn a_v6_checkpoint_restores_under_v7_only_with_the_opt_in() { + let old = with_adapter("pokered-unique8-v6"); + let new = with_adapter("pokered-unique8-v7"); + let migrates = ["pokered-unique8-v6"]; assert!(matches!(decide(&old, &new, &migrates, &[]), RestoreDecision::Refuse(_))); assert_eq!( - decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v5"))), - RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() } + decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v6"))), + RestoreDecision::MigrateAdapter { from: "pokered-unique8-v6".to_string() } ); // And only for a pair the running adapter says it can migrate. assert!(matches!( - decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v5"))), + decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v6"))), RestoreDecision::Refuse(_) )); } #[test] fn nothing_but_the_adapter_segment_may_move() { - let migrates = ["pokered-unique8-v5"]; - let accepted = accepted_adapters(Some("pokered-unique8-v5")); - let new = with_adapter("pokered-unique8-v6"); + let migrates = ["pokered-unique8-v6"]; + let accepted = accepted_adapters(Some("pokered-unique8-v6")); + let new = with_adapter("pokered-unique8-v7"); // A different dataset, with the same adapter bump, is not a migration. let other_dataset = Compatibility { - adapter: "pokered-unique8-v5", + adapter: "pokered-unique8-v6", dataset_fingerprint: "00:11:22:33:44:55:66", ..fixture() } @@ -236,7 +236,7 @@ mod tests { // Neither is a different kernel, and neither is a string of another shape. let other_kernel = - Compatibility { adapter: "pokered-unique8-v5", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() } + Compatibility { adapter: "pokered-unique8-v6", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() } .string(); assert!(matches!( decide(&other_kernel, &new, &migrates, &accepted), @@ -250,8 +250,8 @@ mod tests { assert!(accepted_adapters(None).is_empty()); assert!(accepted_adapters(Some(" ")).is_empty()); assert_eq!( - accepted_adapters(Some("pokered-unique8-v5, pokered-unique8-v4")), - vec!["pokered-unique8-v5".to_string(), "pokered-unique8-v4".to_string()] + accepted_adapters(Some("pokered-unique8-v6, pokered-unique8-v5")), + vec!["pokered-unique8-v6".to_string(), "pokered-unique8-v5".to_string()] ); } diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/catalog.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/catalog.rs index 4e7a0e6..b009c4c 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/catalog.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/catalog.rs @@ -1,4 +1,4 @@ -//! The `pokered-unique8-v6` reward catalog. +//! The `pokered-unique8-v7` reward catalog. //! //! A direct port of the prototype's `src/reward/catalog.ts`, including the //! declaration order, which is the order `counts` and `last` serialize in. @@ -21,6 +21,8 @@ pub mod kind { pub const BADGE: &str = "badge"; pub const BOUNDARY: &str = "boundary"; 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. @@ -44,7 +46,7 @@ pub struct RewardRule { pub stimulation_ms: u32, } -pub const REWARDS: [RewardRule; 9] = [ +pub const REWARDS: [RewardRule; 11] = [ RewardRule { kind: kind::MILESTONE, label: "Story", @@ -125,6 +127,30 @@ pub const REWARDS: [RewardRule; 9] = [ value: 0.30, 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 @@ -229,12 +255,17 @@ mod tests { assert_eq!(rule(kind::CATCH).unwrap().value, 0.30); assert_eq!(CATCH_REPEAT_VALUE, 0.10); 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!(REWARDS.iter().all(|rule| rule.value > 0.0)); } #[test] - fn the_catch_rule_is_last_so_the_older_key_order_does_not_move() { + fn new_rules_are_appended_so_the_older_key_order_does_not_move() { let order: Vec<&str> = REWARDS.iter().map(|rule| rule.kind).collect(); assert_eq!( order, @@ -248,9 +279,11 @@ mod tests { kind::BADGE, kind::BOUNDARY, kind::CATCH, + kind::TALK, + kind::ITEM, ] ); - assert_eq!(index(kind::CATCH), Some(REWARDS.len() - 1)); + assert_eq!(index(kind::ITEM), Some(REWARDS.len() - 1)); } #[test] diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs new file mode 100644 index 0000000..cb42906 --- /dev/null +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs @@ -0,0 +1,421 @@ +//! 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, + opening: Option, + pending: Option, +} + +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. A script's text opens with the joypad already taken, or on the frame + /// a step onto a trigger tile ends; neither is `ready`. + /// 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 { + 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 { + 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 { + 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 { + 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 +} diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs index 7d4058f..2d6b390 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs @@ -1,4 +1,4 @@ -//! The Pokémon Red reward adapter, `pokered-unique8-v6`. +//! The Pokémon Red reward adapter, `pokered-unique8-v7`. //! //! A port of the prototype's `src/reward/pokemon-red.ts`. The gates and budgets //! are unchanged; `docs/rewards-learning.md` holds the live rule table and the @@ -6,9 +6,12 @@ //! ladder with the 38 rungs of `docs/design/ladder.md`; v5 adds one reward rule, //! `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 -//! operator's decision of 2026-09-22, which pays for keeping a wild Pokémon. +//! operator's decision of 2026-09-22, which pays for keeping a wild Pokémon; v7 adds +//! `talk` and `item` and stops `boundary` paying indoors, the operator's decision of +//! 2026-09-23 to pay for engaging with a building rather than for leaving it. pub mod catalog; +pub mod engage; #[cfg(test)] pub(crate) mod fake_wram; pub mod macros; @@ -33,33 +36,39 @@ use symbols::ram; /// Adapter version, pinned into the checkpoint compatibility string. /// -/// `v6` is the `catch` rule. Bumping it is what makes a `v5` checkpoint 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 -/// the operator names it in `FLY_ACCEPT_ADAPTERS` -/// ([`crate::compatibility::RestoreDecision`], `docs/design/flysim.md`). That -/// migration is safe in one direction only, and only for this pair: `v5`'s ledger is -/// a `v6` ledger with the catch counter absent, and an absent counter reads as zero. +/// `v7` is the engagement rules: `talk` and `item` pay, and `boundary` stops paying on an +/// indoor map (the operator, 2026-09-23). Bumping it is what makes a `v6` checkpoint a +/// decision rather than an accident: the compatibility string is compared whole before a +/// restore is attempted, so a `v6` run is refused by default and resumed only when the +/// operator names it in `FLY_ACCEPT_ADAPTERS` ([`crate::compatibility::RestoreDecision`], +/// `docs/design/flysim.md`). That migration is safe in one direction only, and only for this +/// pair: `v6`'s ledger is a `v7` ledger holding no `talk:`, `item:` or `hidden:` keys, and the +/// first sample after the restore seeds the item keys from the cartridge's own bits, so +/// nothing already picked up pays ([`engage::ItemFlags::seed`]). /// -/// (`v5` was the `boundary` rule, and rejected `v4` because a ledger that had never +/// (`v6` was the `catch` rule and migrated `v5` the same way: an absent counter reads as +/// zero. `v5` was the `boundary` rule, and rejected `v4` because a ledger that had never /// recorded a `boundary:` key could not be resumed as though its exits were already /// 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 -/// those is a migration: this one is, because nothing a `v5` ledger holds means -/// something different under `v6`.) -pub const REWARD_ADAPTER: &str = "pokered-unique8-v6"; +/// those is a migration; the last two are, because nothing an older ledger holds means +/// something different under the newer rules.) +pub const REWARD_ADAPTER: &str = "pokered-unique8-v7"; -/// Adapter ids whose checkpoints `v6` can read. +/// Adapter ids whose checkpoints `v7` can read. /// -/// Exactly one, and it is one because the `catch` rule adds a counter and changes nothing else: -/// a `v5` ledger restores as a `v6` ledger with `catchCounts` empty, and every other byte of the -/// state means what it meant. `v4` is not here -- its `seen` ledger holds no `boundary:` keys, so -/// resuming it would pay a second time for every exit the run had already found -- and neither is -/// `v3`, whose stored rank is a rung on a different ladder. +/// Exactly one, and it is one because the engagement rules add ledger keys and change nothing +/// else a `v6` state holds: every field keeps its name, shape and meaning, the `talk:` keys start +/// empty (no conversation was ever paid), and the `item:`/`hidden:` keys are seeded from the +/// game's own flags on the first sample, so no pickup made under `v6` pays when a rollback +/// un-takes it. The `boundary:` keys a `v6` run earned indoors stay in the ledger and mean what +/// they meant -- `exit_visited` still reads them. `v5` is not here: the live run is `v6`, and +/// the one migration the operator asked for is the one this adapter tests. `v4` and `v3` stay +/// refused for the reasons [`REWARD_ADAPTER`] gives. /// /// Listing an id here is necessary but not sufficient: `FLY_ACCEPT_ADAPTERS` must name it too /// (`crate::compatibility::decide`, `docs/design/flysim.md`). -pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v5"]; +pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v6"]; /// The only cartridge semantic rewards are enabled for. Even the canonical /// pret build stays disabled until reviewed; see `docs/rewards-learning.md`. @@ -89,8 +98,19 @@ pub const SUPPORTED_ROM: &str = /// empty, which is the truth about a run that was never paid for a catch. That is the /// whole of the documented `v5` -> `v6` migration; see /// [`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; +/// 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. /// /// The same cap and the same reason as the wild-KO rule's three: a species the fly can @@ -441,6 +461,13 @@ pub struct PokemonRedReward { battle: Option, 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, + /// Transient, recomputed every sample and never checkpointed. safe: bool, progress: u32, @@ -491,6 +518,8 @@ impl PokemonRedReward { stable: 0, battle: None, mode: "BOOT".to_string(), + talk: engage::TalkWatch::default(), + item_flags: None, safe: false, progress: 0, badges: 0, @@ -635,6 +664,10 @@ impl PokemonRedReward { self.stable = 0; self.battle = None; 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 = self.wild_wins.keys().cloned().collect(); for key in keys { self.replay_blocked.insert(&key); @@ -750,11 +783,13 @@ impl PokemonRedReward { self.boundary(&mut emitted, memory, map, x, y, width, height, true, brain_ms); self.initialized = true; } + self.items(&mut emitted, memory, brain_ms); let in_battle = memory.read8(ram::wIsInBattle); if in_battle == 1 || in_battle == 2 || in_battle == 255 { self.mode = "BATTLE".to_string(); self.stable = 0; + self.talk.interrupt(); let species_paid = self.counts.get(kind::SPECIES); if self.battle.is_none() && in_battle != 255 { self.battle = Some(Battle { @@ -849,6 +884,18 @@ impl PokemonRedReward { 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}"); self.stable = if self.location == location { self.stable + 1 } else { 1 }; self.location = location.clone(); @@ -1056,6 +1103,11 @@ impl PokemonRedReward { /// What this is not: a path. No button is chosen here, nothing is planned, and no map /// knowledge reaches the readout. It is a reward the fly may or may not find, like every other /// 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)] fn boundary( &mut self, @@ -1072,6 +1124,7 @@ impl PokemonRedReward { // 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. 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); for index in 0..u16::from(warps) { @@ -1112,6 +1165,10 @@ impl PokemonRedReward { continue; } let key = format!("{key}:{}", if on_exit { "on" } else { "near" }); + if !pays { + self.seen.insert(&key); + continue; + } self.once_scaled( emitted, &key, @@ -1124,6 +1181,34 @@ impl PokemonRedReward { } } + /// Pay each item picked up since the last playable sample, once per item for the lifetime of + /// the ledger (`docs/rewards-learning.md`, the operator 2026-09-23). + /// + /// The first sample that finds [`ITEMS_SEEDED`] absent -- a fresh adapter, or a `v6` state + /// restored under `v7` -- keys every item the cartridge already shows as taken and pays for + /// none of them. After that a pickup is a bit that rose between two playable samples + /// ([`engage::pickups`]) and pays unless its key is already in `seen`, which is what stops a + /// rollback that un-takes an item from paying for it twice. + fn items( + &mut self, + emitted: &mut Vec, + 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 { json!({ "version": STATE_VERSION, @@ -1287,6 +1372,8 @@ impl PokemonRedReward { self.mode = mode.to_string(); self.progress = progress; self.badges = badges; + self.talk.clear(); + self.item_flags = None; Ok(()) } } diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs index 014b4ad..e62c245 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs @@ -28,9 +28,11 @@ pub mod ram { pub const wJoyIgnore: u16 = 0xcd6b; // 52587 pub const wNumMovesMinusOne: u16 = 0xcd6c; // 52588 pub const wBattleResult: u16 = 0xcf0b; // 53003 + pub const wSpriteIndex: u16 = 0xcf13; // 53011 pub const wItemList: u16 = 0xcf7b; // 53115 pub const wListMenuID: u16 = 0xcf94; // 53140 pub const wFontLoaded: u16 = 0xcfc4; // 53188 + pub const wWalkCounter: u16 = 0xcfc5; // 53189 pub const wEnemyMonSpecies: u16 = 0xcfe5; // 53221 pub const wEnemyMonHP: u16 = 0xcfe6; // 53222 pub const wEnemyMonLevel: u16 = 0xcff3; // 53235 @@ -71,11 +73,15 @@ pub mod ram { pub const wSignCoords: u16 = 0xd4b1; // 54449 pub const wSignTextIDs: u16 = 0xd4d1; // 54481 pub const wNumSprites: u16 = 0xd4e1; // 54497 + pub const wMapSpriteExtraData: u16 = 0xd504; // 54532 pub const wTilesetBank: u16 = 0xd52b; // 54571 pub const wTilesetBlocksPtr: u16 = 0xd52c; // 54572 pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576 pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578 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 wStatusFlags6: u16 = 0xd732; // 55090 pub const wStatusFlags7: u16 = 0xd733; // 55091 diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs index 87559c1..dd43822 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs @@ -865,8 +865,8 @@ fn the_recent_ticker_keeps_the_newest_eight_events_newest_first() { #[test] fn the_adapter_reports_its_identity_and_pinned_rom() { let reward = PokemonRedReward::new(); - assert_eq!(reward.id(), "pokered-unique8-v6"); - assert_eq!(reward.migrates_from(), ["pokered-unique8-v5"]); + assert_eq!(reward.id(), "pokered-unique8-v7"); + assert_eq!(reward.migrates_from(), ["pokered-unique8-v6"]); assert!(reward.rom_allowed(SUPPORTED_ROM)); assert!(!reward.rom_allowed( "5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b" @@ -1356,3 +1356,541 @@ fn a_rung_earned_out_of_order_does_not_skip_the_ones_under_it() { f.visit(3, 3); 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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()); +} diff --git a/services/flysim/crates/flybrain-gb/tests/rom.rs b/services/flysim/crates/flybrain-gb/tests/rom.rs index 3132c61..06147cb 100644 --- a/services/flysim/crates/flybrain-gb/tests/rom.rs +++ b/services/flysim/crates/flybrain-gb/tests/rom.rs @@ -15,10 +15,10 @@ use std::time::Instant; -use flybrain_gb::adapter::MemoryReader; +use flybrain_gb::adapter::{MapExit, MemoryReader}; use flybrain_gb::emulator::{AUDIO_SILENCE_LEVEL, CPU_TICKS_PER_SECOND}; use flybrain_gb::pokemon_red::symbols::ram; -use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM}; +use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM, engage}; use flybrain_gb::{ DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, FRAMEBUFFER_LEN, GameAdapter, buttons, @@ -444,7 +444,10 @@ fn read_u32(bytes: &[u8], offset: &mut usize) -> Option { /// 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 -/// payouts are boundary events near the stairs". Two things are checked here that no synthetic +/// payouts are boundary events near the stairs". Since `pokered-unique8-v7` the bedroom is +/// *indoors* (`engage::indoor`: tileset `REDS_HOUSE_2`, neither outside nor bike-ridable), so +/// the same walk now proves the other half of the rule: the stairs enter the ledger where they +/// always did, and nothing is paid for them. Three things are checked here that no synthetic /// WRAM trace can check: /// /// 1. **the warp table layout.** `RedsHouse2F_Object` declares exactly one warp, @@ -453,9 +456,11 @@ fn read_u32(bytes: &[u8], offset: &mut usize) -> Option { /// 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 /// (7, 1), four tiles and five rows away, so the stairs are not baselined by the first -/// playable sample and have to be walked to. +/// playable sample and have to be walked to -- and the ledger records them from beside them. +/// 3. **that the cartridge calls the bedroom a building**: `wCurMapTileset` reads `REDS_HOUSE_2` +/// on the running game, so the rule pays nothing there. #[test] -fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() { +fn the_boundary_rule_records_the_bedroom_stairs_and_pays_nothing_indoors() { let mut emulator = skip_without_rom!(boot()); assert_eq!(emulator.rom_sha256(), SUPPORTED_ROM, "FLY_ROM is not the pinned cartridge"); let mut adapter = PokemonRedReward::new(); @@ -498,6 +503,8 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() { 0, "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) = (emulator.read_wram(ram::wXCoord), emulator.read_wram(ram::wYCoord)); assert!( @@ -509,6 +516,7 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() { let mut seed: u64 = 0x5eed_1234_5678_9abc; let mut boundary: Vec<(u8, u8, f64)> = Vec::new(); let mut kinds: Vec<&'static str> = Vec::new(); + let mut found_at: Option<(u8, u8)> = None; for frame in 0..24_000u32 { seed = seed .wrapping_mul(6_364_136_223_846_793_005) @@ -531,26 +539,19 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() { boundary.push((x, y, event.value)); } } + 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) { break; } } eprintln!("boundary: payouts in the bedroom {boundary:?}, all kinds {kinds:?}"); - let (x, y, value) = *boundary.first().expect("the stairs were never found"); + let (x, y) = found_at.expect("the stairs were never found"); assert!( x.abs_diff(7) + y.abs_diff(1) <= 1, - "the first boundary payout was at ({x}, {y}), not next to the stairs at (7, 1)" + "the stairs entered the ledger at ({x}, {y}), not next to the stairs at (7, 1)" ); - assert!( - 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})"); - } + assert!(boundary.is_empty(), "an indoor exit pays nothing, got {boundary:?}"); } diff --git a/services/flysim/crates/flysim/src/reset.rs b/services/flysim/crates/flysim/src/reset.rs index acf575e..d610950 100644 --- a/services/flysim/crates/flysim/src/reset.rs +++ b/services/flysim/crates/flysim/src/reset.rs @@ -321,7 +321,7 @@ mod tests { wall_ms: 1_700_000_000_000, rom_sha256: "ab".repeat(32), emulator_frame: 12_345, - compatibility: "kernel/pokered-unique8-v6/fingerprint".to_string(), + compatibility: "kernel/pokered-unique8-v7/fingerprint".to_string(), speed: 1.0, buttons: 0, rank_since_ms: 4_242.0, diff --git a/services/flysim/crates/flysim/src/snapshot.rs b/services/flysim/crates/flysim/src/snapshot.rs index dbcced1..c43be4c 100644 --- a/services/flysim/crates/flysim/src/snapshot.rs +++ b/services/flysim/crates/flysim/src/snapshot.rs @@ -242,7 +242,7 @@ pub struct FeedMacroOutcome { /// Reward categories the feed reports counts for. The adapter's own interned kinds /// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, -/// `catch`) map onto these. +/// `catch`, `talk`, `item`) map onto these. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum RewardKind { @@ -291,6 +291,15 @@ impl RewardKind { // for it on the same frame, so the `pokedex` counter already moves. Mapping `catch` // there as well would count one event twice. "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` says + // "new find" so that the row is true of all four (`apps/stage/src/games/pokemon-red.ts`). + "talk" => Self::Explore, + "item" => Self::Explore, // The platformer. "band" => Self::Explore, "coin" => Self::Wildwin, @@ -749,6 +758,8 @@ mod tests { assert_eq!(RewardKind::from_adapter("nonsense"), None); assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore)); 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] diff --git a/services/flysim/crates/flysim/tests/compat_migration.rs b/services/flysim/crates/flysim/tests/compat_migration.rs index ce47b05..2234d56 100644 --- a/services/flysim/crates/flysim/tests/compat_migration.rs +++ b/services/flysim/crates/flysim/tests/compat_migration.rs @@ -1,15 +1,21 @@ -//! A `v5` checkpoint restored under `v6`: accepted with the opt-in, refused without it. +//! A `v6` checkpoint restored under `v7`: accepted with the opt-in, refused without it. //! //! The unit tests in `flybrain-gb` cover the decision function and the adapter's own state //! migration separately. This is the two of them against one artefact: a real `FLYSIM01` -//! envelope carrying a `pokered-unique8-v5` compatibility string and a `v5` reward ledger — +//! envelope carrying a `pokered-unique8-v6` compatibility string and a `v6` reward ledger — //! written, encoded, decoded, and then put through exactly what `Sim::try_restore` puts a -//! candidate through. +//! candidate through, and then one sample of a game in which items were already taken. //! //! No ROM and no dataset, deliberately. Building a `Sim` would need both, and neither is part of -//! the question: what decides a restore is the compatibility string and `import_state`. +//! the question: what decides a restore is the compatibility string and `import_state`, and what +//! decides the seed is the first sample's read of the cartridge's own item bits. +//! +//! (`v5` -> `v6`, the catch rule's migration, was this same file; `v6` no longer migrates from +//! anything, so that pair is refused now like any other.) use flybrain_gb::GameAdapter; +use flybrain_gb::MemoryReader; +use flybrain_gb::pokemon_red::symbols::ram; use flybrain_gb::compatibility::{RestoreDecision, accepted_adapters, decide}; use flybrain_gb::pokemon_red::PokemonRedReward; use flysim::store::{self, RuntimeState}; @@ -24,24 +30,30 @@ fn compatibility(adapter: &str) -> String { ) } -/// A `v5` reward ledger: `STATE_VERSION` 4, every field `v5` wrote, and **no** `catchCounts`. +/// A `v6` reward ledger: `STATE_VERSION` 4, every field `v6` wrote, and **no** `talk:`, +/// `item:`, `hidden:` or `items:seeded` key in `seen`. /// /// Written out by hand rather than exported from an adapter, because an exported one would be a -/// `v6` state with the counter deleted — this is the shape the release box's checkpoints really -/// carry, field for field. -fn v5_reward() -> serde_json::Value { +/// `v7` state with keys deleted — this is the shape the release box's checkpoints really carry, +/// field for field, including a `boundary:` key earned indoors (Red's staircase, map 38) that +/// `v7` would not have paid for and keeps anyway. +fn v6_reward() -> serde_json::Value { serde_json::json!({ "version": 4, - "seen": ["adventure", "map:0", "early:outside", "dex:3", "boundary:0:edge:n:near"], + "seen": [ + "adventure", "map:0", "early:outside", "dex:3", "boundary:0:edge:n:near", + "boundary:38:1:7:on" + ], "tiles": ["0:5:6", "0:5:7"], "tileCounts": { "0": 2 }, "wildWins": { "0:112:4": 2 }, + "catchCounts": { "176": 1 }, "replayBlocked": [], "counts": { "milestone": 2, "exploration": 0, "map": 1, "species": 1, - "trainer": 0, "battle": 2, "badge": 0, "boundary": 1 + "trainer": 0, "battle": 2, "badge": 0, "boundary": 2, "catch": 1 }, - "total": 2.05, + "total": 2.45, "recent": [{ "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 }], "last": { "species": { "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 } }, "initialized": true, @@ -55,7 +67,7 @@ fn v5_reward() -> serde_json::Value { }) } -fn v5_checkpoint() -> Vec { +fn v6_checkpoint() -> Vec { use flybrain_core::decoder::DecoderState; use flybrain_core::lif::LifState; use flybrain_core::ordered::NumberMap; @@ -105,12 +117,12 @@ fn v5_checkpoint() -> Vec { wall_ms: 1_790_000_000_000, rom_sha256: flybrain_gb::pokemon_red::SUPPORTED_ROM.to_string(), emulator_frame: 1_000_000, - compatibility: compatibility("pokered-unique8-v5"), + compatibility: compatibility("pokered-unique8-v6"), speed: 1.0, buttons: 0, rank_since_ms: 1_000.0, last_event_id: 4_242, - reward: v5_reward(), + reward: v6_reward(), ratchet: flybrain_gb::RatchetState { best: 3, attempts: 1, recoveries: 4, ..Default::default() }, emulator: vec![3; 64], framebuffer: vec![0; 32], @@ -121,13 +133,13 @@ fn v5_checkpoint() -> Vec { } #[test] -fn a_v5_checkpoint_is_refused_under_v6_without_the_opt_in() { - let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes"); +fn a_v6_checkpoint_is_refused_under_v7_without_the_opt_in() { + let checkpoint = store::decode(&v6_checkpoint()).expect("the fixture decodes"); let adapter = PokemonRedReward::new(); let current = compatibility(adapter.id()); - assert_ne!(checkpoint.runtime.compatibility, current, "v6 is not v5"); + assert_ne!(checkpoint.runtime.compatibility, current, "v7 is not v6"); - for opt_in in [None, Some(""), Some("pokered-unique8-v4"), Some("some-other-adapter")] { + for opt_in in [None, Some(""), Some("pokered-unique8-v5"), Some("some-other-adapter")] { assert!( matches!( decide( @@ -143,9 +155,18 @@ fn a_v5_checkpoint_is_refused_under_v6_without_the_opt_in() { } } +/// A flat 64 KiB address space: the one thing a sample reads. +struct Wram(Vec); + +impl MemoryReader for Wram { + fn read8(&mut self, address: u16) -> u8 { + self.0[address as usize] + } +} + #[test] -fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_zero() { - let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes"); +fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_seeded() { + let checkpoint = store::decode(&v6_checkpoint()).expect("the fixture decodes"); let mut adapter = PokemonRedReward::new(); let current = compatibility(adapter.id()); @@ -154,25 +175,25 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z &checkpoint.runtime.compatibility, ¤t, adapter.migrates_from(), - &accepted_adapters(Some("pokered-unique8-v5")), + &accepted_adapters(Some("pokered-unique8-v6")), ), - RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() } + RestoreDecision::MigrateAdapter { from: "pokered-unique8-v6".to_string() } ); // The migration itself: `import_state`, exactly as `Sim::try_restore` calls it. - adapter.import_state(&checkpoint.runtime.reward).expect("a v5 ledger is a valid v6 ledger"); + adapter.import_state(&checkpoint.runtime.reward).expect("a v6 ledger is a valid v7 ledger"); let after = adapter.export_state(); - assert_eq!(after["catchCounts"], serde_json::json!({}), "the new counter starts at 0"); - assert_eq!(after["counts"]["catch"], serde_json::json!(0)); + assert_eq!(after["counts"]["talk"], serde_json::json!(0), "no conversation was ever paid"); + assert_eq!(after["counts"]["item"], serde_json::json!(0), "nor any item"); - // And nothing else moved: every field the v5 state carried round-trips to the same value, - // and the only key v6 adds is the counter. + // And nothing else moved: every field the v6 state carried round-trips to the same value, + // and v7 adds no field at all -- its ledgers are keys in `seen`. // // `counts` is the one field that is not byte-identical, and it is not a change of meaning: - // it serializes every kind in the catalog, so a v6 state lists `catch` where a v5 state had - // nothing to list. Every kind the v5 state did carry keeps its number. - let before = v5_reward(); + // it serializes every kind in the catalog, so a v7 state lists `talk` and `item` where a v6 + // state had nothing to list. Every kind the v6 state did carry keeps its number. + let before = v6_reward(); for (key, value) in before.as_object().unwrap() { if key == "counts" { for (kind, count) in value.as_object().unwrap() { @@ -184,7 +205,7 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z .keys() .filter(|kind| !value.as_object().unwrap().contains_key(*kind)) .collect(); - assert_eq!(added, vec!["catch"], "v6 counts one more kind and no others"); + assert_eq!(added, vec!["talk", "item"], "v7 counts two more kinds and no others"); continue; } assert_eq!(&after[key], value, "{key} must survive the migration byte for byte"); @@ -195,7 +216,29 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z .keys() .filter(|key| !before.as_object().unwrap().contains_key(*key)) .collect(); - assert_eq!(added, vec!["catchCounts"], "v6 adds one field and no others"); + assert!(added.is_empty(), "v7 adds no field: {added:?}"); + + // The first sample of the restored game. Under v6 the fly took an item ball (global + // toggleable index 0x2a) and a hidden item (index 9); v6 paid for neither. The sample seeds + // both into the ledger and pays nothing, which is the "no retroactive payout" half. + let mut wram = Wram(vec![0; 0x10000]); + wram.0[ram::wStatusFlags6 as usize] = 1; + wram.0[ram::wPartyCount as usize] = 1; + wram.0[ram::wCurMapWidth as usize] = 10; + wram.0[ram::wCurMapHeight as usize] = 9; + wram.0[ram::wXCoord as usize] = 5; + wram.0[ram::wYCoord as usize] = 7; + wram.0[(ram::wToggleableObjectFlags + 0x2a / 8) as usize] |= 1 << (0x2a % 8); + wram.0[(ram::wObtainedHiddenItemsFlags + 1) as usize] |= 1 << 1; + assert!(adapter.sample(&mut wram, 2_000.0).is_empty(), "the seed pays nothing"); + let seen = adapter.export_state()["seen"].clone(); + for key in ["item:42", "hidden:9", "items:seeded"] { + assert!(seen.as_array().unwrap().contains(&serde_json::json!(key)), "{key} seeded"); + } + assert!( + !seen.as_array().unwrap().iter().any(|key| key.as_str().unwrap().starts_with("talk:")), + "the talk ledger starts empty" + ); // The rest of what a restore reads is untouched by the migration. assert_eq!(adapter.progress().rank, 3); @@ -206,11 +249,11 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z #[test] fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() { let adapter = PokemonRedReward::new(); - let accepted = accepted_adapters(Some("pokered-unique8-v5")); + let accepted = accepted_adapters(Some("pokered-unique8-v6")); let current = compatibility(adapter.id()); - // A v5 string whose state format also moved: a different build, not a rule change. - let other_abi = compatibility("pokered-unique8-v5").replace("199616", "199617"); + // A v6 string whose state format also moved: a different build, not a rule change. + let other_abi = compatibility("pokered-unique8-v6").replace("199616", "199617"); assert!(matches!( decide(&other_abi, ¤t, adapter.migrates_from(), &accepted), RestoreDecision::Refuse(_) @@ -221,4 +264,15 @@ fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() { decide(¤t, ¤t, adapter.migrates_from(), &[]), 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(_) + )); } diff --git a/services/flysim/crates/flysim/tests/integration.rs b/services/flysim/crates/flysim/tests/integration.rs index 73c4692..2c6c36e 100644 --- a/services/flysim/crates/flysim/tests/integration.rs +++ b/services/flysim/crates/flysim/tests/integration.rs @@ -267,7 +267,7 @@ async fn the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_kil let (status, versions) = service.get("/status"); assert_eq!(status, 200); assert_eq!(versions["version"]["kernel"], json!("lif-1ms-f64-v2")); - assert_eq!(versions["version"]["adapter"], json!("pokered-unique8-v6")); + assert_eq!(versions["version"]["adapter"], json!("pokered-unique8-v7")); assert!( versions["version"]["dataset"].as_str().unwrap_or_default().len() > 32, "the dataset fingerprint is in /status: {}", diff --git a/services/flysim/crates/flysim/tests/rom_engage.rs b/services/flysim/crates/flysim/tests/rom_engage.rs new file mode 100644 index 0000000..4221a6e --- /dev/null +++ b/services/flysim/crates/flysim/tests/rom_engage.rs @@ -0,0 +1,432 @@ +//! The engagement rewards against the real cartridge: a conversation indoors and an item ball. +//! +//! Gated on `FLY_ROM` *and* on a checkpoint, the way every ROM test in this workspace is, and +//! skips cleanly without either: +//! +//! ```sh +//! FLY_ROM="$HOME/roms/pokemon-red.gb" \ +//! FLY_ENGAGE_CHECKPOINT=.local/checkpoints/ \ +//! 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> { + 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 { + 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, + /// `(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 { + 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> = 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 { + 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 { + 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::>() + ); + // 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); +} diff --git a/services/flysim/tools/gen_symbols.py b/services/flysim/tools/gen_symbols.py index b499e43..959ba83 100644 --- a/services/flysim/tools/gen_symbols.py +++ b/services/flysim/tools/gen_symbols.py @@ -209,6 +209,22 @@ EXTRA_RAM = ( # services/flysim/tools/resolve_wram.py is the second reading of it, from ram/wram.asm at # this commit, bracketed by wFontLoaded and wForcePlayerToChooseMon. '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', ) diff --git a/services/flysim/tools/resolve_wram.py b/services/flysim/tools/resolve_wram.py index c7973cb..1572a84 100644 --- a/services/flysim/tools/resolve_wram.py +++ b/services/flysim/tools/resolve_wram.py @@ -57,6 +57,22 @@ WANTED = { # 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. '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', } @@ -76,9 +92,33 @@ WANTED = { COUNTED = { 'NUM_HMS': ('constants/item_constants.asm', r'^\s*add_hm\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]: """Every address `symbols.rs` carries today, by symbol name.""" return { @@ -95,10 +135,7 @@ def constants(root: Path) -> dict[str, int]: BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left out, which kills the cursor at any declaration that uses it. """ - counted = { - name: len(re.findall(pattern, (root / path).read_text(), re.M)) - for name, (path, pattern) in COUNTED.items() - } + counted = {name: count_in(root, *spec) for name, spec in COUNTED.items()} pending: dict[str, str] = {} sources = sorted((root / 'constants').glob('*.asm')) + sorted( (root / 'constants').glob('*.inc')