Merge feat/engage-reward: the fly is paid for talking to people indoors and picking up items, not for leaving buildings (adapter v7)
This commit is contained in:
commit
5d527f0910
30 changed files with 1939 additions and 153 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <N>` keeps it and rewinds it. It archives both stores to a
|
||||
dated directory, rewrites `milestone-<N>.checkpoint` with the ratchet's `attempts` and
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 #<species>`, 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 #<slot> IN AREA <map>`, `READ SIGN #<id> IN
|
||||
AREA <map>`, `FOUND ITEM #<item>`, `FOUND A HIDDEN ITEM` -- reach the event log, `/status` and the
|
||||
checkpoint. Both also reset the stage's stall meter, which counts `explore`: engaging with a
|
||||
building is progress in the sense the operator asked for.
|
||||
|
||||
## Catch rewards
|
||||
|
||||
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:<map>:sprite:<slot>` or
|
||||
`talk:<map>:sign:<text id>` -- the same "map and object index" the macros' `talked` ledger uses,
|
||||
but **not** that ledger: the macros' ledger is session state and is thrown away on a restore; this
|
||||
one is checkpointed and survives a rollback, so talking to the same person again, after a restore
|
||||
or not, pays nothing. The trainer the fly speaks to before a battle is a person and pays once; the
|
||||
nurse, a clerk and a sign each pay once per map.
|
||||
|
||||
**`item`, +0.15.** Read from the cartridge's own "this one has been taken" bits, on any map.
|
||||
An item ball is one of the map's toggleable sprites (`wToggleableObjectList`, sprite slot and
|
||||
global index) whose `wMapSpriteExtraData` is `(item id, 0)` -- the shape `LoadMapHeader` writes for
|
||||
an `ITEM` `object_event` and for nothing else (a trainer's is `(class, number)` with numbers from
|
||||
1, a person's two zeroes); `PickUpItem` sets its global bit in `wToggleableObjectFlags` through
|
||||
`HideObject`, and only after `GiveItem` succeeded, so a full bag pays nothing. A hidden item is a
|
||||
bit of `wObtainedHiddenItemsFlags`, set by `FoundHiddenItemText` after `GiveItem` and by nothing
|
||||
else; hidden *coins* have their own bitset and are not items. Either pays when its bit rises
|
||||
between two playable samples, keyed `item:<global index>` or `hidden:<index>`, once for the life
|
||||
of the ledger. A gift item from a script (the Old Amber, a TM from a person) is not an item ball:
|
||||
the conversation pays `talk`, and the item nothing.
|
||||
|
||||
**The seed.** The first playable sample that finds the key `items:seeded` absent -- a fresh
|
||||
adapter, or a `v6` ledger restored under `v7` -- writes a key for every bit the game already shows
|
||||
as taken and pays for none of them, so a rollback to a slot from before a `v6`-era pickup cannot
|
||||
pay for taking it again. The two item balls a script *reveals* are left out of the seed, because
|
||||
their bits are set from a new game until Giovanni's defeat clears them: the Rocket Hideout's Silph
|
||||
Scope and Lift Key (`$87`, `$88`), the only `ITEM` entries `data/maps/toggleable_objects.asm` starts
|
||||
`OFF`.
|
||||
|
||||
**`boundary` indoors.** Every exit on an indoor map is still written to the ledger, so
|
||||
`exit_visited` answers exactly what it did and the macros see no change, but nothing is paid. A
|
||||
town's doors, a route's edges, the forest's gates and a cave's ladders pay as before. One
|
||||
consequence, measured on the cartridge (`tests/rom_engage.rs`): for the thirty-odd frames of
|
||||
`PlayMapChangeSound` the cartridge has written the destination into `wCurMap` while the tileset and
|
||||
the warp table are still the map being left, so the exit the fly is standing on is classified by
|
||||
the map it belongs to. Walking into a building through a town door still pays that door's on-exit
|
||||
half, 0.10, once, keyed under the building's id as it always was; walking out pays nothing.
|
||||
|
||||
**The scale.** A building's worth of engagement -- a few people, a sign, perhaps a ball -- is
|
||||
0.3 to 0.6: more than the 0.15 its door paid for being left, less than a new Pokédex entry per
|
||||
person, far below a badge. Everything is once per thing for the lifetime of the ledger, so no
|
||||
building can be farmed.
|
||||
|
||||
## Gates
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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}.<timestamp> (kept, not deleted) and
|
||||
|
|
|
|||
|
|
@ -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 <release-env> <release-tarball>
|
||||
FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 infra/05-deploy.sh <release-env> <release-tarball>
|
||||
# The gate logs "the adapter version is the only difference, and it is named; the run is KEPT
|
||||
# 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.
|
||||
|
|
|
|||
5
infra/env/example.env
vendored
5
infra/env/example.env
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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=<sessionId>",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
421
services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs
Normal file
421
services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs
Normal file
|
|
@ -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<Armed>,
|
||||
opening: Option<Opening>,
|
||||
pending: Option<Conversation>,
|
||||
}
|
||||
|
||||
impl TalkWatch {
|
||||
pub fn clear(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
/// A sample that is not the overworld -- a battle. Whatever the fly was doing before it is
|
||||
/// not what opens the next text box, so the arming is dropped; a conversation already open
|
||||
/// (a trainer the fly spoke to) stays pending and pays when its box is finally closed.
|
||||
pub fn interrupt(&mut self) {
|
||||
self.armed = None;
|
||||
self.opening = None;
|
||||
}
|
||||
|
||||
/// One overworld sample (`wIsInBattle` zero, every playability gate passed).
|
||||
///
|
||||
/// A conversation pays when all of this holds, each read out of WRAM:
|
||||
///
|
||||
/// 1. **The fly started it.** On the last sample before the text box opened
|
||||
/// (`wFontLoaded` bit 0 rising) the fly had the joypad -- no ignored buttons, no simulated
|
||||
/// input, no scripted movement ([`state::controllable`]) -- was standing still
|
||||
/// (`wWalkCounter` zero, which is the only state the overworld reads A in) and stood on the
|
||||
/// tile it is on now. 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<Conversation> {
|
||||
let open = memory.read8(ram::wFontLoaded) & poke::BIT_FONT_LOADED != 0;
|
||||
if !open {
|
||||
// A box that closes before its argument was ever seen to change: a short text about
|
||||
// the same thing as the last one. The argument was written before a letter printed,
|
||||
// so it is this text's; the dialogue box must have been drawn for it.
|
||||
let argument = memory.read8(ram::wSpriteIndex);
|
||||
if let Some(opening) = self.opening.take()
|
||||
&& opening.dialogue
|
||||
&& opening.map == map
|
||||
&& let Some(thing) = thing_named(memory, argument)
|
||||
{
|
||||
self.pending = Some(Conversation { map, thing });
|
||||
}
|
||||
let finished = self
|
||||
.pending
|
||||
.take()
|
||||
.filter(|conversation| conversation.map == map);
|
||||
let ready = state::controllable(memory) && memory.read8(ram::wWalkCounter) == 0;
|
||||
let stale = argument;
|
||||
self.armed = Some(Armed {
|
||||
map,
|
||||
x,
|
||||
y,
|
||||
ready,
|
||||
stale,
|
||||
});
|
||||
return finished;
|
||||
}
|
||||
if let Some(armed) = self.armed.take()
|
||||
&& armed.ready
|
||||
&& armed.map == map
|
||||
&& armed.x == x
|
||||
&& armed.y == y
|
||||
&& self.pending.is_none()
|
||||
&& indoor(memory.read8(ram::wCurMapTileset))
|
||||
{
|
||||
self.opening = Some(Opening {
|
||||
map,
|
||||
samples: 0,
|
||||
stale: armed.stale,
|
||||
dialogue: false,
|
||||
});
|
||||
}
|
||||
if let Some(opening) = self.opening.as_mut() {
|
||||
opening.samples += 1;
|
||||
opening.dialogue |= state::text_box(memory).waiting;
|
||||
let map = opening.map;
|
||||
let argument = memory.read8(ram::wSpriteIndex);
|
||||
if argument != opening.stale || opening.samples >= ARGUMENT_SETTLED {
|
||||
// One reading, whichever way it goes: the argument this text was opened with,
|
||||
// and only while the dialogue box is what is drawn (not the start menu's).
|
||||
let dialogue = state::text_box(memory).waiting;
|
||||
self.opening = None;
|
||||
if dialogue && let Some(thing) = thing_named(memory, argument) {
|
||||
self.pending = Some(Conversation { map, thing });
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The step `facing` points at from `(x, y)`, or `None` off the top or left of the map.
|
||||
fn ahead(x: u8, y: u8, facing: Facing) -> Option<(u8, u8)> {
|
||||
let (dx, dy) = facing.delta();
|
||||
let x = u8::try_from(i16::from(x) + dx).ok()?;
|
||||
let y = u8::try_from(i16::from(y) + dy).ok()?;
|
||||
Some((x, y))
|
||||
}
|
||||
|
||||
/// `wMapSpriteExtraData`'s two bytes for a sprite slot (1-based, as `hSpriteIndex` is).
|
||||
fn extra_data(memory: &mut dyn MemoryReader, slot: u8) -> (u8, u8) {
|
||||
let entry = ram::wMapSpriteExtraData + (u16::from(slot) - 1) * 2;
|
||||
(memory.read8(entry), memory.read8(entry + 1))
|
||||
}
|
||||
|
||||
/// Whether sprite `slot` is an item ball: `LoadMapHeader` writes `(item id, 0)` into its extra
|
||||
/// data for an `ITEM`-flagged `object_event`, `(trainer class, trainer number)` for a `TRAINER`
|
||||
/// one -- trainer numbers start at 1 -- and two zeroes for everything else.
|
||||
fn is_item_ball(memory: &mut dyn MemoryReader, slot: u8) -> bool {
|
||||
let (item, second) = extra_data(memory, slot);
|
||||
item != 0 && second == 0
|
||||
}
|
||||
|
||||
/// `DisplayTextID`'s argument, if it names something the player is facing.
|
||||
///
|
||||
/// The caller also asks for the bottom dialogue box ([`state::text_box`]'s `waiting`):
|
||||
/// `DisplayTextIDInit` draws it for every text id but the start menu's, which it draws at the top
|
||||
/// right instead.
|
||||
fn thing_named(memory: &mut dyn MemoryReader, argument: u8) -> Option<Thing> {
|
||||
if argument == 0 {
|
||||
// TEXT_START_MENU.
|
||||
return None;
|
||||
}
|
||||
let player: Player = state::player(memory)?;
|
||||
let one = ahead(player.x, player.y, player.facing)?;
|
||||
let sprites = memory.read8(ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
|
||||
if argument <= sprites {
|
||||
let npc = state::npcs(memory)
|
||||
.into_iter()
|
||||
.find(|npc| npc.slot == argument)?;
|
||||
let at = (npc.x, npc.y);
|
||||
let reached = at == one
|
||||
|| (ahead(one.0, one.1, player.facing) == Some(at)
|
||||
&& state::counter_tiles(memory)
|
||||
.iter()
|
||||
.any(|tile| *tile != poke::NO_COUNTER_TILE));
|
||||
if reached && !is_item_ball(memory, argument) {
|
||||
return Some(Thing::Sprite(argument));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
state::signs(memory)
|
||||
.into_iter()
|
||||
.any(|sign| sign.text_id == argument && (sign.x, sign.y) == one)
|
||||
.then_some(Thing::Sign(argument))
|
||||
}
|
||||
|
||||
/// `wToggleableObjectFlags` is `flag_array $100`.
|
||||
const TOGGLE_BYTES: usize = 32;
|
||||
/// `wObtainedHiddenItemsFlags` is `flag_array MAX_HIDDEN_ITEMS`, and `MAX_HIDDEN_ITEMS` is 112
|
||||
/// (`constants/item_constants.asm`).
|
||||
const HIDDEN_ITEM_BYTES: usize = 14;
|
||||
/// `wToggleableObjectList` is `ds 16 * 2 + 1`: sixteen `(sprite slot, global index)` pairs and a
|
||||
/// `$ff` terminator.
|
||||
const TOGGLE_LIST_ENTRIES: u16 = 16;
|
||||
|
||||
/// The two item balls a script *reveals*: `TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4` (`$87`, the Silph
|
||||
/// Scope) and `TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5` (`$88`, the Lift Key), the only `ITEM`
|
||||
/// `object_event`s `data/maps/toggleable_objects.asm` starts `OFF`, and the only item entries
|
||||
/// `constants/toggle_constants.asm` does not mark "X, never toggled by a script".
|
||||
///
|
||||
/// Every other item ball's bit is clear from a new game until `PickUpItem` sets it, so a set bit
|
||||
/// is a pickup. These two are set from the start and cleared when Giovanni's defeat shows them,
|
||||
/// so seeding them as "already taken" would withhold two payouts for ever. They are the only
|
||||
/// bits the seed leaves out.
|
||||
pub const SCRIPT_SHOWN_ITEM_BALLS: [u8; 2] = [0x87, 0x88];
|
||||
|
||||
/// The cartridge's two "this item has been taken" bitsets, as of one sample.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ItemFlags {
|
||||
toggles: [u8; TOGGLE_BYTES],
|
||||
hidden: [u8; HIDDEN_ITEM_BYTES],
|
||||
}
|
||||
|
||||
fn bit(bytes: &[u8], index: usize) -> bool {
|
||||
bytes
|
||||
.get(index / 8)
|
||||
.is_some_and(|byte| byte & (1 << (index % 8)) != 0)
|
||||
}
|
||||
|
||||
impl ItemFlags {
|
||||
pub fn read(memory: &mut dyn MemoryReader) -> Self {
|
||||
let mut toggles = [0; TOGGLE_BYTES];
|
||||
for (offset, byte) in toggles.iter_mut().enumerate() {
|
||||
*byte = memory.read8(ram::wToggleableObjectFlags + offset as u16);
|
||||
}
|
||||
let mut hidden = [0; HIDDEN_ITEM_BYTES];
|
||||
for (offset, byte) in hidden.iter_mut().enumerate() {
|
||||
*byte = memory.read8(ram::wObtainedHiddenItemsFlags + offset as u16);
|
||||
}
|
||||
Self { toggles, hidden }
|
||||
}
|
||||
|
||||
/// Ledger keys for every item this state already shows as taken: the seed that stops a
|
||||
/// pickup made before the rule existed from paying after a rollback un-takes it.
|
||||
///
|
||||
/// Every set toggle bit but [`SCRIPT_SHOWN_ITEM_BALLS`] -- which includes the bits of people
|
||||
/// a script has hidden, harmlessly, because only an item ball's key is ever looked up -- and
|
||||
/// every set hidden-item bit.
|
||||
pub fn seed(&self) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
for index in 0..TOGGLE_BYTES * 8 {
|
||||
if bit(&self.toggles, index) && !SCRIPT_SHOWN_ITEM_BALLS.contains(&(index as u8)) {
|
||||
keys.push(ball_key(index as u8));
|
||||
}
|
||||
}
|
||||
for index in 0..HIDDEN_ITEM_BYTES * 8 {
|
||||
if bit(&self.hidden, index) {
|
||||
keys.push(hidden_key(index as u8));
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
}
|
||||
|
||||
/// One item the fly has just picked up.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Pickup {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
pub fn ball_key(global: u8) -> String {
|
||||
format!("item:{global}")
|
||||
}
|
||||
|
||||
pub fn hidden_key(index: u8) -> String {
|
||||
format!("hidden:{index}")
|
||||
}
|
||||
|
||||
/// Items whose "taken" bit rose between `before` and `now`.
|
||||
///
|
||||
/// - **An item ball** is one of this map's toggleable sprites (`wToggleableObjectList`) whose
|
||||
/// extra data says item ([`is_item_ball`]). `PickUpItem` sets its global bit in
|
||||
/// `wToggleableObjectFlags` through `HideObject`, and only after `GiveItem` succeeded, so a full
|
||||
/// bag pays nothing. A toggleable that is not an item -- a person a script hides, a legendary
|
||||
/// after its battle -- is never looked at.
|
||||
/// - **A hidden item** is a bit of `wObtainedHiddenItemsFlags`, which `FoundHiddenItemText` sets
|
||||
/// after `GiveItem` succeeded and nothing else in the game writes. Hidden coins have a bitset of
|
||||
/// their own and are not items.
|
||||
///
|
||||
/// A bit that was already set on the previous sample is not a pickup, which is what keeps a
|
||||
/// restored or seeded state from paying for anything it already holds.
|
||||
pub fn pickups(memory: &mut dyn MemoryReader, before: &ItemFlags, now: &ItemFlags) -> Vec<Pickup> {
|
||||
let mut out = Vec::new();
|
||||
if now == before {
|
||||
// The overwhelmingly common frame: nothing was taken, and nothing more need be read.
|
||||
return out;
|
||||
}
|
||||
let sprites = memory.read8(ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
|
||||
for entry in 0..TOGGLE_LIST_ENTRIES {
|
||||
let slot = memory.read8(ram::wToggleableObjectList + entry * 2);
|
||||
if slot == 0xff {
|
||||
break;
|
||||
}
|
||||
let global = memory.read8(ram::wToggleableObjectList + entry * 2 + 1);
|
||||
let index = usize::from(global);
|
||||
if slot == 0 || slot > sprites || !bit(&now.toggles, index) || bit(&before.toggles, index) {
|
||||
continue;
|
||||
}
|
||||
let (item, second) = extra_data(memory, slot);
|
||||
if item != 0 && second == 0 {
|
||||
out.push(Pickup {
|
||||
key: ball_key(global),
|
||||
label: format!("FOUND ITEM #{item}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
for index in 0..HIDDEN_ITEM_BYTES * 8 {
|
||||
if bit(&now.hidden, index) && !bit(&before.hidden, index) {
|
||||
out.push(Pickup {
|
||||
key: hidden_key(index as u8),
|
||||
label: "FOUND A HIDDEN ITEM".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -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<Battle>,
|
||||
mode: String,
|
||||
|
||||
/// The `talk` rule's frame-to-frame watch. Transient: never checkpointed, cleared by a
|
||||
/// rollback and by a restore.
|
||||
talk: engage::TalkWatch,
|
||||
/// The item bitsets as of the last playable sample, so a pickup is a bit that *rose*.
|
||||
/// Transient for the same reason.
|
||||
item_flags: Option<engage::ItemFlags>,
|
||||
|
||||
/// Transient, recomputed every sample and never checkpointed.
|
||||
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<String> = 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<RewardEvent>,
|
||||
memory: &mut impl MemoryReader,
|
||||
brain_ms: f64,
|
||||
) {
|
||||
let now = engage::ItemFlags::read(memory);
|
||||
if !self.seen.contains(ITEMS_SEEDED) {
|
||||
for key in now.seed() {
|
||||
self.seen.insert(&key);
|
||||
}
|
||||
self.seen.insert(ITEMS_SEEDED);
|
||||
} else if let Some(before) = &self.item_flags {
|
||||
for pickup in engage::pickups(memory, before, &now) {
|
||||
self.once(emitted, &pickup.key, kind::ITEM, pickup.label, false, brain_ms);
|
||||
}
|
||||
}
|
||||
self.item_flags = Some(now);
|
||||
}
|
||||
|
||||
pub fn export_state(&self) -> Value {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<RewardEvent> {
|
||||
self.memory.set(ram::wFontLoaded, 1);
|
||||
self.dialogue_box(true);
|
||||
let mut events = Vec::new();
|
||||
for _ in 0..delay {
|
||||
events.extend(self.sample());
|
||||
}
|
||||
self.memory.set(ram::wSpriteIndex, argument);
|
||||
events.extend(self.sample());
|
||||
events
|
||||
}
|
||||
|
||||
fn close_box(&mut self) -> Vec<RewardEvent> {
|
||||
self.memory.set(ram::wFontLoaded, 0);
|
||||
self.dialogue_box(false);
|
||||
self.sample()
|
||||
}
|
||||
|
||||
/// One conversation as the cartridge draws it: a sample with the box closed (the frame the A
|
||||
/// press was read on), `DisplayTextIDInit` setting the font bit and `DisplayTextID` copying
|
||||
/// `argument` into `wSpriteIndex`, a few frames of text, and `CloseTextDisplay`.
|
||||
fn talk(&mut self, argument: u8) -> Vec<RewardEvent> {
|
||||
let mut events = self.sample();
|
||||
events.extend(self.open_box(argument, 20));
|
||||
events.extend(self.sample());
|
||||
events.extend(self.close_box());
|
||||
events
|
||||
}
|
||||
|
||||
fn talk_events(&mut self, argument: u8) -> Vec<RewardEvent> {
|
||||
self.talk(argument).into_iter().filter(|event| event.kind == kind::TALK).collect()
|
||||
}
|
||||
|
||||
/// `wToggleableObjectList` for this map: `(sprite slot, global index)` pairs and `$ff`.
|
||||
fn toggle_list(&mut self, entries: &[(u8, u8)]) {
|
||||
for (index, (slot, global)) in entries.iter().enumerate() {
|
||||
self.memory.set(ram::wToggleableObjectList + index as u16 * 2, *slot);
|
||||
self.memory.set(ram::wToggleableObjectList + index as u16 * 2 + 1, *global);
|
||||
}
|
||||
self.memory.set(ram::wToggleableObjectList + entries.len() as u16 * 2, 0xff);
|
||||
}
|
||||
|
||||
fn set_bit(&mut self, base: u16, index: u16, on: bool) {
|
||||
let address = base + index / 8;
|
||||
let mask = 1 << (index % 8);
|
||||
let byte = self.memory.bytes[address as usize];
|
||||
self.memory.set(address, if on { byte | mask } else { byte & !mask });
|
||||
}
|
||||
|
||||
fn item_events(&mut self) -> Vec<RewardEvent> {
|
||||
self.sample().into_iter().filter(|event| event.kind == kind::ITEM).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indoors_is_the_cartridges_building_tilesets_and_nothing_else() {
|
||||
// CheckIfInOutsideMap's outside (OVERWORLD, PLATEAU) and BikeRidingTilesets (OVERWORLD,
|
||||
// FOREST, UNDERGROUND, SHIP_PORT, CAVERN) are the two tables; indoor is neither.
|
||||
let outdoor = [0, 3, 11, 14, 17, 23];
|
||||
for tileset in 0..24u8 {
|
||||
assert_eq!(engage::indoor(tileset), !outdoor.contains(&tileset), "tileset {tileset}");
|
||||
}
|
||||
assert!(!engage::indoor(24), "a tileset past the table is not a building");
|
||||
assert!(!engage::indoor(0xff));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conversation_the_fly_opens_indoors_pays_once_when_its_box_closes() {
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(maps::PEWTER_GYM, GYM);
|
||||
f.visit(5, 5);
|
||||
f.sprite(1, 5, 4, (0, 0));
|
||||
f.face(FACING_UP);
|
||||
|
||||
// Nothing on the frames the box is open: the payout waits for it to close.
|
||||
let mut events = f.sample();
|
||||
events.extend(f.open_box(1, 20));
|
||||
assert_eq!(count_of_kind(&events, kind::TALK), 0, "not while the box is open");
|
||||
let events = f.close_box();
|
||||
assert_eq!(kinds(&events), ["talk"]);
|
||||
assert_eq!(events[0].value, 0.10);
|
||||
assert_eq!(events[0].stimulation_ms, 100);
|
||||
assert_eq!(labels(&events), [format!("TALKED TO #1 IN AREA {}", maps::PEWTER_GYM)]);
|
||||
|
||||
// Talking to the same person again, as often as the fly likes, is not a farm.
|
||||
for _ in 0..5 {
|
||||
assert!(f.talk_events(1).is_empty(), "one payout per (map, object) for the ledger's life");
|
||||
}
|
||||
|
||||
// A second person on the same map is a second key.
|
||||
f.sprite(2, 4, 5, (0, 0));
|
||||
f.face(FACING_LEFT);
|
||||
assert_eq!(kinds(&f.talk_events(2)), ["talk"]);
|
||||
|
||||
// A sign is a text id past the sprite slots, on the tile the player faces.
|
||||
f.memory.set(ram::wNumSigns, 1);
|
||||
f.memory.set(ram::wSignCoords, 6);
|
||||
f.memory.set(ram::wSignCoords + 1, 5);
|
||||
f.memory.set(ram::wSignTextIDs, 7);
|
||||
f.face(FACING_DOWN);
|
||||
let sign = f.talk_events(7);
|
||||
assert_eq!(labels(&sign), [format!("READ SIGN #7 IN AREA {}", maps::PEWTER_GYM)]);
|
||||
assert!(f.talk_events(7).is_empty());
|
||||
assert_eq!(f.reward.statistics().counts[kind::TALK], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_slot_on_another_indoor_map_is_another_conversation() {
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(maps::PEWTER_GYM, GYM);
|
||||
f.visit(5, 5);
|
||||
f.sprite(1, 5, 4, (0, 0));
|
||||
f.face(FACING_UP);
|
||||
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
|
||||
f.on_map(maps::OAKS_LAB, HOUSE);
|
||||
f.visit(5, 5);
|
||||
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn talking_outdoors_in_a_forest_or_in_a_cave_pays_nothing() {
|
||||
for (map, tileset) in [
|
||||
(maps::PEWTER_CITY, 0),
|
||||
(maps::VIRIDIAN_FOREST, FOREST),
|
||||
(maps::MT_MOON_1F, CAVERN),
|
||||
] {
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(map, tileset);
|
||||
f.visit(5, 5);
|
||||
f.sprite(1, 5, 4, (0, 0));
|
||||
f.face(FACING_UP);
|
||||
assert!(f.talk_events(1).is_empty(), "map {map}, tileset {tileset}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_the_fly_did_not_open_pays_nothing() {
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(maps::PEWTER_GYM, GYM);
|
||||
f.visit(5, 5);
|
||||
f.sprite(1, 5, 4, (0, 0));
|
||||
f.face(FACING_UP);
|
||||
|
||||
// A script that took the joypad before it drew the box: a trainer walking up, a guard.
|
||||
f.memory.set(ram::wJoyIgnore, 0xff);
|
||||
assert!(f.talk_events(1).is_empty(), "the joypad was the cartridge's");
|
||||
f.memory.set(ram::wJoyIgnore, 0);
|
||||
|
||||
// Simulated input, and scripted movement.
|
||||
f.memory.set(ram::wSimulatedJoypadStatesIndex, 3);
|
||||
assert!(f.talk_events(1).is_empty(), "the buttons were simulated");
|
||||
f.memory.set(ram::wSimulatedJoypadStatesIndex, 0);
|
||||
f.memory.set(ram::wStatusFlags5, 0x80);
|
||||
assert!(f.talk_events(1).is_empty(), "the movement was scripted");
|
||||
f.memory.set(ram::wStatusFlags5, 0);
|
||||
|
||||
// A trigger tile: the box opens on the frame a step ends, with the walk counter still
|
||||
// running on the sample before it -- the overworld never reads A mid-step.
|
||||
f.memory.set(ram::wWalkCounter, 1);
|
||||
assert!(f.talk_events(1).is_empty(), "a step onto a trigger tile is not a press");
|
||||
f.memory.set(ram::wWalkCounter, 0);
|
||||
|
||||
// Text about someone the fly is not facing: a script naming a sprite across the room.
|
||||
f.sprite(2, 9, 9, (0, 0));
|
||||
assert!(f.talk_events(2).is_empty(), "not the thing in front");
|
||||
// A text id that is no sign in front of the fly.
|
||||
assert!(f.talk_events(9).is_empty(), "no sign there");
|
||||
// The start menu is text id 0.
|
||||
assert!(f.talk_events(0).is_empty(), "the start menu is not a conversation");
|
||||
|
||||
// A script that opens the box with the joypad taken and hands it back mid-conversation:
|
||||
// the frames after it are the fly's, but the box did not open on one of them.
|
||||
f.memory.set(ram::wJoyIgnore, 0xff);
|
||||
f.sample();
|
||||
f.open_box(1, 0);
|
||||
f.memory.set(ram::wJoyIgnore, 0);
|
||||
f.sample();
|
||||
f.sample();
|
||||
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0, "no open edge after a ready frame");
|
||||
|
||||
// START in front of someone: the menu's box is not the dialogue box, and `wSpriteIndex`
|
||||
// still names the last person spoken to for the frames before the menu's own id arrives.
|
||||
f.memory.set(ram::wSpriteIndex, 1);
|
||||
f.sample();
|
||||
f.memory.set(ram::wFontLoaded, 1);
|
||||
for _ in 0..20 {
|
||||
f.sample();
|
||||
}
|
||||
f.memory.set(ram::wSpriteIndex, 0);
|
||||
f.sample();
|
||||
f.memory.set(ram::wFontLoaded, 0);
|
||||
assert_eq!(count_of_kind(&f.sample(), kind::TALK), 0, "the start menu is not a conversation");
|
||||
|
||||
// The argument never arrives inside the window: nothing is guessed.
|
||||
f.memory.set(ram::wSpriteIndex, 0);
|
||||
f.sample();
|
||||
f.open_box(1, 60);
|
||||
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0, "a second of frames, then no more");
|
||||
|
||||
// None of that was recorded: the person is still worth one conversation.
|
||||
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conversation_that_ends_somewhere_else_pays_nothing() {
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(maps::PEWTER_GYM, GYM);
|
||||
f.visit(5, 5);
|
||||
f.sprite(1, 5, 4, (0, 0));
|
||||
f.face(FACING_UP);
|
||||
f.sample();
|
||||
f.open_box(1, 20);
|
||||
// The script warped the fly out while the box was up.
|
||||
f.on_map(maps::PEWTER_CITY, 0);
|
||||
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0);
|
||||
|
||||
// A rollback mid-conversation, likewise: the watch is transient.
|
||||
f.on_map(maps::PEWTER_GYM, GYM);
|
||||
f.sample();
|
||||
f.open_box(1, 20);
|
||||
f.reward.clear_transient();
|
||||
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_ball_is_an_item_not_a_conversation() {
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(maps::OAKS_LAB, HOUSE);
|
||||
f.visit(5, 5);
|
||||
f.sprite(1, 5, 4, (0x14, 0));
|
||||
f.face(FACING_UP);
|
||||
assert!(f.talk_events(1).is_empty());
|
||||
// A trainer is a person: `(class, number)`, and numbers start at one.
|
||||
f.sprite(2, 4, 5, (0xcb, 2));
|
||||
f.face(FACING_LEFT);
|
||||
assert_eq!(kinds(&f.talk_events(2)), ["talk"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clerk_across_a_counter_is_in_reach_only_where_the_tileset_has_counters() {
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(maps::VIRIDIAN_MART, MART);
|
||||
f.visit(5, 5);
|
||||
f.sprite(1, 5, 3, (0, 0));
|
||||
f.face(FACING_UP);
|
||||
assert!(f.talk_events(1).is_empty(), "two tiles away with no counter tiles in the header");
|
||||
f.memory.set(ram::wTilesetTalkingOverTiles, 0x18);
|
||||
assert_eq!(kinds(&f.talk_events(1)), ["talk"], "IsSpriteOrSignInFrontOfPlayer's long range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rollback_or_a_restore_cannot_replay_a_conversation() {
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(maps::PEWTER_GYM, GYM);
|
||||
f.visit(5, 5);
|
||||
f.sprite(1, 5, 4, (0, 0));
|
||||
f.face(FACING_UP);
|
||||
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
|
||||
|
||||
f.reward.clear_transient();
|
||||
let state = f.reward.export_state();
|
||||
assert!(
|
||||
state["seen"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!(format!("talk:{}:sprite:1", maps::PEWTER_GYM))),
|
||||
"the ledger is the checkpointed `seen` set, not the macros' session ledger"
|
||||
);
|
||||
let mut restored = PokemonRedReward::new();
|
||||
restored.import_state(&state).unwrap();
|
||||
f.reward = restored;
|
||||
f.visit(5, 5);
|
||||
assert!(f.talk_events(1).is_empty());
|
||||
assert_eq!(f.reward.statistics().counts[kind::TALK], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_ball_pays_once_when_its_bit_rises_and_never_again() {
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(maps::VIRIDIAN_FOREST, FOREST);
|
||||
f.sprite(3, 5, 4, (0x14, 0)); // a Potion
|
||||
f.toggle_list(&[(3, 0x2a)]);
|
||||
f.visit(5, 5);
|
||||
|
||||
// PickUpItem: GiveItem, then HideObject sets the ball's global bit.
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
|
||||
let events = f.item_events();
|
||||
assert_eq!(labels(&events), ["FOUND ITEM #20"]);
|
||||
assert_eq!(events[0].value, 0.15);
|
||||
assert_eq!(events[0].stimulation_ms, 120);
|
||||
assert!(f.item_events().is_empty(), "a bit that stays set pays once");
|
||||
|
||||
// A rollback to a slot where the ball is still there, and the fly takes it again.
|
||||
f.reward.clear_transient();
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x2a, false);
|
||||
f.sample();
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
|
||||
assert!(f.item_events().is_empty(), "once per item for the run");
|
||||
assert_eq!(f.reward.statistics().counts[kind::ITEM], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_item_balls_bit_pays_and_only_after_a_pickup() {
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(maps::PEWTER_CITY, 0);
|
||||
f.sprite(1, 2, 2, (0, 0)); // a person a script hides
|
||||
f.sprite(2, 3, 3, (0xcb, 1)); // a trainer
|
||||
f.sprite(4, 6, 6, (0x14, 0)); // a ball
|
||||
f.toggle_list(&[(1, 0x03), (2, 0x04), (4, 0x05)]);
|
||||
f.visit(5, 5);
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x03, true);
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x04, true);
|
||||
assert!(f.item_events().is_empty(), "a hidden person or trainer is not an item");
|
||||
// A bag too full to take the ball leaves the bit clear, and nothing pays.
|
||||
assert!(f.item_events().is_empty());
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x05, true);
|
||||
assert_eq!(kinds(&f.item_events()), ["item"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_item_pays_once_per_index() {
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(maps::PEWTER_GYM, GYM);
|
||||
f.visit(5, 5);
|
||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, true);
|
||||
assert_eq!(labels(&f.item_events()), ["FOUND A HIDDEN ITEM"]);
|
||||
assert!(f.item_events().is_empty());
|
||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 18, true);
|
||||
assert_eq!(kinds(&f.item_events()), ["item"]);
|
||||
f.reward.clear_transient();
|
||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, false);
|
||||
f.sample();
|
||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, true);
|
||||
assert!(f.item_events().is_empty(), "a rollback cannot replay a hidden item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_already_taken_are_seeded_and_the_two_script_shown_balls_are_not() {
|
||||
let mut f = Fixture::new();
|
||||
f.memory.set(ram::wCurMap, maps::REDS_HOUSE_2F);
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x10, true);
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x87, true);
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x88, true);
|
||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 4, true);
|
||||
assert!(f.item_events().is_empty(), "the seed pays nothing");
|
||||
let seen = f.reward.export_state()["seen"].clone();
|
||||
let seen: Vec<&str> = seen.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
|
||||
for key in ["item:16", "hidden:4", "items:seeded"] {
|
||||
assert!(seen.contains(&key), "{key}");
|
||||
}
|
||||
assert!(!seen.contains(&"item:135") && !seen.contains(&"item:136"));
|
||||
|
||||
// Giovanni beaten: the script shows the Silph Scope's ball (its bit clears), and the fly
|
||||
// takes it.
|
||||
f.on_map(0x8a, 22);
|
||||
f.sprite(9, 25, 2, (0x48, 0));
|
||||
f.toggle_list(&[(9, 0x87)]);
|
||||
f.sample();
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x87, false);
|
||||
f.sample();
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x87, true);
|
||||
assert_eq!(kinds(&f.item_events()), ["item"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_pays_nothing_indoors_but_still_records_the_exit() {
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(maps::REDS_HOUSE_1F, 1);
|
||||
f.warps(&[(4, 4)]);
|
||||
assert!(boundary_values(&f.visit(3, 4)).is_empty(), "no payout beside an indoor door");
|
||||
assert!(boundary_values(&f.visit(4, 4)).is_empty(), "nor on it");
|
||||
assert!(
|
||||
f.reward.exit_visited(MapExit::Warp { map: maps::REDS_HOUSE_1F, x: 4, y: 4 }),
|
||||
"the ledger still knows the door, so the macros see what they always saw"
|
||||
);
|
||||
assert_eq!(f.reward.statistics().counts[kind::BOUNDARY], 0);
|
||||
|
||||
// Outdoors, in the forest and in a cave, exits pay exactly what they did.
|
||||
let outdoors =
|
||||
[(maps::PALLET_TOWN, 0), (maps::VIRIDIAN_FOREST, FOREST), (maps::MT_MOON_1F, CAVERN)];
|
||||
for (map, tileset) in outdoors {
|
||||
f.on_map(map, tileset);
|
||||
assert_eq!(boundary_values(&f.visit(3, 4)), [0.05], "map {map}");
|
||||
assert_eq!(boundary_values(&f.visit(4, 4)), [0.10], "map {map}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v6_state_restores_under_v7_with_empty_talk_and_seeded_item_ledgers() {
|
||||
// A v6 run: some play, a pickup the v6 adapter did not pay for, and a v6 export -- which is
|
||||
// a v7 export without any of the keys v7 writes.
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(maps::VIRIDIAN_FOREST, FOREST);
|
||||
f.sprite(3, 5, 4, (0x14, 0));
|
||||
f.toggle_list(&[(3, 0x2a)]);
|
||||
f.visit(5, 5);
|
||||
f.catch(0xb0, Some(3));
|
||||
let mut v6 = f.reward.export_state();
|
||||
let seen: Vec<Value> = v6["seen"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|key| {
|
||||
let key = key.as_str().unwrap();
|
||||
!(key.starts_with("talk:") || key.starts_with("item") || key.starts_with("hidden:"))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
v6["seen"] = json!(seen);
|
||||
v6["counts"].as_object_mut().unwrap().remove("talk");
|
||||
v6["counts"].as_object_mut().unwrap().remove("item");
|
||||
assert_eq!(v6["version"], json!(STATE_VERSION), "v6 and v7 share a schema version");
|
||||
|
||||
// Under v6 the fly took the ball.
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
|
||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, true);
|
||||
|
||||
let mut restored = PokemonRedReward::new();
|
||||
restored.import_state(&v6).unwrap();
|
||||
assert_eq!(restored.statistics().counts[kind::TALK], 0);
|
||||
assert_eq!(restored.statistics().counts[kind::ITEM], 0);
|
||||
assert_eq!(restored.statistics().counts[kind::CATCH], 1, "nothing else moves");
|
||||
f.reward = restored;
|
||||
assert!(f.sample().is_empty(), "no retroactive payout for anything taken under v6");
|
||||
|
||||
// A rollback to a v6-era slot where the ball is still on the ground: taking it again is
|
||||
// the same pickup, and it does not pay.
|
||||
f.reward.clear_transient();
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x2a, false);
|
||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, false);
|
||||
f.sample();
|
||||
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
|
||||
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, true);
|
||||
assert!(f.item_events().is_empty());
|
||||
|
||||
// The talk ledger starts empty: the first conversation under v7 pays.
|
||||
f.on_map(maps::PEWTER_GYM, GYM);
|
||||
f.visit(5, 5);
|
||||
f.sprite(1, 5, 4, (0, 0));
|
||||
f.face(FACING_UP);
|
||||
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warp_is_classified_by_the_header_still_loaded_while_the_map_id_has_moved_on() {
|
||||
// `WarpFound2` writes the destination into `wCurMap` and then plays the map-change sound for
|
||||
// thirty-odd frames before `EnterMap` loads the new header (`tests/rom_engage.rs` measured
|
||||
// it at Route 2's gate door). On those frames the tileset and the warp table are still the
|
||||
// map the fly is leaving, and the exit it is standing on is that map's.
|
||||
let mut f = Fixture::booted();
|
||||
f.on_map(maps::ROUTE_2, 0);
|
||||
f.warps(&[(3, 11)]);
|
||||
f.visit(3, 10);
|
||||
// Town -> building: the id is the gate's, the header is still Route 2's, and Route 2's
|
||||
// door is an outdoor exit, so its on-exit half pays as it always did.
|
||||
f.memory.set(ram::wCurMap, 0x2f);
|
||||
assert_eq!(boundary_values(&f.visit(3, 11)), [0.10]);
|
||||
|
||||
// Building -> town: the id is Pewter's, the header still the museum's, whose door is an
|
||||
// indoor exit and pays nothing.
|
||||
f.on_map(0x34, 10);
|
||||
f.warps(&[(10, 7)]);
|
||||
f.visit(10, 6);
|
||||
f.memory.set(ram::wCurMap, maps::PEWTER_CITY);
|
||||
assert!(boundary_values(&f.visit(10, 7)).is_empty());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@
|
|||
|
||||
use std::time::Instant;
|
||||
|
||||
use 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<u32> {
|
|||
/// 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<u32> {
|
|||
/// 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:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
fn v6_checkpoint() -> Vec<u8> {
|
||||
use flybrain_core::decoder::DecoderState;
|
||||
use flybrain_core::lif::LifState;
|
||||
use flybrain_core::ordered::NumberMap;
|
||||
|
|
@ -105,12 +117,12 @@ fn v5_checkpoint() -> Vec<u8> {
|
|||
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<u8> {
|
|||
}
|
||||
|
||||
#[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<u8>);
|
||||
|
||||
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(_)
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {}",
|
||||
|
|
|
|||
432
services/flysim/crates/flysim/tests/rom_engage.rs
Normal file
432
services/flysim/crates/flysim/tests/rom_engage.rs
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
//! The engagement rewards against the real cartridge: a conversation indoors and an item ball.
|
||||
//!
|
||||
//! Gated on `FLY_ROM` *and* on a checkpoint, the way every ROM test in this workspace is, and
|
||||
//! skips cleanly without either:
|
||||
//!
|
||||
//! ```sh
|
||||
//! FLY_ROM="$HOME/roms/pokemon-red.gb" \
|
||||
//! FLY_ENGAGE_CHECKPOINT=.local/checkpoints/<a rung-10 Pewter checkpoint> \
|
||||
//! cargo test --release -p flysim --test rom_engage -- --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! ## What only the cartridge can answer
|
||||
//!
|
||||
//! The synthetic traces in `pokemon_red/tests.rs` write `wFontLoaded`, `wSpriteIndex`,
|
||||
//! `wToggleableObjectFlags` and the rest from the disassembly. They cannot say that an A press at
|
||||
//! a person on this cartridge opens the box on a frame whose previous sample was the fly's own,
|
||||
//! that `wSpriteIndex` names that person by the time the adapter samples, or that `PickUpItem`
|
||||
//! raises the ball's bit on a frame the adapter sees. This test does, with the shipping adapter
|
||||
//! sampling once a frame, and it restores the checkpoint's own `v6` reward ledger -- so it is also
|
||||
//! the `v6` -> `v7` migration on real game state: the item keys are seeded from the cartridge's
|
||||
//! bits and nothing already taken pays.
|
||||
//!
|
||||
//! ## How the fly is moved
|
||||
//!
|
||||
//! Not by the macro layer, and not by a brain: a small scripted walker with a breadth-first route
|
||||
//! over the whole-map grid (`state::map_grid`), pressing one direction at a time, answering text
|
||||
//! with B and battles with A. The question is what the adapter reads, not whether anything finds
|
||||
//! its way there. From the Pewter checkpoint it walks south out of the city, down Route 2 into the
|
||||
//! Viridian Forest north gate -- a building, where it talks to the old man twice -- and on into
|
||||
//! the forest to the Antidote ball at (25, 11), which it picks up, and then, after rolling the
|
||||
//! emulator back to before the pickup, picks up again.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use flybrain_gb::adapter::RewardEvent;
|
||||
use flybrain_gb::pokemon_red::macros::state::{Facing, Walkable};
|
||||
use flybrain_gb::pokemon_red::symbols::ram;
|
||||
use flybrain_gb::pokemon_red::{PokemonRedReward, catalog, engage, state};
|
||||
use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons};
|
||||
|
||||
const MS_PER_FRAME: f64 = 1000.0 / 59.7275;
|
||||
const PEWTER_CITY: u8 = 0x02;
|
||||
const ROUTE_2: u8 = 0x0d;
|
||||
const NORTH_GATE: u8 = 0x2f;
|
||||
const VIRIDIAN_FOREST: u8 = 0x33;
|
||||
/// `constants/item_constants.asm`: `ANTIDOTE` is `$0b`.
|
||||
const ANTIDOTE: &str = "FOUND ITEM #11";
|
||||
|
||||
fn rom() -> Option<Vec<u8>> {
|
||||
let path = std::env::var_os("FLY_ROM")?;
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn checkpoint() -> Option<flysim::store::Checkpoint> {
|
||||
let path = std::env::var_os("FLY_ENGAGE_CHECKPOINT")?;
|
||||
Some(
|
||||
flysim::store::load(std::path::Path::new(&path))
|
||||
.expect("the checkpoint should be a FLYSIM01 envelope"),
|
||||
)
|
||||
}
|
||||
|
||||
struct Run {
|
||||
gb: Emulator,
|
||||
adapter: PokemonRedReward,
|
||||
ms: f64,
|
||||
frames: u64,
|
||||
payouts: Vec<RewardEvent>,
|
||||
/// `(wCurMap, wCurMapTileset)` on the frame each payout in `payouts` was made.
|
||||
payout_maps: Vec<(u8, u8)>,
|
||||
}
|
||||
|
||||
fn delta(facing: Facing) -> (i16, i16) {
|
||||
facing.delta()
|
||||
}
|
||||
|
||||
fn mask(facing: Facing) -> u8 {
|
||||
match facing {
|
||||
Facing::Up => buttons::UP,
|
||||
Facing::Down => buttons::DOWN,
|
||||
Facing::Left => buttons::LEFT,
|
||||
Facing::Right => buttons::RIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
const FACINGS: [Facing; 4] = [Facing::Up, Facing::Down, Facing::Left, Facing::Right];
|
||||
|
||||
impl Run {
|
||||
fn resume(rom: &[u8], checkpoint: &flysim::store::Checkpoint) -> Self {
|
||||
let mut gb = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
|
||||
.expect("binjgb should accept the cartridge");
|
||||
gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state");
|
||||
let mut adapter = PokemonRedReward::new();
|
||||
// The checkpoint was written by `pokered-unique8-v6`: this is the migration.
|
||||
adapter.import_state(&checkpoint.runtime.reward).expect("a v6 ledger is a v7 ledger");
|
||||
Self { gb, adapter, ms: 0.0, frames: 0, payouts: Vec::new(), payout_maps: Vec::new() }
|
||||
}
|
||||
|
||||
fn byte(&mut self, address: u16) -> u8 {
|
||||
self.gb.read_wram(address)
|
||||
}
|
||||
|
||||
fn frame(&mut self, mask: u8) {
|
||||
self.gb.set_buttons(mask);
|
||||
self.gb.run_frame().expect("a frame should complete");
|
||||
self.ms += MS_PER_FRAME;
|
||||
self.frames += 1;
|
||||
let ms = self.ms;
|
||||
let events = self.adapter.sample(&mut self.gb, ms);
|
||||
let here = (self.byte(ram::wCurMap), self.byte(ram::wCurMapTileset));
|
||||
self.payout_maps.extend(events.iter().map(|_| here));
|
||||
self.payouts.extend(events);
|
||||
assert!(self.frames < 200_000, "the walker is lost: {}", self.whereabouts());
|
||||
}
|
||||
|
||||
fn whereabouts(&mut self) -> String {
|
||||
format!(
|
||||
"map {:#04x} at ({}, {}), battle {}, font {}",
|
||||
self.byte(ram::wCurMap),
|
||||
self.byte(ram::wXCoord),
|
||||
self.byte(ram::wYCoord),
|
||||
self.byte(ram::wIsInBattle),
|
||||
self.byte(ram::wFontLoaded)
|
||||
)
|
||||
}
|
||||
|
||||
fn map(&mut self) -> u8 {
|
||||
self.byte(ram::wCurMap)
|
||||
}
|
||||
|
||||
fn at(&mut self) -> (u8, u8) {
|
||||
(self.byte(ram::wXCoord), self.byte(ram::wYCoord))
|
||||
}
|
||||
|
||||
/// Whatever is on screen that is not the fly's to walk through: a battle (A through it) or a
|
||||
/// text box (B through it). Returns once the overworld is controllable again.
|
||||
fn settle(&mut self) {
|
||||
for tick in 0..20_000u32 {
|
||||
let in_battle = self.byte(ram::wIsInBattle) != 0;
|
||||
let open = self.byte(ram::wFontLoaded) & 1 != 0;
|
||||
if !in_battle && !open && state::controllable(&mut self.gb) {
|
||||
if self.byte(ram::wWalkCounter) == 0 {
|
||||
return;
|
||||
}
|
||||
self.frame(buttons::NONE);
|
||||
continue;
|
||||
}
|
||||
let press = if in_battle { buttons::A } else { buttons::B };
|
||||
self.frame(if tick % 8 < 3 { press } else { buttons::NONE });
|
||||
}
|
||||
panic!("the screen never settled: {}", self.whereabouts());
|
||||
}
|
||||
|
||||
/// Press `facing` until the player has moved one tile, the map has changed, or it is plain
|
||||
/// that the step is refused (which turns the player to face that way).
|
||||
fn step(&mut self, facing: Facing) {
|
||||
let (map, from) = (self.map(), self.at());
|
||||
for _ in 0..48 {
|
||||
self.frame(mask(facing));
|
||||
if self.map() != map || self.at() != from {
|
||||
break;
|
||||
}
|
||||
if self.byte(ram::wIsInBattle) != 0 || self.byte(ram::wFontLoaded) & 1 != 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.frame(buttons::NONE);
|
||||
self.settle();
|
||||
}
|
||||
|
||||
/// Breadth-first over the decoded map, people excluded, from where the player stands to the
|
||||
/// nearest tile `goal` accepts; the first step of that route, or `None`.
|
||||
fn route(&mut self, goal: &dyn Fn(u8, u8) -> bool) -> Option<Facing> {
|
||||
let grid = state::map_grid(&mut self.gb).ok()?;
|
||||
let people: Vec<(u8, u8)> =
|
||||
state::npcs(&mut self.gb).iter().map(|npc| (npc.x, npc.y)).collect();
|
||||
let (width, height) = (grid.width(), grid.height());
|
||||
let start = self.at();
|
||||
let mut first: Vec<Option<Facing>> = vec![None; usize::from(width) * usize::from(height)];
|
||||
let mut seen = vec![false; first.len()];
|
||||
let index = |x: u8, y: u8| usize::from(y) * usize::from(width) + usize::from(x);
|
||||
let mut queue = VecDeque::from([start]);
|
||||
seen[index(start.0, start.1)] = true;
|
||||
while let Some((x, y)) = queue.pop_front() {
|
||||
if (x, y) != start && goal(x, y) {
|
||||
return first[index(x, y)];
|
||||
}
|
||||
for facing in FACINGS {
|
||||
if grid.walled(x, y, facing) {
|
||||
continue;
|
||||
}
|
||||
let (dx, dy) = delta(facing);
|
||||
let (Ok(nx), Ok(ny)) =
|
||||
(u8::try_from(i16::from(x) + dx), u8::try_from(i16::from(y) + dy))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if nx >= width || ny >= height || seen[index(nx, ny)] {
|
||||
continue;
|
||||
}
|
||||
if grid.walkable(nx, ny) != Walkable::Yes || people.contains(&(nx, ny)) {
|
||||
continue;
|
||||
}
|
||||
seen[index(nx, ny)] = true;
|
||||
first[index(nx, ny)] = if (x, y) == start { Some(facing) } else { first[index(x, y)] };
|
||||
queue.push_back((nx, ny));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Walk until standing on a tile `goal` accepts, on this map.
|
||||
fn walk_to(&mut self, goal: &dyn Fn(u8, u8) -> bool) {
|
||||
let map = self.map();
|
||||
for _ in 0..400 {
|
||||
self.settle();
|
||||
assert_eq!(self.map(), map, "the walk left the map: {}", self.whereabouts());
|
||||
let (x, y) = self.at();
|
||||
if goal(x, y) {
|
||||
return;
|
||||
}
|
||||
let Some(facing) = self.route(goal) else {
|
||||
// A grid refused on this frame, or a person in the way: let a frame go by.
|
||||
self.frame(buttons::NONE);
|
||||
continue;
|
||||
};
|
||||
self.step(facing);
|
||||
}
|
||||
let grid = state::map_grid(&mut self.gb);
|
||||
let detail = match &grid {
|
||||
Ok(grid) => {
|
||||
let (x, y) = self.at();
|
||||
let mut rows = String::new();
|
||||
for ty in 0..grid.height().min(24) {
|
||||
for tx in 0..grid.width() {
|
||||
rows.push(if (tx, ty) == (x, y) {
|
||||
'@'
|
||||
} else {
|
||||
match grid.walkable(tx, ty) {
|
||||
Walkable::Yes => '.',
|
||||
Walkable::No => '#',
|
||||
Walkable::Unknown => '?',
|
||||
}
|
||||
});
|
||||
}
|
||||
rows.push('\n');
|
||||
}
|
||||
format!("reachable {}\n{rows}", grid.reachable_from(x, y))
|
||||
}
|
||||
Err(refusal) => format!("grid refused: {}", refusal.label()),
|
||||
};
|
||||
panic!("never reached the goal: {}; {detail}", self.whereabouts());
|
||||
}
|
||||
|
||||
/// Walk to `(x, y)` and keep pressing `out` until the map changes.
|
||||
fn leave_by(&mut self, x: u8, y: u8, out: Facing) {
|
||||
let map = self.map();
|
||||
self.walk_to(&|tx, ty| (tx, ty) == (x, y));
|
||||
for _ in 0..8 {
|
||||
self.step(out);
|
||||
if self.map() != map {
|
||||
// The warp's fade and the new map's first frames.
|
||||
for _ in 0..60 {
|
||||
self.frame(buttons::NONE);
|
||||
}
|
||||
self.settle();
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("pressing {out:?} at ({x}, {y}) never left the map: {}", self.whereabouts());
|
||||
}
|
||||
|
||||
/// Stand beside `(x, y)`, face it, press A once and let the conversation run to its end.
|
||||
/// Returns the payouts the conversation produced.
|
||||
fn press_a_at(&mut self, x: u8, y: u8) -> Vec<RewardEvent> {
|
||||
self.walk_to(&|tx, ty| tx.abs_diff(x) + ty.abs_diff(y) == 1);
|
||||
let (px, py) = self.at();
|
||||
let facing = FACINGS
|
||||
.into_iter()
|
||||
.find(|facing| {
|
||||
let (dx, dy) = delta(*facing);
|
||||
i16::from(px) + dx == i16::from(x) && i16::from(py) + dy == i16::from(y)
|
||||
})
|
||||
.expect("a neighbour faces the target one way");
|
||||
// Turn in place: the step is refused because the thing is in the way.
|
||||
self.step(facing);
|
||||
assert_eq!(self.at(), (px, py), "turning must not move the player");
|
||||
self.press_a_here()
|
||||
}
|
||||
|
||||
/// Press A where the player stands and faces, and let whatever it opens run to its end.
|
||||
fn press_a_here(&mut self) -> Vec<RewardEvent> {
|
||||
for _ in 0..4 {
|
||||
self.frame(buttons::NONE);
|
||||
}
|
||||
let before = self.payouts.len();
|
||||
for _ in 0..6 {
|
||||
self.frame(buttons::A);
|
||||
}
|
||||
self.frame(buttons::NONE);
|
||||
self.settle();
|
||||
for _ in 0..8 {
|
||||
self.frame(buttons::NONE);
|
||||
}
|
||||
self.payouts[before..].to_vec()
|
||||
}
|
||||
|
||||
fn of_kind(&self, kind: &str) -> usize {
|
||||
self.payouts.iter().filter(|event| event.kind == kind).count()
|
||||
}
|
||||
}
|
||||
|
||||
fn kinds(events: &[RewardEvent]) -> Vec<&'static str> {
|
||||
events.iter().map(|event| event.kind).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conversation_indoors_and_an_item_ball_each_pay_exactly_once_on_the_cartridge() {
|
||||
let Some(rom) = rom() else {
|
||||
eprintln!("skipped: FLY_ROM is not set");
|
||||
return;
|
||||
};
|
||||
let Some(checkpoint) = checkpoint() else {
|
||||
eprintln!("skipped: no FLY_ENGAGE_CHECKPOINT");
|
||||
return;
|
||||
};
|
||||
let mut run = Run::resume(&rom, &checkpoint);
|
||||
if run.map() != PEWTER_CITY {
|
||||
eprintln!("skipped: the checkpoint is on map {:#04x}, not Pewter City", run.map());
|
||||
return;
|
||||
}
|
||||
let before = run.adapter.progress().counts;
|
||||
|
||||
// The restore's first sample: the v6 ledger holds no item keys, so this is the seed.
|
||||
run.settle();
|
||||
assert!(run.payouts.is_empty(), "the migration pays nothing: {:?}", run.payouts);
|
||||
|
||||
// Pewter City's south edge, onto Route 2.
|
||||
let height = run.byte(ram::wCurMapHeight) * 2;
|
||||
run.walk_to(&|_, y| y == height - 1);
|
||||
let (x, y) = run.at();
|
||||
run.leave_by(x, y, Facing::Down);
|
||||
assert_eq!(run.map(), ROUTE_2, "{}", run.whereabouts());
|
||||
|
||||
// Route 2's door into the forest's north gate, `warp_event 3, 11`: the gate is south of
|
||||
// the city, so its door is entered heading south, from the tile above it.
|
||||
run.leave_by(3, 10, Facing::Down);
|
||||
assert_eq!(run.map(), NORTH_GATE, "{}", run.whereabouts());
|
||||
let tileset = run.byte(ram::wCurMapTileset);
|
||||
assert!(engage::indoor(tileset), "the gate is a building (tileset {tileset})");
|
||||
|
||||
// The old man at (2, 5): one conversation, one payout, when the box closes.
|
||||
let first = run.press_a_at(2, 5);
|
||||
eprintln!("gate, first conversation: {first:?}");
|
||||
let talks: Vec<&RewardEvent> =
|
||||
first.iter().filter(|event| event.kind == catalog::kind::TALK).collect();
|
||||
assert_eq!(talks.len(), 1, "one conversation, one payout: {first:?}");
|
||||
assert_eq!(talks[0].value, 0.10);
|
||||
assert!(talks[0].label.starts_with("TALKED TO #"), "{}", talks[0].label);
|
||||
// The same man again, and again: not a farm.
|
||||
for _ in 0..2 {
|
||||
let again = run.press_a_at(2, 5);
|
||||
assert!(
|
||||
!again.iter().any(|event| event.kind == catalog::kind::TALK),
|
||||
"a second conversation with the same person pays nothing: {again:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// On through the gate, `warp_event 4, 7`, into the forest.
|
||||
run.leave_by(4, 7, Facing::Down);
|
||||
assert_eq!(run.map(), VIRIDIAN_FOREST, "{}", run.whereabouts());
|
||||
assert!(!engage::indoor(run.byte(ram::wCurMapTileset)), "the forest is not a building");
|
||||
|
||||
// The Antidote ball at (25, 11). Save the game just before, to take it twice.
|
||||
run.walk_to(&|tx, ty| tx.abs_diff(25) + ty.abs_diff(11) == 1);
|
||||
let slot = run.gb.export_state().expect("an emulator state");
|
||||
let first = run.press_a_at(25, 11);
|
||||
eprintln!("forest, the ball: {first:?}");
|
||||
let items: Vec<&RewardEvent> =
|
||||
first.iter().filter(|event| event.kind == catalog::kind::ITEM).collect();
|
||||
assert_eq!(items.len(), 1, "one pickup, one payout: {first:?}");
|
||||
assert_eq!(items[0].value, 0.15);
|
||||
assert_eq!(items[0].label, ANTIDOTE);
|
||||
assert!(
|
||||
!first.iter().any(|event| event.kind == catalog::kind::TALK),
|
||||
"a ball is not a conversation, and the forest is not indoors"
|
||||
);
|
||||
// The ball is gone: pressing A at the empty tile pays nothing.
|
||||
let empty = run.press_a_here();
|
||||
assert!(!empty.iter().any(|event| event.kind == catalog::kind::ITEM), "{empty:?}");
|
||||
|
||||
// A rollback to the slot with the ball still there, the way the ratchet restores one, and
|
||||
// the fly takes it again: the same item, and it does not pay twice.
|
||||
run.gb.import_state(&slot).expect("the slot restores");
|
||||
run.adapter.clear_transient();
|
||||
run.frame(buttons::NONE);
|
||||
run.settle();
|
||||
let again = run.press_a_at(25, 11);
|
||||
eprintln!("forest, the ball after a rollback: {again:?}");
|
||||
assert!(
|
||||
!again.iter().any(|event| event.kind == catalog::kind::ITEM),
|
||||
"once per item for the run: {again:?}"
|
||||
);
|
||||
|
||||
let after = run.adapter.progress().counts;
|
||||
eprintln!(
|
||||
"{:.1} brain minutes; talk {} -> {}, item {} -> {}; every payout (kind, (map, tileset)): {:?}",
|
||||
run.ms / 60_000.0,
|
||||
before[catalog::kind::TALK],
|
||||
after[catalog::kind::TALK],
|
||||
before[catalog::kind::ITEM],
|
||||
after[catalog::kind::ITEM],
|
||||
kinds(&run.payouts).iter().zip(&run.payout_maps).collect::<Vec<_>>()
|
||||
);
|
||||
// The gate's exits were stood beside and walked through, and none of them paid: an indoor
|
||||
// exit pays nothing. What *does* show up under the gate's id is Route 2's door, the
|
||||
// outdoor exit the fly took: for the thirty-odd frames of `PlayMapChangeSound` the cartridge
|
||||
// has already written the new `wCurMap` while the header, the warp table and the tileset
|
||||
// are still Route 2's, and the rule reads that frame as what it is -- an outdoor exit.
|
||||
for (event, (map, tileset)) in run.payouts.iter().zip(&run.payout_maps) {
|
||||
assert!(
|
||||
!(event.kind == catalog::kind::BOUNDARY && engage::indoor(*tileset)),
|
||||
"a boundary payout on an indoor map's own header (map {map}): {event:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(run.of_kind(catalog::kind::TALK), 1);
|
||||
assert_eq!(run.of_kind(catalog::kind::ITEM), 1);
|
||||
assert_eq!(after[catalog::kind::TALK], before[catalog::kind::TALK] + 1);
|
||||
assert_eq!(after[catalog::kind::ITEM], before[catalog::kind::ITEM] + 1);
|
||||
}
|
||||
|
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue