Compare commits

...

21 commits

Author SHA1 Message Date
acamilo
2f7b7cebd0 docs: v0.6.0 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-23 21:23:57 +00:00
acamilo
ff49b56e2c docs: the explore row stays "new place" 2026-09-23 21:23:40 +00:00
acamilo
b484a077d4 stage: the explore row reads "new place" again (the operator, 2026-09-23) 2026-09-23 21:23:05 +00:00
acamilo
bc117ad470 flybrain-gb: say what a move with power does not promise 2026-09-23 19:23:48 +00:00
acamilo
fa021918b4 Merge fix/loop-row60: a move the cartridge would refuse is not dealt beside one that works 2026-09-23 19:23:43 +00:00
acamilo
4591e7907a docs: macros.md 12.23, macros-wram.md section 13, the row 60 audit 2026-09-23 17:59:29 +00:00
acamilo
07f88c443a tests: the ROM proof from the Route 1 checkpoint 2026-09-23 17:23:05 +00:00
acamilo
b33af9e772 scene_probe: the route survey prints the battle bytes a MOVE n button's effect rests on 2026-09-23 17:23:05 +00:00
acamilo
a02d5f7995 trap_hunt: payouts by kind, battle lengths and wins, and MOVE n on a move without effect 2026-09-23 17:23:05 +00:00
acamilo
f3e3e9cf98 palette: a move the cartridge answers with nothing is not dealt beside one it does not
Row 60: TAIL WHIP against a Pidgey at DEFENSE -6 was MOVE 2 183 times. The
rule is PP's: a refused move leaves the pad while another move is usable, and
with none usable the moves stay as PP deals them, so an open list never
comes down to BACK alone.
2026-09-23 17:23:05 +00:00
acamilo
438b2d8540 state: the move table, and whether the battle engine will answer a move with nothing
move_data reads a row of Moves from the cartridge image ($0E:$4000, each row
checked against its own id); move_without_effect answers the refusals the
effect routines make on bytes already in WRAM: a stat stage at its limit or a
stat at 1/999, Mist or a substitute against a stat-lowering move, a status
move against a statused, Poison-type or (Electric) Ground-type target.
MacroState::move_without_effect defaults to false.
2026-09-23 17:23:05 +00:00
acamilo
c8ecf06db5 symbols: the stat stages and the enemy's status, type and stats, resolved from wram.asm
resolve_wram.py now follows the decomp's const and _RS counters and a struct
macro's field labels, so battle_struct fields resolve and 77 of 81 pinned
addresses are re-derived with no disagreement.
2026-09-23 17:23:05 +00:00
acamilo
37adeb9417 docs: a map script's text can pay talk when it names the thing in front, and where it can 2026-09-23 17:22:11 +00:00
acamilo
5d527f0910 Merge feat/engage-reward: the fly is paid for talking to people indoors and picking up items, not for leaving buildings (adapter v7) 2026-09-23 17:20:02 +00:00
acamilo
56b02ff758 rewards: a frame on which no item bit moved reads nothing more 2026-09-23 14:34:10 +00:00
acamilo
8d1d5c44f5 docs: the engagement rewards, and the v6 migration in deploy examples
rewards-learning.md carries the v7 table, the definition of indoors, the four
conditions a conversation pays under, the item and hidden-item reads, the seed,
boundary's indoor rule with the warp-transition frame it was measured on, the
feed-kind choice and the ticker word, and the honesty note on paying for a
conversation. flysim.md records the v6 -> v7 migration; macros-wram.md the six
new reads; the architecture tour and integration.md point at the live catalog.
The deploy comments, example.env and the runbook name FLY_ACCEPT_ADAPTERS=
pokered-unique8-v6 for the v0.6.0 deploy.
2026-09-23 14:34:10 +00:00
acamilo
3cf706c96a rewards: read the conversation's argument once the cartridge has written it, and a ROM test
The first cartridge run found what the synthetic trace could not. In the
Viridian Forest north gate the font bit rose on one frame and DisplayTextID's
argument reached wSpriteIndex twenty frames later: DisplayTextIDInit loads the
font's tiles into VRAM first. Until then the byte still names the previous
text's subject, which may be the person in front of the fly from a conversation
that did not pay. So the watch now remembers the byte from the last ready
frame and reads it once, when it changes or after 45 samples, and only while
the bottom dialogue box is drawn (the start menu draws its own elsewhere); a
box that closes first is read on the closing sample if the dialogue box was up.

tests/rom_engage.rs, gated on FLY_ROM and FLY_ENGAGE_CHECKPOINT: from the rung-10
Pewter checkpoint, with the checkpoint's own v6 ledger restored (the migration,
on real game state), a scripted walker goes down Route 2 into the forest's north
gate, talks to the old man three times (one payout, 0.10) and picks up the
Antidote ball at (25, 11) (one payout, 0.15, "FOUND ITEM #11"); pressing A at
the empty tile pays nothing, and after rolling the emulator back to before the
pickup, taking it again pays nothing. No boundary payout lands while an indoor
header is loaded.

It also measured the warp transition: for about thirty frames of
PlayMapChangeSound wCurMap already names the destination while the tileset and
warp table are still the map being left, so the exit the fly stands on is
classified by the map it belongs to -- a town door still pays its on-exit half,
keyed under the building's id as before, and a building's door does not. A unit
test pins both directions.
2026-09-23 14:34:10 +00:00
acamilo
2d288b0146 stage: the explore row reads "new find"
Since pokered-unique8-v7 the explore feed kind carries four adapter rules --
new ground, an exit, a conversation indoors, an item picked up -- and "new
place" was true of the first two only. The per-game copy now names what all
four are; the feed protocol and the kind set are unchanged.
2026-09-23 14:34:10 +00:00
acamilo
52811bf512 session types: the legacy composition names pokered-unique8-v7
The example composition's adapter and FLYSIM01 string move to v7 with the live
adapter, and the fixtures are regenerated by update_fixtures; valid.json and
invalid.json's hand-written cases follow, with the one deliberately mismatched
string moving from v5 to v6 so it is still the previous adapter.
2026-09-23 14:34:10 +00:00
acamilo
2e7eed0a78 rewards: talk and item, boundary indoors pays nothing, adapter v7 with a v6 migration
The operator's decision of 2026-09-23: pay the fly for engaging with what is
inside a building, and stop paying it for walking back out of one. Chosen over
a pad rule and over weighting the choice, and like v0.5.0's catch reward it is
a catalog change, not a loop-review fix.

Indoors. engage::indoor is two of the cartridge's own tables: not outside by
CheckIfInOutsideMap (tileset OVERWORLD or PLATEAU; WarpFound2 labels the other
branch .indoorMaps) and not a place BikeRidingTilesets lets the bike be ridden
(OVERWORLD, FOREST, UNDERGROUND, SHIP_PORT, CAVERN). That is every house, mart,
center, gym, gate, lab, museum, ship, tower, mansion and hideout, and not
Viridian Forest, a cave, the Underground Path or the dock.

`talk`, +0.10, 100 ms, the catalog's tenth kind. Paid when the text box closes
on a conversation that (1) opened on the sample after one where the fly had the
joypad (state::controllable) and was standing still (wWalkCounter zero, the only
state the overworld reads A in) on the same tile; (2) is with the thing in
front: DisplayTextID copies its argument into wSpriteIndex, a sprite slot up to
wNumSprites whose sprite stands on the tile the player faces (or one further
across a counter, on a tileset with counter tiles), or a text id matching the
sign on that tile; an item ball is not a person; (3) opened indoors; (4) closed
on the same map. Keyed talk:<map>:sprite:<slot> / talk:<map>:sign:<id> in the
lifetime `seen` ledger, which is checkpointed and survives a rollback -- not the
macros' session `talked` ledger.

`item`, +0.15, 120 ms, the eleventh. An item ball is a toggleable sprite of this
map (wToggleableObjectList) whose wMapSpriteExtraData is (item id, 0), the shape
LoadMapHeader writes for an ITEM object_event and nothing else; PickUpItem sets
its global bit in wToggleableObjectFlags after GiveItem succeeds. A hidden item
is a bit of wObtainedHiddenItemsFlags, set by FoundHiddenItemText after GiveItem
and by nothing else. Either pays when its bit rises between two playable
samples, once per item (item:<global> / hidden:<index>) for the ledger's life.

`boundary` still writes every key indoors, so exit_visited answers what it did
and the macros see no change, but emits nothing on an indoor map. Outdoor,
forest and cave exits pay as before.

v6 -> v7. STATE_VERSION stays 4 and no field is added: the new ledgers are keys
in `seen`. A v6 state restores with no talk: keys, and the first playable sample
that finds `items:seeded` absent writes one key per item the game already shows
as taken, pays for none, and marks the seed -- so a rollback that un-takes a v6
pickup cannot pay for it. The two item balls a script reveals (the Rocket
Hideout's Silph Scope and Lift Key, toggles $87 and $88, the only ITEM entries
toggleable_objects.asm starts OFF) are left out of the seed. MIGRATES_FROM is
["pokered-unique8-v6"]; v5 is no longer migrated.

Feed kinds: both publish on `explore`, the family of new ground and a door found,
at the same quiet scale; not `area` (maps, notable), `story` or `wildwin`. No
feed-protocol change.

The compatibility string differs from main's in exactly one segment:
pokered-unique8-v6 -> pokered-unique8-v7.

Tests: catalog values and order; indoor over all 24 tilesets; a talk pays once
per (map, object), not while the box is open, not re-talked, not outdoors/in the
forest/in a cave, not for text opened with the joypad taken, simulated, scripted,
mid-step, about someone not in front, or the start menu; not across a warp or a
rollback; counter reach only with counter tiles; item balls, people, trainers,
hidden items, the seed and the script-shown balls; boundary indoors records and
pays nothing; a v6 state and a v6 FLYSIM01 envelope migrate with the new ledgers
empty and the items seeded. rom.rs's bedroom walk now proves the stairs are
recorded and unpaid on the cartridge.
2026-09-23 14:34:10 +00:00
acamilo
ed0080ab2c symbols: the six WRAM names the engagement rewards read
wSpriteIndex, wWalkCounter, wMapSpriteExtraData, wToggleableObjectFlags,
wToggleableObjectList and wObtainedHiddenItemsFlags, resolved by
tools/resolve_wram.py from ram/wram.asm at the pinned commit and bracketed by
addresses symbols.rs already carries. 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; both are now counted up to the line that defines them.
2026-09-23 14:34:10 +00:00
40 changed files with 3029 additions and 164 deletions

View file

@ -89,7 +89,9 @@ decoder preset to use.
disassembly at commit 0cd19d3 (`symbols.rs`), gates rewards on a playable state, baselines already disassembly at commit 0cd19d3 (`symbols.rs`), gates rewards on a playable state, baselines already
achieved flags on the first sample so a restore never replays them, and pays only positive achieved flags on the first sample so a restore never replays them, and pays only positive
rewards: story flags, exploration coverage (capped per map), new areas, Pokédex entries, trainer rewards: story flags, exploration coverage (capped per map), new areas, Pokédex entries, trainer
flags, decaying wild wins, badges. 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, - Ratchet (`ratchet.rs`): a 38-rung ladder (boot, bedroom, Pallet Town, Oak's lab, starter, parcel,
Pokédex, each town, each badge, the Elite Four, Champion). On first reaching a higher rung in a Pokédex, each town, each badge, the Elite Four, Champion). On first reaching a higher rung in a
safe state it archives the emulator snapshot; on a stall (120 s without new exploration) or a game safe state it archives the emulator snapshot; on a stall (120 s without new exploration) or a game

View file

@ -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 absent from a `v5` state and restores empty, which is the truth about a run that was never paid
for a catch. `STATE_VERSION` does not move, because the schema did not. for a catch. `STATE_VERSION` does not move, because the schema did not.
**`v6` -> `v7`** (2026-09-23, the engagement rewards) is the same migration for the next pair,
and `pokered-unique8-v7`'s `migrates_from()` is `["pokered-unique8-v6"]` and nothing else -- `v5`
is no longer migrated, because the live run is `v6`. The deploy that ships it sets
`FLY_ACCEPT_ADAPTERS=pokered-unique8-v6`. No field is added this time: `talk` and `item` key
their ledgers into the existing `seen` array, as `boundary` did, so a `v6` state restores
unchanged with no `talk:` keys. The one step is at the first sample after the restore, not in
`import_state`: a ledger without the `items:seeded` key writes an `item:`/`hidden:` key for every
item the cartridge already shows as taken, pays for none, and marks the seed, so a rollback to a
slot from before a `v6`-era pickup cannot pay for it (`docs/rewards-learning.md`, "The seed").
`STATE_VERSION` stays 4.
- **Restarting a run from an earlier rung** (2026-09-22). `FLY_RESET_STATE=1` throws the run away; - **Restarting a run from an earlier rung** (2026-09-22). `FLY_RESET_STATE=1` throws the run away;
`infra/bin/fly-reset-to-milestone <N>` keeps it and rewinds it. It archives both stores to a `infra/bin/fly-reset-to-milestone <N>` keeps it and rewinds it. It archives both stores to a
dated directory, rewrites `milestone-<N>.checkpoint` with the ratchet's `attempts` and dated directory, rewrites `milestone-<N>.checkpoint` with the ratchet's `attempts` and

View file

@ -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 tell those apart. The cartridge's own flag needs none, which is why the row above is the one the
adapter reads. adapter reads.
### The engagement rewards' reads (2026-09-23)
**New 2026-09-23** (`talk` and `item`, `docs/rewards-learning.md`, "Engagement rewards"). All six
were resolved by `services/flysim/tools/resolve_wram.py` from `ram/wram.asm` at the pinned commit
and are bracketed by addresses `symbols.rs` already carried; two of the brackets needed the tool
to count `NUM_STATS` and `NUM_CITY_MAPS`, which the decomp defines as `const_value` over an
enumeration.
| what | symbol | address | notes | verified |
| --- | --- | --- | --- | --- |
| the text's subject | `wSpriteIndex` | `$cf13` | `DisplayTextID` copies its argument here: a sprite slot up to `wNumSprites`, else a text id. It arrives **about twenty frames after** `wFontLoaded` bit 0 rises, because `DisplayTextIDInit` loads the font's tiles first; until then it still holds the previous text's subject. | survey (`tests/rom_engage.rs`: the Viridian Forest north gate, the old man at slot 2, font bit on frame 820 and the argument on frame 840) |
| mid-step | `wWalkCounter` | `$cfc5` | non-zero for the frames of a step; the overworld only reads A at zero. Right after `wFontLoaded` in `ram/wram.asm`. | ROM, trace |
| an item ball's item | `wMapSpriteExtraData` | `$d504` | two bytes per sprite slot (slot 1 first): `(item id, 0)` for an `ITEM` `object_event`, `(trainer class, trainer number)` for a `TRAINER` one, zeroes otherwise -- `LoadMapHeader`'s `.itemBallSprite` / `.trainerSprite` / `.regularSprite` | survey (the forest's Antidote ball read `(11, 0)`) |
| taken or hidden, per object | `wToggleableObjectFlags` | `$d5a6` | `flag_array $100`, one bit per global toggleable index (`constants/toggle_constants.asm`); `PickUpItem`'s `HideObject` sets an item ball's bit after `GiveItem` succeeded | survey (the forest's Antidote ball's bit rose on the pickup frame) |
| this map's toggleables | `wToggleableObjectList` | `$d5ce` | up to sixteen `(sprite slot, global index)` pairs, `$ff`-terminated, written by `MarkTownVisitedAndLoadToggleableObjects` | survey |
| hidden items found | `wObtainedHiddenItemsFlags` | `$d6f0` | `flag_array MAX_HIDDEN_ITEMS` (112); `FoundHiddenItemText` sets the bit after `GiveItem` succeeded, and nothing else writes it | ROM (disassembly), trace |
Not used, and why: `hJoyPressed`/`hJoyHeld` would say "A was pressed" directly, but they are HRAM,
which neither `gen_symbols.py` nor `resolve_wram.py` resolves, and a hand-written address is the one
thing those tools exist to refuse. "The fly had the joypad and was standing still on the frame
before the box opened, and the box is about the thing it faces" is the same fact read out of WRAM.
### Battle menu and cursor, own turn against forced switch ### Battle menu and cursor, own turn against forced switch
`HandleMenuInput` is shared by every menu in the game, so which menu is up is read from where it `HandleMenuInput` is shared by every menu in the game, so which menu is up is read from where it
@ -931,3 +953,45 @@ where that layout puts them, so the byte is `wBattleType - 1` = `$d059`, asserte
neighbours in `scene/tests.rs`. Measured on the cartridge in the Pewter Gym: `$00` in the neighbours in `scene/tests.rs`. Measured on the cartridge in the Pewter Gym: `$00` in the
overworld, `$cd` (`OPP_JR_TRAINER_M`) from the last box of the trainer's challenge through the overworld, `$cd` (`OPP_JR_TRAINER_M`) from the last box of the trainer's challenge through the
219-frame transition and the battle. `controllable` reads it as zero. 219-frame transition and the battle. `controllable` reads it as zero.
## 13. What a move will do: the move table and the bytes its effect reads (2026-09-23, `docs/design/macros.md` 12.23)
Row 60. Seven addresses, resolved by `tools/resolve_wram.py` and emitted into `symbols.rs`; the
tool now follows the decomp's `const` and `_RS` counters and a struct macro's field labels, which
is what reaches the `battle_struct` fields, and it re-derives 77 of 81 pinned addresses with no
disagreement.
| symbol | address | what reads it |
| --- | --- | --- |
| `wPlayerMonStatMods` | `$cd1a` | six stages, ATTACK DEFENSE SPEED SPECIAL ACCURACY EVASION; 1 is -6, 7 normal, 13 is +6 |
| `wEnemyMonStatMods` | `$cd2e` | the same for the enemy |
| `wEnemyMonStatus` | `$cfe9` | the enemy's status byte (`battle_struct` +4) |
| `wEnemyMonType1` | `$cfea` | and `Type2` after it |
| `wEnemyMonAttack` | `$cff6` | the enemy's modified ATTACK DEFENSE SPEED SPECIAL, big-endian words |
| `wBattleMonAttack` | `$d025` | the same for the fly's Pokémon |
| `wEnemyBattleStatus2` | `$d068` | bit 1 Mist, 4 substitute, 5 must recharge |
**The move table is ROM.** `Moves` opens `SECTION "Battle Engine 7"`, which `layout.link` places
first in bank `$0E`, so it is `$0E:$4000`, six bytes a row in move-id order from `POUND`:
animation (the move id itself), effect, power, type, accuracy, PP. `state::move_data` reads a row
through `MemoryReader::read_rom`, the cartridge image, and refuses a row whose first byte is not the
id asked for. Measured from the row-60 checkpoint: TACKLE (`$21`) effect `$00` power 35, TAIL
WHIP (`$27`) effect `$13` (`DEFENSE_DOWN1_EFFECT`) power 0.
**`state::move_without_effect`** answers only refusals decided before the roll, for a move with no
power, from `engine/battle/effects.asm` and `MoveHitTest`:
- `*_UP1` / `*_UP2`: the user's stage is 13, or (ATTACK..SPECIAL) the stat is 999;
- `*_DOWN1` / `*_DOWN2`: the target has Mist or a substitute, its stage is 1, or (ATTACK..SPECIAL)
the stat is 1 -- `StatModifierDownEffect` restores the stage and prints "Nothing happened!"
then, so a low-level target reaches it before -6;
- `SLEEP_EFFECT`: any status, unless the target must recharge;
- `POISON_EFFECT`: a substitute, any status, or a Poison type;
- `PARALYZE_EFFECT`: any status, or an Electric move against a Ground type.
`None` outside a battle, without a cartridge, or with a stage byte outside 1..13.
**Not covered** (the reading would be the same kind, and nothing early in the game reaches it):
Confuse Ray and Supersonic on a confused target, Leech Seed on a seeded or Grass target, Focus
Energy, Mist, Reflect and Light Screen already up, Disable on a disabled target, and a damaging
move the type chart makes "doesn't affect" (the chart is another ROM table).

View file

@ -1505,6 +1505,40 @@ has a person to walk to where it had none, `GO OUT` is withheld by 12.5's own ru
frames that were never the fly's deal nothing. The decoder, the reward catalog, the adapter frames that were never the fly's deal nothing. The decoder, the reward catalog, the adapter
version, the roles and the compatibility string are untouched. version, the roles and the compatibility string are untouched.
### 12.23 A move the cartridge answers with nothing is not dealt beside one it does not (2026-09-23, row 60)
Live on v0.5.5, early game after the reset to milestone 1: Route 1, Squirtle L5 (TACKLE, TAIL
WHIP) against a wild Pidgey, "Nothing happened!" on the screen. Since the reset `MOVE 2` 183
times and `MOVE 1` once; the last reward 25 brain minutes before the checkpoint, one wild win in
the whole run. Check 10 flagged `unrewarded` (1,600 decisions, no reward event, no new ground on
two probes), which is right, and it is unchanged.
- **The pad was at fault, not only the choice.** `MOVE n`'s precondition was "the slot holds a
move with PP", so TAIL WHIP stayed on the pad after it had walked the Pidgey's DEFENSE to the
point the cartridge refuses it. `StatModifierDownEffect` answers "Nothing happened!" when the
stage is already -6 **or the stat itself is already 1**, restoring the stage. The checkpoint is
the frame Squirtle fainted to a Pidgey L3 at DEFENSE -6 (stat 2); in the next battle the stat
reached 1 at -5. From there the pad dealt `MOVE 1, MOVE 2, RUN` and the fly pressed `MOVE 2`
until Squirtle fainted, woke at home and walked back: every battle lost, one wild win in the
run. The readout's favourite being `MOVE 2` is the fly's; a button that can do nothing at all
being on the pad is a macro that knows nothing about its own effect, section 12.2's trap.
- **What a move does is the cartridge's, read the same way for every move.** `state::move_data`
reads the move's row of `Moves` from the cartridge image (`$0E:$4000`, each row checked against
its own id) and `state::move_without_effect` answers the refusals the effect routines make on
bytes already in WRAM: a stat stage at its limit or a stat at 1 or 999, Mist or a substitute in
front of a stat-lowering move, a sleep, poison or paralysis move against a target that already has
a status, is Poison type, or is Ground type to an Electric move. No move is named; a miss is a
roll and is not answered. `macros-wram.md` section 13 has the bytes.
- **It is PP's rule.** A move the cartridge answers with nothing is not dealt beside one it does
not, exactly as a spent move is not (12.6, 12.8), and `MOVE 1` over the menu stops being FIGHT's
backstop only in that case. When no move would do anything the moves stay as PP deals them:
taking the last ones away would leave an open list whose only button is `BACK`, 12.11's pair,
and a turn that ends on "Nothing happened!" still ends. RUN, ITEM and SWITCH are untouched.
Nothing is ranked, weighted or pressed for the fly: a button leaves the pad while it cannot change
anything and comes back when it can (a new battle resets the stages). The decoder, the reward
catalog, the adapter version, the roles and the compatibility string are untouched.
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a ## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
## priority to visit the shop at least once per area; make shop macros item purchases. same ## priority to visit the shop at least once per area; make shop macros item purchases. same
## for the Pokécenter. heal should be a macro.") ## for the Pokécenter. heal should be a macro.")

View file

@ -177,6 +177,10 @@ achievements.
| Wild win | +0.1, +0.05, +0.0333 | at most three observed wild KOs per `(map,species,level)` | | Wild win | +0.1, +0.05, +0.0333 | at most three observed wild KOs per `(map,species,level)` |
| Badge | +3 | each newly set badge bit | | Badge | +3 | each newly set badge bit |
That is the prototype's catalog as it shipped, kept here as history. The live one is
`docs/rewards-learning.md`: `pokered-unique8-v7` adds `boundary` (v5), `catch` (v6), and `talk` and
`item` with no `boundary` payout indoors (v7, the operator's decision of 2026-09-23).
Every value is positive; there were no loss or blackout penalties. Values in a frame summed into Every value is positive; there were no loss or blackout penalties. Values in a frame summed into
`R`, then `m = tanh(R)`. PAM stimulation ran 80 to 400 ms depending on reward kind, with `R`, then `m = tanh(R)`. PAM stimulation ran 80 to 400 ms depending on reward kind, with
overlapping pulses taking their maximum. overlapping pulses taking their maximum.

View file

@ -1,6 +1,6 @@
# Rewards and learning # 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 `services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the
gates and the rules); this page says what each rule pays for and why it is allowed to. The gates and the rules); this page says what each rule pays for and why it is allowed to. The
prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules
@ -23,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 | | `trainer` | `trainer` | +0.5 | 200 ms | Each named `EVENT_BEAT_*` flag once, except the flags classified as story milestones |
| `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` | | `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` |
| `badge` | `badge` | +3 | 400 ms | Each newly set badge bit | | `badge` | `badge` | +3 | 400 ms | Each newly set badge bit |
| `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger | | `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 | | `catch` | `wildwin` | +0.30, +0.10 | 150 ms | A wild Pokémon kept by a ball: +0.30 for a species this run had never owned, +0.10 for a repeat; at most three payouts per species for the lifetime of the ledger |
| `talk` | `explore` | +0.10 | 100 ms | A conversation the fly opened with a person or a sign **indoors**, paid when its box closes; once per `(map, sprite slot or sign text id)` for the lifetime of the ledger |
| `item` | `explore` | +0.15 | 120 ms | An item ball or a hidden item picked up, on any map; once per item for the lifetime of the ledger |
Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")` Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")`
is `None` by test. The values in one frame sum into `R`, and the network reinforces once with is `None` by test. The values in one frame sum into `R`, and the network reinforces once with
@ -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 log, `/status` and the checkpoint. Changing the ticker copy means opening the feed's closed kind
set, which this rule deliberately did not do. set, which this rule deliberately did not do.
`talk` and `item` publish on `explore`, for the same reason `boundary` does: each is the fly
finding what is in a place -- new ground, a door, a person or sign it opened, an item it picked up
-- at the same quiet scale (0.05 to 0.15). Not `area`, which counts maps and is a notable row; not
`story`, which is the plot; not `wildwin`, which is a battle. No feed kind was added, so
`docs/feed-protocol.md` and the stage's switch statements did not move. The Pokémon Red ticker's
`explore` row still says "new place" ("3 new places" collapsed) for all four: "new find" was
proposed and the operator kept "new place" (2026-09-23), so a conversation or an item reads as a
new place on screen. The event labels -- `TALKED TO #<slot> IN AREA <map>`, `READ SIGN #<id> IN
AREA <map>`, `FOUND ITEM #<item>`, `FOUND A HIDDEN ITEM` -- reach the event log, `/status` and the
checkpoint. Both also reset the stage's stall meter, which counts `explore`: engaging with a
building is progress in the sense the operator asked for.
## Catch rewards ## Catch rewards
The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not only for The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not only for
@ -82,6 +96,87 @@ well below a badge (3.0). A catch of a new species pays 0.80 across two kinds, w
a story flag and a badge -- deliberately, because it is the one event that is both a discovery and a story flag and a badge -- deliberately, because it is the one event that is both a discovery and
a thing the fly had to do on purpose. a thing the fly had to do on purpose.
## Engagement rewards
The operator's decision of 2026-09-23, recorded with the port decisions: reward the fly for
engaging *inside* buildings and stop paying it for leaving them. It was chosen over a pad rule and
over weighting the choice, and it is a catalog change -- an operator decision, like the catch
reward -- not a loop-review fix (`docs/loop-review.md`). It answers a shape the loop reviews kept
finding in Pewter: `GO OBJECTIVE` into a building and `GO OUT` straight back, paid for the door on
the way out and for nothing inside.
**Indoors** is two of the cartridge's own tables, and nothing hand-classified
(`pokemon_red/engage.rs`, `indoor`). `CheckIfInOutsideMap` (`home/overworld.asm`) is the game's
outdoor test -- tileset `OVERWORLD` or `PLATEAU` -- and `WarpFound2` labels its other branch
`.indoorMaps`; on its own that would call Viridian Forest and every cave indoors, and their exits
are how the fly gets anywhere. `BikeRidingTilesets` (`data/tilesets/bike_riding_tilesets.asm`) is
the list of places the bicycle may be ridden -- `OVERWORLD`, `FOREST`, `UNDERGROUND`, `SHIP_PORT`,
`CAVERN` -- and the bike is the one thing the cartridge refuses inside a building by rule. A map is
indoors when its `wCurMapTileset` is in neither: every house, mart, Pokémon Center, gym, gate, lab
and museum, the S.S. Anne, Silph Co., the Pokémon Tower, the Mansion, the Rocket Hideout and the
Indigo Plateau's rooms. Not the forest, a cave, the Underground Path or Vermilion's dock.
**`talk`, +0.10.** Paid on the sample the text box closes, for a conversation that
1. *the fly opened*: on the last sample before the font bit (`wFontLoaded` bit 0) rose, the fly had
the joypad -- no `wJoyIgnore`, no simulated input, no scripted movement -- was standing still
(`wWalkCounter` zero, the only state the overworld reads A in) and stood where it stands now. A
script's text usually opens with the joypad taken; one opened by a map script the frame after a
step ends can still look opened by the fly, and is then held to rule 2 (below);
2. *is with the thing in front of it*: `DisplayTextID` copies its argument into `wSpriteIndex` --
a sprite slot up to `wNumSprites`, or a text id -- and the sprite must stand on the tile the
player faces (or one further, across a counter, on a tileset that has counter tiles, which is
`IsSpriteOrSignInFrontOfPlayer`'s own long reach), or the text id must be the sign's on that
tile. The byte arrives about **twenty frames after** the font bit (measured on the cartridge:
`DisplayTextIDInit` loads the font's tiles first) and until then still names the previous
text's subject, so it is read once it has changed or 45 samples have passed, and only while the
bottom dialogue box is drawn -- the start menu draws its own box elsewhere. An item ball is a
sprite but not a person, and pays `item`;
3. *opened indoors*, on the map the box opened on;
4. *finished*: the box closed on the same map. A conversation that ends in a warp, a rollback or a
restore pays nothing.
The ledger is the adapter's lifetime `seen` set, keyed `talk:<map>:sprite:<slot>` or
`talk:<map>:sign:<text id>` -- the same "map and object index" the macros' `talked` ledger uses,
but **not** that ledger: the macros' ledger is session state and is thrown away on a restore; this
one is checkpointed and survives a rollback, so talking to the same person again, after a restore
or not, pays nothing. The trainer the fly speaks to before a battle is a person and pays once; the
nurse, a clerk and a sign each pay once per map.
**`item`, +0.15.** Read from the cartridge's own "this one has been taken" bits, on any map.
An item ball is one of the map's toggleable sprites (`wToggleableObjectList`, sprite slot and
global index) whose `wMapSpriteExtraData` is `(item id, 0)` -- the shape `LoadMapHeader` writes for
an `ITEM` `object_event` and for nothing else (a trainer's is `(class, number)` with numbers from
1, a person's two zeroes); `PickUpItem` sets its global bit in `wToggleableObjectFlags` through
`HideObject`, and only after `GiveItem` succeeded, so a full bag pays nothing. A hidden item is a
bit of `wObtainedHiddenItemsFlags`, set by `FoundHiddenItemText` after `GiveItem` and by nothing
else; hidden *coins* have their own bitset and are not items. Either pays when its bit rises
between two playable samples, keyed `item:<global index>` or `hidden:<index>`, once for the life
of the ledger. A gift item from a script (the Old Amber, a TM from a person) is not an item ball:
the conversation pays `talk`, and the item nothing.
**The seed.** The first playable sample that finds the key `items:seeded` absent -- a fresh
adapter, or a `v6` ledger restored under `v7` -- writes a key for every bit the game already shows
as taken and pays for none of them, so a rollback to a slot from before a `v6`-era pickup cannot
pay for taking it again. The two item balls a script *reveals* are left out of the seed, because
their bits are set from a new game until Giovanni's defeat clears them: the Rocket Hideout's Silph
Scope and Lift Key (`$87`, `$88`), the only `ITEM` entries `data/maps/toggleable_objects.asm` starts
`OFF`.
**`boundary` indoors.** Every exit on an indoor map is still written to the ledger, so
`exit_visited` answers exactly what it did and the macros see no change, but nothing is paid. A
town's doors, a route's edges, the forest's gates and a cave's ladders pay as before. One
consequence, measured on the cartridge (`tests/rom_engage.rs`): for the thirty-odd frames of
`PlayMapChangeSound` the cartridge has written the destination into `wCurMap` while the tileset and
the warp table are still the map being left, so the exit the fly is standing on is classified by
the map it belongs to. Walking into a building through a town door still pays that door's on-exit
half, 0.10, once, keyed under the building's id as it always was; walking out pays nothing.
**The scale.** A building's worth of engagement -- a few people, a sign, perhaps a ball -- is
0.3 to 0.6: more than the 0.15 its door paid for being left, less than a new Pokédex entry per
person, far below a badge. Everything is once per thing for the lifetime of the ledger, so no
building can be farmed.
## Gates ## Gates
Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other
@ -110,6 +205,10 @@ therefore replays none of it.
## Boundary rewards ## Boundary rewards
Since `pokered-unique8-v7` everything below holds **outdoors** -- towns, routes, the forest,
caves -- and on an indoor map the same keys are written and nothing is paid ("Engagement rewards"
above has the definition of indoors and the one transition frame worth knowing about).
`docs/design/room-escape.md` section 2. The rule pays 0.05 the first time the fly stands on a tile `docs/design/room-escape.md` section 2. The rule pays 0.05 the first time the fly stands on a tile
orthogonally adjacent to one of the current map's exits, and 0.10 the first time it stands on the orthogonally adjacent to one of the current map's exits, and 0.10 the first time it stands on the
exit tile itself. Both are keyed into the adapter's lifetime `seen` ledger as exit tile itself. Both are keyed into the adapter's lifetime `seen` ledger as
@ -177,7 +276,20 @@ body picks the macro; the descending neurons press the buttons.**
## Honesty ## 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. That evidence is not proof: a map script runs
one frame after a step ends and may open text while the fly is still, controllable and on the same
tile. Such text pays only if it names the person or sign the fly is facing, once per key; in the
early game none can (checked: the museum ticket man, the Route 22 and Route 5 guards, Viridian
Mart, Oak's Lab), and a few late ones can once each (the Fighting Dojo master, the Elite Four
after their battles). Review of 2026-09-23.
The catalog also includes catches. The honesty panel's copy is not data-driven from the catalog --
`apps/stage/src/lib/schedule.ts`'s rotating card is four written lines and lists no kinds -- so `apps/stage/src/lib/schedule.ts`'s rotating card is four written lines and lists no kinds -- so
there was nothing to regenerate and the copy is unchanged. The sentences below are where the there was nothing to regenerate and the copy is unchanged. The sentences below are where the
argument lives. argument lives.

View file

@ -832,3 +832,21 @@ median; a two-fly transition near 10 to 12 ms at the median in every execution m
10 gains `unrewarded` (100+ decisions, no reward, no new ground, two probes), still never 10 gains `unrewarded` (100+ decisions, no reward, no new ground, two probes), still never
acting. Also shipped: EDGE-01, the feed over flybus behind FLY_FEED_VIA, off (`direct`) acting. Also shipped: EDGE-01, the feed over flybus behind FLY_FEED_VIA, off (`direct`)
everywhere. Next: row 59, Route 3's neighbours and Mt. Moon's doors in the geography table. everywhere. Next: row 59, Route 3's neighbours and Mt. Moon's doors in the geography table.
- 2026-09-23 (the operator's reset): the live run restarted from milestone 1, the bedroom, with the
brain as it was then (65 brain seconds), to watch the macros in the early game. The v5 archive
migrated to v6. It reached rung 9, Viridian Forest, 31 minutes later.
- 2026-09-23 (v0.6.0, the operator's decision, plus loop review row 60): the fly is paid for
engaging inside buildings and not for leaving them. New catalog kinds `talk` (+0.10, the first
conversation the fly opens with each person or sign on an indoor map, once per key for the run)
and `item` (+0.15, each item ball or hidden item, once); `boundary` pays nothing for an indoor
exit. Indoor is the cartridge's own tables (not CheckIfInOutsideMap's outdoor tilesets and not a
bike tileset). Both publish on `explore`; the ticker still reads "new place" (the operator kept
the wording). Adapter `pokered-unique8-v7`; a deploy with FLY_ACCEPT_ADAPTERS=pokered-unique8-v6
migrates the run and seeds every item the game already shows taken without paying it. Row 60:
from the fresh run, TAIL WHIP (MOVE 2) was dealt beside TACKLE after the cartridge had begun
refusing it ("Nothing happened!": the stage at -6 or the stat at 1), and the brain's MOVE 2
preference drew battles out until Squirtle fainted. A move the cartridge would refuse -- read
from its move table and the target's stages, status, types, Mist and substitute -- is not dealt
beside one that works, as a move out of PP is not. ROM test: 2,657 of 2,657 own-turn frames dealt
the refused move before, 0 of 568 after; battles won 0 -> 3 of 12 -> 14. The real-brain hunt did
not finish at this load; recorded as a deviation. Ethos check held.

View file

@ -300,8 +300,9 @@ if [ -n "$RELEASE_TARBALL" ]; then
# #
# FLY_ACCEPT_ADAPTERS is the *other* override, and the opposite one: it keeps # FLY_ACCEPT_ADAPTERS is the *other* override, and the opposite one: it keeps
# the run. It names adapter version strings whose checkpoints the new build # the run. It names adapter version strings whose checkpoints the new build
# may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 for the deploy # may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 for the deploy
# that adds the catch reward. It only applies when the adapter segment is the # 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 # ONLY difference between the two strings and the new build's adapter says it
# can read that one; a dataset, kernel, emulator or state-format change is # can read that one; a dataset, kernel, emulator or state-format change is
# still a refusal, because none of those has a migration. The same variable is # still a refusal, because none of those has a migration. The same variable is
@ -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 * deploy a build whose string matches (check out the commit the running release was built from), or
* if the ADAPTER VERSION is the only segment that differs and the new build documents a * if the ADAPTER VERSION is the only segment that differs and the new build documents a
migration from the old one, re-run with FLY_ACCEPT_ADAPTERS set to the adapter id in the live migration from the old one, re-run with FLY_ACCEPT_ADAPTERS set to the adapter id in the live
string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-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 rule at restore. See docs/design/flysim.md, \"Restoring across an adapter version\", or
* accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which * accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which
archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and

View file

@ -2757,5 +2757,88 @@ route survey above is the reproduction; the hunt is reported, not smoothed.
- `cargo clippy --all-targets`: **0 warnings**. - `cargo clippy --all-targets`: **0 warnings**.
- `npm test` 663 passed; `npm run typecheck` clean. - `npm test` 663 passed; `npm run typecheck` clean.
- `infra/tests/lint.sh`: ALL CHECKS PASSED, check 10's two new cases and the de-PII guard included. - `infra/tests/lint.sh`: ALL CHECKS PASSED, check 10's two new cases and the de-PII guard included.
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`**, byte-identical to the - `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had:
base. Decoder, reward catalog, adapter version and roles untouched. 648 bytes, sha256 `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68`
on `main` after the engagement rewards (adapter v7). Decoder, reward catalog, adapter version
and roles untouched.
## 2026-09-23, row 60: TAIL WHIP after it stopped working
### What was live
Map 12 (Route 1), rank 9, v0.5.5, after the operator reset the run to milestone 1: Squirtle L5
(TACKLE, TAIL WHIP) in wild battles with Pidgey, "Nothing happened!" on the screen. Since the reset
`MOVE 2` 183 starts and `MOVE 1` one; the last reward 25 brain minutes before the checkpoint,
1,600 decisions since, one wild win in the whole run. Check 10 flagged `unrewarded` -- correctly:
100+ decisions, no reward event, no new ground on two probes. It is unchanged.
### The survey: the pad on the fly's own turn
The route probe from the checkpoint (`FLY_PROBE_CATCH=route`, `FLY_PROBE_PREFER="MOVE 2"`, the
live readout's favourite; 72,000 frames) now prints the battle bytes on every pad change. The
checkpoint is the frame Squirtle fainted: Pidgey L3, DEFENSE stage 1 (-6), DEFENSE 2, TAIL WHIP's
row `$27`: effect `$13` power 0. In the next battle DEFENSE reached **1 at stage -5**, which
`StatModifierDownEffect` refuses as well. From then on the own-turn pad was `MOVE 1, MOVE 2, RUN`
over the menu and `BACK, MOVE 1, MOVE 2` over the list: **every own-turn pad with TAIL WHIP
refused dealt `MOVE 2`** (72 of 72; a 73rd frame was a list with no placeable cursor, `NEXT`
alone), TACKLE beside it every time. `MOVE 1` was never missing; `MOVE 2` was a
button that could change nothing.
| # | trap | trigger | test | fix, or why it is left |
| --- | --- | --- | --- | --- |
| 60 | `MOVE n` is bound by PP alone, so a move the cartridge refuses ("Nothing happened!", "didn't affect") stays on the pad beside one that works | any stat move at its stage or stat limit, any status move at a target it cannot affect; Route 1, TAIL WHIP at a Pidgey's DEFENSE 1, every battle lost | `a_move_the_cartridge_answers_with_nothing_is_off_the_pad_beside_one_it_does_not`, `a_spent_move_and_a_move_without_effect_leave_the_one_that_works`, `with_no_move_that_does_anything_the_moves_stay_as_pp_deals_them`, six `state` tests, `tail_whip_at_its_limit_is_not_dealt_and_a_route_one_battle_is_won` (ROM) | **fixed**: `state::move_data` reads the move's row of `Moves` (`$0E:$4000`) from the cartridge image; `state::move_without_effect` answers the refusals decided before the roll (stage 1/13, stat 1/999, Mist or substitute, a statused, Poison or Ground target); the palette treats such a move as it treats a spent one. `docs/design/macros.md` 12.23, `macros-wram.md` section 13 |
### Before and after
The ROM-gated run, 72,000 frames (20.1 brain minutes) from the checkpoint, the real palette
driven with `MOVE 2` preferred and a uniform choice otherwise; base is this branch with the
palette commit reverted:
| measure | base | branch |
| --- | ---: | ---: |
| own-turn frames with a refused move beside a useful one | 2,657 | 568 |
| ... of them dealing the refused move | **2,657** | **0** |
| battles ended / won | 12 / **0** | 14 / **3** |
| battles ended with the fly's Pokémon fainted | 12 | 8 |
| battle length, frames, median / max | 4,970 / 8,994 | 3,594 / 4,793 |
| `MOVE 2` / `MOVE 1` / `RUN` starts | 94 / 0 / 0 | 56 / 5 / 4 |
The route survey, same driver and checkpoint: base never leaves Pallet Town, Red's house and
Route 1 (637 tiles at the end); the branch reaches Viridian City, its Pokémon Center and mart and
Route 2 (748).
The survey itself, same driver: own-turn pads dealing a refused TAIL WHIP 72 -> **0** (of 11 at
the limit); `MOVE 2` / `MOVE 1` / `RUN` done 94 / 0 / 0 -> 56 / 4 / 4; maps with a macro done 4 -> 10.
**The real-brain trap hunt was not run to the end.** Both 20-minute arms (`trap_hunt` now reports
payouts by kind, battle lengths and wins, and `MOVE n` starts on a move without effect) were
started from the checkpoint and stopped after 1 h 48 min wall at about 19 CPU-minutes each: the box
sat at load 25-40 and a stub arm ticks the same brain. The ROM-gated run and the survey above are
the before/after; they drive the real palette with the live readout's measured preference instead
of the brain, which is the deviation.
### Residuals, named rather than worked around
- **The fly still spends TAIL WHIP while it works.** Six presses at stage 7 to 1 are the fly's
choice, and a Squirtle at 8/20 can faint doing it; eight of fourteen battles on the branch
still ended that way. What changed is that the seventh is not on the pad.
- **Not covered, same kind, nothing early reaches it:** Confuse Ray and Supersonic on a confused
target, Leech Seed on a seeded or Grass target, Focus Energy, Mist, Reflect and Light Screen
already up, Disable on a disabled target, and a damaging move the type chart makes "doesn't
affect" (the chart is another ROM table). `macros-wram.md` section 13.
- **With no move that would do anything, the moves stay as PP deals them.** Taking them away would
leave an open list with `BACK` alone (12.11); a turn that ends on "Nothing happened!" still ends.
### Gates
- `cargo test --release -p flybrain-gb` with `FLY_ROM`: 426 + 27 passed, 0 failed.
- `cargo test --release -p flysim --no-fail-fast` with `FLY_ROM` and the row-60 checkpoint:
all passed but `integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
the known load failure (`total` 1 against 38, or the feed at 14-18 Hz, on a box at load 30),
failing the same two ways on the base.
- `cargo clippy --workspace --all-targets -- -D warnings`: clean.
- `npm test` 663 passed; `npm run typecheck` clean; `infra/tests/lint.sh` ALL CHECKS PASSED.
- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had:
648 bytes, sha256 `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68`
on `main` after the engagement rewards (adapter v7). Decoder, reward catalog, adapter version
and roles untouched.

View file

@ -263,7 +263,7 @@ pct exec $CTID -- /opt/fly/bin/fly-reset-to-milestone $N
# (a) the running release already wrote that checkpoint -> nothing to deploy, skip to 5. # (a) the running release already wrote that checkpoint -> nothing to deploy, skip to 5.
# (b) the new build bumps the ADAPTER VERSION and nothing else -> name the checkpoint's # (b) the new build bumps the ADAPTER VERSION and nothing else -> name the checkpoint's
# adapter so the gate and flysim both migrate instead of refusing: # adapter so the gate and flysim both migrate instead of refusing:
FLY_ACCEPT_ADAPTERS=pokered-unique8-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 # The gate logs "the adapter version is the only difference, and it is named; the run is KEPT
# and migrated", and writes FLY_ACCEPT_ADAPTERS into /etc/fly/fly.env so flysim applies the # and migrated", and writes FLY_ACCEPT_ADAPTERS into /etc/fly/fly.env so flysim applies the
# same rule at restore. Anything else about the string differing is still a refusal. # same rule at restore. Anything else about the string differing is still a refusal.

View file

@ -335,8 +335,9 @@ FLY_FEED_VIA=direct
# two strings AND the new build's adapter declares a migration from that one. A # two strings AND the new build's adapter declares a migration from that one. A
# dataset, kernel, plasticity, emulator or state-format difference is still a # dataset, kernel, plasticity, emulator or state-format difference is still a
# refusal. Set it for the one deploy that needs it and leave it out afterwards; # refusal. Set it for the one deploy that needs it and leave it out afterwards;
# 05-deploy writes it into /etc/fly/fly.env only while it is set. # 05-deploy writes it into /etc/fly/fly.env only while it is set. The v0.6.0
# FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 # deploy (pokered-unique8-v7, the engagement rewards) is the one that needs:
# FLY_ACCEPT_ADAPTERS=pokered-unique8-v6
# --- push mode -------------------------------------------------------------- # --- push mode --------------------------------------------------------------
# local: flypush.service stays disabled, everything else identical to prod. # local: flypush.service stays disabled, everything else identical to prod.

View file

@ -52,7 +52,7 @@ pub fn example_composition() -> LegacyComposition {
byte_length: 1_048_576, byte_length: 1_048_576,
format: "gb-rom".to_owned(), format: "gb-rom".to_owned(),
}, },
adapter: "pokered-unique8-v6".to_owned(), adapter: "pokered-unique8-v7".to_owned(),
symbol_provenance: pokered.to_owned(), symbol_provenance: pokered.to_owned(),
mode: "macros".to_owned(), mode: "macros".to_owned(),
macro_channels: decoder_vector("macros")["macroChannels"] macro_channels: decoder_vector("macros")["macroChannels"]
@ -71,7 +71,7 @@ pub fn example_composition() -> LegacyComposition {
audio_sample_rate: 48_000, audio_sample_rate: 48_000,
}, },
flysim_compatibility: format!( flysim_compatibility: format!(
"{}/pokered-unique8-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::KERNEL_VERSION,
gameboy::FAFB_V783_FINGERPRINT, gameboy::FAFB_V783_FINGERPRINT,
gameboy::PLASTICITY_VERSION, gameboy::PLASTICITY_VERSION,

View file

@ -492,7 +492,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -560,9 +560,9 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-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": [ "recipeLines": [
"fly-session/composition-v1", "fly-session/composition-v1",
"session=<sessionId>", "session=<sessionId>",

View file

@ -5202,7 +5202,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5243,7 +5243,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-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" "reason": "the profile AssetRef digest is the legacy profile's"
}, },
@ -5267,7 +5267,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "raw", "mode": "raw",
"macroChannels": [ "macroChannels": [
@ -5308,7 +5308,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-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" "reason": "raw mode deals no macro channels"
}, },
@ -5332,7 +5332,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [] "macroChannels": []
@ -5368,7 +5368,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-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" "reason": "macros mode needs its channels"
}, },
@ -5392,7 +5392,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5433,7 +5433,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-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" "reason": "the executor extension is pokered-macros-v1"
}, },
@ -5457,7 +5457,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5498,7 +5498,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "exact", "restore": "exact",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-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" "reason": "the legacy composition declares legacy-transient-reset"
}, },
@ -5522,7 +5522,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5563,7 +5563,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-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" "reason": "the rollback policy needs gameboy-slots-v1"
}, },
@ -5587,7 +5587,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5628,7 +5628,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-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" "reason": "one Game Boy frame is 8572265625/512 ns, not 1/60 s"
}, },
@ -5652,7 +5652,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5693,7 +5693,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-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" "reason": "the FLYSIM01 string and the declaration describe the same fly"
}, },
@ -5717,7 +5717,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5758,7 +5758,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSESS1", "checkpointFormatOfRecord": "FLYSESS1",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-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" "reason": "FLYSIM01 stays until RETIRE-01"
}, },

View file

@ -3337,7 +3337,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -3405,11 +3405,11 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-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", "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\"}", "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": "77d8a88ff7fea51eb29b1ae75fc9cf8c17c184e6a1e5a585e86399f41fd9c9b0" "digest": "44916db0a0846d3338f50d4f24d9d4e0214fa239af7d15e4174213cd93b07e28"
}, },
{ {
"name": "an example legacy composition in raw mode", "name": "an example legacy composition in raw mode",
@ -3431,7 +3431,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "raw", "mode": "raw",
"macroChannels": [] "macroChannels": []
@ -3467,11 +3467,11 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-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": "", "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\"}", "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": "ac42e702ec6c9b09482b1ee335743ef85fe599a72e2c34a4d32aea67d67d0201" "digest": "568eceeb052ebf4fbd0c10661e227783cecc8ac7fee75ae6ba84335c684bfb7c"
}, },
{ {
"name": "a rank climb: slot saved, then the milestone capture", "name": "a rank climb: slot saved, then the milestone capture",

View file

@ -16,7 +16,7 @@ ROM bytes -> Emulator::run_frame -> RGBA frame + PCM + WRAM
| --- | --- | | --- | --- |
| `emulator` | Safe wrapper over binjgb: frames, framebuffer, buttons, WRAM, audio, save states, ROM hash | | `emulator` | Safe wrapper over binjgb: frames, framebuffer, buttons, WRAM, audio, save states, ROM hash |
| `adapter` | `GameAdapter`, `RewardEvent`, `ProgressSnapshot`, `MemoryReader`, `adapter_for` | | `adapter` | `GameAdapter`, `RewardEvent`, `ProgressSnapshot`, `MemoryReader`, `adapter_for` |
| `pokemon_red` | The `pokered-unique8-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 | | `platformer` | The `sml-progress-v1` Super Mario Land adapter, its catalog and its RAM map |
| `ratchet` | The progress ratchet, generic over the adapter's rank and its `RecoveryPolicy` | | `ratchet` | The progress ratchet, generic over the adapter's rank and its `RecoveryPolicy` |
| `recovery` | Rolling the game back to the ratchet's best safe snapshot | | `recovery` | Rolling the game back to the ratchet's best safe snapshot |

View file

@ -56,10 +56,10 @@ impl MemoryReader for &mut dyn MemoryReader {
/// One reward payout in one frame. /// One reward payout in one frame.
/// ///
/// `kind` is an adapter-owned interned name (Pokémon: `milestone`, /// `kind` is an adapter-owned interned name (Pokémon: `milestone`,
/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`); it is the /// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`,
/// key the statistics counters and the on-screen ticker group by. Field names /// `talk`, `item`); it is the key the statistics counters and the on-screen ticker group
/// serialize exactly as the prototype's `RewardEvent` did, so a checkpoint /// by. Field names serialize exactly as the prototype's `RewardEvent` did, so a
/// written by either implementation reads in the other. /// checkpoint written by either implementation reads in the other.
#[derive(Debug, Clone, PartialEq, Serialize)] #[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RewardEvent { pub struct RewardEvent {
pub kind: &'static str, pub kind: &'static str,
@ -241,7 +241,7 @@ impl std::error::Error for AdapterError {}
/// A game, as the sim loop sees it. /// A game, as the sim loop sees it.
pub trait GameAdapter: Send { pub trait GameAdapter: Send {
/// Adapter version string, pinned into the checkpoint compatibility string. /// Adapter version string, pinned into the checkpoint compatibility string.
/// Pokémon: `pokered-unique8-v6`. /// Pokémon: `pokered-unique8-v7`.
fn id(&self) -> &'static str; fn id(&self) -> &'static str;
/// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration /// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration

View file

@ -30,7 +30,7 @@ pub const PROTOTYPE_PLASTICITY_VERSION: &str = "fly-kc-mbon-rstdp-v2";
pub struct Compatibility<'a> { pub struct Compatibility<'a> {
/// `kernelVersion(config)` from the neural library. /// `kernelVersion(config)` from the neural library.
pub neural_kernel_version: &'a str, pub neural_kernel_version: &'a str,
/// The adapter's version string, e.g. `pokered-unique8-v6`. /// The adapter's version string, e.g. `pokered-unique8-v7`.
pub adapter: &'a str, pub adapter: &'a str,
/// The dataset's seven SHA-256 digests joined with `:`. /// The dataset's seven SHA-256 digests joined with `:`.
pub dataset_fingerprint: &'a str, pub dataset_fingerprint: &'a str,
@ -78,7 +78,7 @@ const ADAPTER_SEGMENT: usize = 1;
/// The environment variable that opts a deploy into the adapter migration. /// The environment variable that opts a deploy into the adapter migration.
/// ///
/// Read by flysim at restore and by `infra/05-deploy.sh`'s compatibility gate. Comma- or /// Read by flysim at restore and by `infra/05-deploy.sh`'s compatibility gate. Comma- or
/// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v5`. /// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v6`.
pub const ACCEPT_ADAPTERS_ENV: &str = "FLY_ACCEPT_ADAPTERS"; pub const ACCEPT_ADAPTERS_ENV: &str = "FLY_ACCEPT_ADAPTERS";
/// What a build may do with a checkpoint whose compatibility string is not its own. /// What a build may do with a checkpoint whose compatibility string is not its own.
@ -180,7 +180,7 @@ mod tests {
assert_eq!( assert_eq!(
fixture().prototype_string(), fixture().prototype_string(),
concat!( concat!(
"lif-1ms-f64-v2/pokered-unique8-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/", "fly-kc-mbon-rstdp-v2/",
"binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/", "binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/",
"pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
@ -194,37 +194,37 @@ mod tests {
#[test] #[test]
fn an_identical_string_restores_without_any_opt_in() { fn an_identical_string_restores_without_any_opt_in() {
let current = with_adapter("pokered-unique8-v6"); let current = with_adapter("pokered-unique8-v7");
assert_eq!(decide(&current, &current, &[], &[]), RestoreDecision::Exact); assert_eq!(decide(&current, &current, &[], &[]), RestoreDecision::Exact);
} }
#[test] #[test]
fn a_v5_checkpoint_restores_under_v6_only_with_the_opt_in() { fn a_v6_checkpoint_restores_under_v7_only_with_the_opt_in() {
let old = with_adapter("pokered-unique8-v5"); let old = with_adapter("pokered-unique8-v6");
let new = with_adapter("pokered-unique8-v6"); let new = with_adapter("pokered-unique8-v7");
let migrates = ["pokered-unique8-v5"]; let migrates = ["pokered-unique8-v6"];
assert!(matches!(decide(&old, &new, &migrates, &[]), RestoreDecision::Refuse(_))); assert!(matches!(decide(&old, &new, &migrates, &[]), RestoreDecision::Refuse(_)));
assert_eq!( assert_eq!(
decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v5"))), decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v6"))),
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() } RestoreDecision::MigrateAdapter { from: "pokered-unique8-v6".to_string() }
); );
// And only for a pair the running adapter says it can migrate. // And only for a pair the running adapter says it can migrate.
assert!(matches!( assert!(matches!(
decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v5"))), decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v6"))),
RestoreDecision::Refuse(_) RestoreDecision::Refuse(_)
)); ));
} }
#[test] #[test]
fn nothing_but_the_adapter_segment_may_move() { fn nothing_but_the_adapter_segment_may_move() {
let migrates = ["pokered-unique8-v5"]; let migrates = ["pokered-unique8-v6"];
let accepted = accepted_adapters(Some("pokered-unique8-v5")); let accepted = accepted_adapters(Some("pokered-unique8-v6"));
let new = with_adapter("pokered-unique8-v6"); let new = with_adapter("pokered-unique8-v7");
// A different dataset, with the same adapter bump, is not a migration. // A different dataset, with the same adapter bump, is not a migration.
let other_dataset = Compatibility { let other_dataset = Compatibility {
adapter: "pokered-unique8-v5", adapter: "pokered-unique8-v6",
dataset_fingerprint: "00:11:22:33:44:55:66", dataset_fingerprint: "00:11:22:33:44:55:66",
..fixture() ..fixture()
} }
@ -236,7 +236,7 @@ mod tests {
// Neither is a different kernel, and neither is a string of another shape. // Neither is a different kernel, and neither is a string of another shape.
let other_kernel = let other_kernel =
Compatibility { adapter: "pokered-unique8-v5", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() } Compatibility { adapter: "pokered-unique8-v6", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() }
.string(); .string();
assert!(matches!( assert!(matches!(
decide(&other_kernel, &new, &migrates, &accepted), decide(&other_kernel, &new, &migrates, &accepted),
@ -250,8 +250,8 @@ mod tests {
assert!(accepted_adapters(None).is_empty()); assert!(accepted_adapters(None).is_empty());
assert!(accepted_adapters(Some(" ")).is_empty()); assert!(accepted_adapters(Some(" ")).is_empty());
assert_eq!( assert_eq!(
accepted_adapters(Some("pokered-unique8-v5, pokered-unique8-v4")), accepted_adapters(Some("pokered-unique8-v6, pokered-unique8-v5")),
vec!["pokered-unique8-v5".to_string(), "pokered-unique8-v4".to_string()] vec!["pokered-unique8-v6".to_string(), "pokered-unique8-v5".to_string()]
); );
} }

View file

@ -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 //! A direct port of the prototype's `src/reward/catalog.ts`, including the
//! declaration order, which is the order `counts` and `last` serialize in. //! declaration order, which is the order `counts` and `last` serialize in.
@ -21,6 +21,8 @@ pub mod kind {
pub const BADGE: &str = "badge"; pub const BADGE: &str = "badge";
pub const BOUNDARY: &str = "boundary"; pub const BOUNDARY: &str = "boundary";
pub const CATCH: &str = "catch"; pub const CATCH: &str = "catch";
pub const TALK: &str = "talk";
pub const ITEM: &str = "item";
} }
/// What a `catch` of a species this run has already caught pays. /// What a `catch` of a species this run has already caught pays.
@ -44,7 +46,7 @@ pub struct RewardRule {
pub stimulation_ms: u32, pub stimulation_ms: u32,
} }
pub const REWARDS: [RewardRule; 9] = [ pub const REWARDS: [RewardRule; 11] = [
RewardRule { RewardRule {
kind: kind::MILESTONE, kind: kind::MILESTONE,
label: "Story", label: "Story",
@ -125,6 +127,30 @@ pub const REWARDS: [RewardRule; 9] = [
value: 0.30, value: 0.30,
stimulation_ms: 150, stimulation_ms: 150,
}, },
// The operator's decision of 2026-09-23: pay the fly for engaging with what is *inside* a
// building rather than for leaving it (`boundary` pays nothing on an indoor map from v7).
// Both appended, for the reason every rule since `boundary` was: the declaration order is
// the key order `counts` serializes in, and every checkpoint already written carries the
// first nine in this order.
//
// `talk` is one payout per person or sign per map for the lifetime of the ledger, and only
// indoors: the conversation the fly opened by pressing A at it, paid when the box closes.
RewardRule {
kind: kind::TALK,
label: "Talk",
trigger: "Conversation the fly opened indoors; once per map and person or sign",
value: 0.10,
stimulation_ms: 100,
},
// `item` is one payout per item ball or hidden item for the lifetime of the ledger, on any
// map: the cartridge's own "this one has been taken" bit rising.
RewardRule {
kind: kind::ITEM,
label: "Item",
trigger: "Item ball or hidden item picked up; once per item",
value: 0.15,
stimulation_ms: 120,
},
]; ];
/// Position of `kind` in [`REWARDS`], or `None` for an unknown kind. This is /// Position of `kind` in [`REWARDS`], or `None` for an unknown kind. This is
@ -229,12 +255,17 @@ mod tests {
assert_eq!(rule(kind::CATCH).unwrap().value, 0.30); assert_eq!(rule(kind::CATCH).unwrap().value, 0.30);
assert_eq!(CATCH_REPEAT_VALUE, 0.10); assert_eq!(CATCH_REPEAT_VALUE, 0.10);
assert_eq!(rule(kind::CATCH).unwrap().stimulation_ms, 150); assert_eq!(rule(kind::CATCH).unwrap().stimulation_ms, 150);
// Nor these: the operator's engagement rules, `pokered-unique8-v7`.
assert_eq!(rule(kind::TALK).unwrap().value, 0.10);
assert_eq!(rule(kind::TALK).unwrap().stimulation_ms, 100);
assert_eq!(rule(kind::ITEM).unwrap().value, 0.15);
assert_eq!(rule(kind::ITEM).unwrap().stimulation_ms, 120);
assert!(rule("blackout").is_none(), "the catalog has no penalties"); assert!(rule("blackout").is_none(), "the catalog has no penalties");
assert!(REWARDS.iter().all(|rule| rule.value > 0.0)); assert!(REWARDS.iter().all(|rule| rule.value > 0.0));
} }
#[test] #[test]
fn 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(); let order: Vec<&str> = REWARDS.iter().map(|rule| rule.kind).collect();
assert_eq!( assert_eq!(
order, order,
@ -248,9 +279,11 @@ mod tests {
kind::BADGE, kind::BADGE,
kind::BOUNDARY, kind::BOUNDARY,
kind::CATCH, 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] #[test]

View file

@ -0,0 +1,423 @@
//! What the engagement rules of `pokered-unique8-v7` read: indoors, a conversation the fly
//! opened, and an item picked up.
//!
//! The operator's decision of 2026-09-23 (`docs/rewards-learning.md`, "Engagement rewards"):
//! pay the fly for engaging with what is inside a building -- `talk` and `item` -- and stop
//! paying `boundary` for walking back out of one. Everything here is a read of game memory
//! after a frame; nothing chooses, biases or presses a button, and nothing is checkpointed.
//! The lifetime ledgers the payouts are keyed into are the adapter's own `seen` set, in
//! [`super::PokemonRedReward`].
use crate::adapter::MemoryReader;
use super::macros::state::{Facing, Player};
use super::state::{self, poke};
use super::symbols::ram;
/// Tileset ids, `constants/tileset_constants.asm` at [`super::symbols::POKERED_COMMIT`]. Only the
/// ones the indoor rule names.
pub mod tileset {
pub const OVERWORLD: u8 = 0;
pub const FOREST: u8 = 3;
pub const UNDERGROUND: u8 = 11;
pub const SHIP_PORT: u8 = 14;
pub const CAVERN: u8 = 17;
pub const PLATEAU: u8 = 23;
/// `DEF NUM_TILESETS EQU const_value`: 24 tilesets, ids 0 to 23.
pub const COUNT: u8 = 24;
}
/// Whether a map with this tileset is **inside a building**, by the cartridge's own two tables.
///
/// - `CheckIfInOutsideMap` (`home/overworld.asm`) is the game's own outdoor test: tileset
/// `OVERWORLD` or `PLATEAU` is "a town or route", and `WarpFound2` labels the other branch
/// `.indoorMaps`. On its own that also calls Viridian Forest and every cave indoor.
/// - `BikeRidingTilesets` (`data/tilesets/bike_riding_tilesets.asm`) is the game's list of places
/// a bicycle may be ridden -- `OVERWORLD`, `FOREST`, `UNDERGROUND`, `SHIP_PORT`, `CAVERN` -- and
/// the bike is the one thing the cartridge refuses *inside a building* by rule.
///
/// Indoor is neither: not outside, and not somewhere the bike is allowed. That is every house,
/// mart, Pokémon Center, gym, gate, lab, museum, the S.S. Anne, Silph Co., the Pokémon Tower, the
/// Mansion, the Rocket Hideout and the Indigo Plateau's rooms -- and *not* Viridian Forest, a
/// cave, the Underground Path or Vermilion's dock, whose exits are how the fly gets anywhere.
/// A tileset id past the table is not indoor: an unreadable map is never a reason to withhold
/// `boundary`.
pub fn indoor(tileset: u8) -> bool {
use tileset::*;
tileset < COUNT
&& !matches!(
tileset,
OVERWORLD | PLATEAU | FOREST | UNDERGROUND | SHIP_PORT | CAVERN
)
}
/// What a conversation was with, as `DisplayTextID` names it: a sprite slot, or a sign's text id.
///
/// The same split the macros' session `talked` ledger uses, but this is not that ledger: the
/// payout is keyed into the adapter's lifetime `seen` set, which is checkpointed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Thing {
Sprite(u8),
Sign(u8),
}
/// A conversation the fly opened and the cartridge has now closed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Conversation {
pub map: u8,
pub thing: Thing,
}
impl Conversation {
/// The `seen` ledger key: one payout per `(map, object)` for the lifetime of the ledger.
pub fn key(&self) -> String {
match self.thing {
Thing::Sprite(slot) => format!("talk:{}:sprite:{slot}", self.map),
Thing::Sign(id) => format!("talk:{}:sign:{id}", self.map),
}
}
pub fn label(&self) -> String {
match self.thing {
Thing::Sprite(slot) => format!("TALKED TO #{slot} IN AREA {}", self.map),
Thing::Sign(id) => format!("READ SIGN #{id} IN AREA {}", self.map),
}
}
}
/// Samples after the box opens by which `DisplayTextID` has certainly written its argument.
///
/// `DisplayTextIDInit` sets the font bit and then loads the font's tiles into VRAM, which takes
/// frames; `DisplayTextID` copies its argument into `wSpriteIndex` only after that. Measured on
/// the cartridge (`tests/rom_engage.rs`, the Viridian Forest north gate): the bit rose on one
/// frame and the argument arrived **twenty frames** later. Until then the byte still holds
/// whatever the *last* text was about -- which may well be the person in front of the fly, from
/// a conversation that did not pay -- so it is not read as this conversation's argument until it
/// has changed, or until this many samples have gone by, after which an unchanged byte means the
/// new text is about the same thing as the last one. More than twice the measured delay.
const ARGUMENT_SETTLED: u8 = 45;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Armed {
map: u8,
x: u32,
y: u32,
/// The fly had the joypad and was standing still: [`state::controllable`] and a zero
/// `wWalkCounter`. The overworld only reads an A press in that state.
ready: bool,
/// `wSpriteIndex` before the box opened: the previous text's argument.
stale: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Opening {
map: u8,
samples: u8,
stale: u8,
/// The bottom dialogue box has been on screen during this opening.
dialogue: bool,
}
/// The `talk` rule's per-frame watch. Transient: a restore or a rollback clears it, so a
/// conversation in flight at a checkpoint pays nothing, which is the conservative answer.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TalkWatch {
armed: Option<Armed>,
opening: Option<Opening>,
pending: Option<Conversation>,
}
impl TalkWatch {
pub fn clear(&mut self) {
*self = Self::default();
}
/// A sample that is not the overworld -- a battle. Whatever the fly was doing before it is
/// not what opens the next text box, so the arming is dropped; a conversation already open
/// (a trainer the fly spoke to) stays pending and pays when its box is finally closed.
pub fn interrupt(&mut self) {
self.armed = None;
self.opening = None;
}
/// One overworld sample (`wIsInBattle` zero, every playability gate passed).
///
/// A conversation pays when all of this holds, each read out of WRAM:
///
/// 1. **The fly started it.** On the last sample before the text box opened
/// (`wFontLoaded` bit 0 rising) the fly had the joypad -- no ignored buttons, no simulated
/// input, no scripted movement ([`state::controllable`]) -- was standing still
/// (`wWalkCounter` zero, which is the only state the overworld reads A in) and stood on the
/// tile it is on now. Most script text opens with the joypad already taken, and is not
/// `ready`; a map script that runs the frame after a step ends can open text while the fly
/// is still `ready`, so it pays only if rule 2 names the thing in front (none in the early
/// game; the Fighting Dojo master and the Elite Four once each).
/// 2. **It is with the thing in front of the fly.** `DisplayTextID` copies its argument into
/// `wSpriteIndex` once the font is loaded ([`ARGUMENT_SETTLED`] has the timing, and why
/// the byte is read only once it has changed or settled), in the bottom dialogue box --
/// the start menu is drawn elsewhere. A value up to `wNumSprites` is a sprite slot, and that sprite must stand
/// on the tile the player faces -- or one further, across a counter, on a tileset that has
/// counter tiles (`IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter`). A larger
/// value is a text id, and it must be the text id of the sign on the tile the player faces.
/// An item ball is a sprite but not a person: it pays `item`, not `talk`.
/// 3. **Indoors**, by [`indoor`], on the map the box opened on.
/// 4. **It finished.** The box closed again on the same map. A conversation that ends in a
/// warp, a restore or a blackout pays nothing.
///
/// Returns the conversation on the sample the box closes; the caller pays it once per key.
pub fn observe(
&mut self,
memory: &mut dyn MemoryReader,
map: u8,
x: u32,
y: u32,
) -> Option<Conversation> {
let open = memory.read8(ram::wFontLoaded) & poke::BIT_FONT_LOADED != 0;
if !open {
// A box that closes before its argument was ever seen to change: a short text about
// the same thing as the last one. The argument was written before a letter printed,
// so it is this text's; the dialogue box must have been drawn for it.
let argument = memory.read8(ram::wSpriteIndex);
if let Some(opening) = self.opening.take()
&& opening.dialogue
&& opening.map == map
&& let Some(thing) = thing_named(memory, argument)
{
self.pending = Some(Conversation { map, thing });
}
let finished = self
.pending
.take()
.filter(|conversation| conversation.map == map);
let ready = state::controllable(memory) && memory.read8(ram::wWalkCounter) == 0;
let stale = argument;
self.armed = Some(Armed {
map,
x,
y,
ready,
stale,
});
return finished;
}
if let Some(armed) = self.armed.take()
&& armed.ready
&& armed.map == map
&& armed.x == x
&& armed.y == y
&& self.pending.is_none()
&& indoor(memory.read8(ram::wCurMapTileset))
{
self.opening = Some(Opening {
map,
samples: 0,
stale: armed.stale,
dialogue: false,
});
}
if let Some(opening) = self.opening.as_mut() {
opening.samples += 1;
opening.dialogue |= state::text_box(memory).waiting;
let map = opening.map;
let argument = memory.read8(ram::wSpriteIndex);
if argument != opening.stale || opening.samples >= ARGUMENT_SETTLED {
// One reading, whichever way it goes: the argument this text was opened with,
// and only while the dialogue box is what is drawn (not the start menu's).
let dialogue = state::text_box(memory).waiting;
self.opening = None;
if dialogue && let Some(thing) = thing_named(memory, argument) {
self.pending = Some(Conversation { map, thing });
}
}
}
None
}
}
/// The step `facing` points at from `(x, y)`, or `None` off the top or left of the map.
fn ahead(x: u8, y: u8, facing: Facing) -> Option<(u8, u8)> {
let (dx, dy) = facing.delta();
let x = u8::try_from(i16::from(x) + dx).ok()?;
let y = u8::try_from(i16::from(y) + dy).ok()?;
Some((x, y))
}
/// `wMapSpriteExtraData`'s two bytes for a sprite slot (1-based, as `hSpriteIndex` is).
fn extra_data(memory: &mut dyn MemoryReader, slot: u8) -> (u8, u8) {
let entry = ram::wMapSpriteExtraData + (u16::from(slot) - 1) * 2;
(memory.read8(entry), memory.read8(entry + 1))
}
/// Whether sprite `slot` is an item ball: `LoadMapHeader` writes `(item id, 0)` into its extra
/// data for an `ITEM`-flagged `object_event`, `(trainer class, trainer number)` for a `TRAINER`
/// one -- trainer numbers start at 1 -- and two zeroes for everything else.
fn is_item_ball(memory: &mut dyn MemoryReader, slot: u8) -> bool {
let (item, second) = extra_data(memory, slot);
item != 0 && second == 0
}
/// `DisplayTextID`'s argument, if it names something the player is facing.
///
/// The caller also asks for the bottom dialogue box ([`state::text_box`]'s `waiting`):
/// `DisplayTextIDInit` draws it for every text id but the start menu's, which it draws at the top
/// right instead.
fn thing_named(memory: &mut dyn MemoryReader, argument: u8) -> Option<Thing> {
if argument == 0 {
// TEXT_START_MENU.
return None;
}
let player: Player = state::player(memory)?;
let one = ahead(player.x, player.y, player.facing)?;
let sprites = memory.read8(ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
if argument <= sprites {
let npc = state::npcs(memory)
.into_iter()
.find(|npc| npc.slot == argument)?;
let at = (npc.x, npc.y);
let reached = at == one
|| (ahead(one.0, one.1, player.facing) == Some(at)
&& state::counter_tiles(memory)
.iter()
.any(|tile| *tile != poke::NO_COUNTER_TILE));
if reached && !is_item_ball(memory, argument) {
return Some(Thing::Sprite(argument));
}
return None;
}
state::signs(memory)
.into_iter()
.any(|sign| sign.text_id == argument && (sign.x, sign.y) == one)
.then_some(Thing::Sign(argument))
}
/// `wToggleableObjectFlags` is `flag_array $100`.
const TOGGLE_BYTES: usize = 32;
/// `wObtainedHiddenItemsFlags` is `flag_array MAX_HIDDEN_ITEMS`, and `MAX_HIDDEN_ITEMS` is 112
/// (`constants/item_constants.asm`).
const HIDDEN_ITEM_BYTES: usize = 14;
/// `wToggleableObjectList` is `ds 16 * 2 + 1`: sixteen `(sprite slot, global index)` pairs and a
/// `$ff` terminator.
const TOGGLE_LIST_ENTRIES: u16 = 16;
/// The two item balls a script *reveals*: `TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4` (`$87`, the Silph
/// Scope) and `TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5` (`$88`, the Lift Key), the only `ITEM`
/// `object_event`s `data/maps/toggleable_objects.asm` starts `OFF`, and the only item entries
/// `constants/toggle_constants.asm` does not mark "X, never toggled by a script".
///
/// Every other item ball's bit is clear from a new game until `PickUpItem` sets it, so a set bit
/// is a pickup. These two are set from the start and cleared when Giovanni's defeat shows them,
/// so seeding them as "already taken" would withhold two payouts for ever. They are the only
/// bits the seed leaves out.
pub const SCRIPT_SHOWN_ITEM_BALLS: [u8; 2] = [0x87, 0x88];
/// The cartridge's two "this item has been taken" bitsets, as of one sample.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemFlags {
toggles: [u8; TOGGLE_BYTES],
hidden: [u8; HIDDEN_ITEM_BYTES],
}
fn bit(bytes: &[u8], index: usize) -> bool {
bytes
.get(index / 8)
.is_some_and(|byte| byte & (1 << (index % 8)) != 0)
}
impl ItemFlags {
pub fn read(memory: &mut dyn MemoryReader) -> Self {
let mut toggles = [0; TOGGLE_BYTES];
for (offset, byte) in toggles.iter_mut().enumerate() {
*byte = memory.read8(ram::wToggleableObjectFlags + offset as u16);
}
let mut hidden = [0; HIDDEN_ITEM_BYTES];
for (offset, byte) in hidden.iter_mut().enumerate() {
*byte = memory.read8(ram::wObtainedHiddenItemsFlags + offset as u16);
}
Self { toggles, hidden }
}
/// Ledger keys for every item this state already shows as taken: the seed that stops a
/// pickup made before the rule existed from paying after a rollback un-takes it.
///
/// Every set toggle bit but [`SCRIPT_SHOWN_ITEM_BALLS`] -- which includes the bits of people
/// a script has hidden, harmlessly, because only an item ball's key is ever looked up -- and
/// every set hidden-item bit.
pub fn seed(&self) -> Vec<String> {
let mut keys = Vec::new();
for index in 0..TOGGLE_BYTES * 8 {
if bit(&self.toggles, index) && !SCRIPT_SHOWN_ITEM_BALLS.contains(&(index as u8)) {
keys.push(ball_key(index as u8));
}
}
for index in 0..HIDDEN_ITEM_BYTES * 8 {
if bit(&self.hidden, index) {
keys.push(hidden_key(index as u8));
}
}
keys
}
}
/// One item the fly has just picked up.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pickup {
pub key: String,
pub label: String,
}
pub fn ball_key(global: u8) -> String {
format!("item:{global}")
}
pub fn hidden_key(index: u8) -> String {
format!("hidden:{index}")
}
/// Items whose "taken" bit rose between `before` and `now`.
///
/// - **An item ball** is one of this map's toggleable sprites (`wToggleableObjectList`) whose
/// extra data says item ([`is_item_ball`]). `PickUpItem` sets its global bit in
/// `wToggleableObjectFlags` through `HideObject`, and only after `GiveItem` succeeded, so a full
/// bag pays nothing. A toggleable that is not an item -- a person a script hides, a legendary
/// after its battle -- is never looked at.
/// - **A hidden item** is a bit of `wObtainedHiddenItemsFlags`, which `FoundHiddenItemText` sets
/// after `GiveItem` succeeded and nothing else in the game writes. Hidden coins have a bitset of
/// their own and are not items.
///
/// A bit that was already set on the previous sample is not a pickup, which is what keeps a
/// restored or seeded state from paying for anything it already holds.
pub fn pickups(memory: &mut dyn MemoryReader, before: &ItemFlags, now: &ItemFlags) -> Vec<Pickup> {
let mut out = Vec::new();
if now == before {
// The overwhelmingly common frame: nothing was taken, and nothing more need be read.
return out;
}
let sprites = memory.read8(ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
for entry in 0..TOGGLE_LIST_ENTRIES {
let slot = memory.read8(ram::wToggleableObjectList + entry * 2);
if slot == 0xff {
break;
}
let global = memory.read8(ram::wToggleableObjectList + entry * 2 + 1);
let index = usize::from(global);
if slot == 0 || slot > sprites || !bit(&now.toggles, index) || bit(&before.toggles, index) {
continue;
}
let (item, second) = extra_data(memory, slot);
if item != 0 && second == 0 {
out.push(Pickup {
key: ball_key(global),
label: format!("FOUND ITEM #{item}"),
});
}
}
for index in 0..HIDDEN_ITEM_BYTES * 8 {
if bit(&now.hidden, index) && !bit(&before.hidden, index) {
out.push(Pickup {
key: hidden_key(index as u8),
label: "FOUND A HIDDEN ITEM".to_string(),
});
}
}
out
}

View file

@ -416,6 +416,38 @@ impl Wram {
pub const BLOCKSET_BANK: u8 = 0x11; pub const BLOCKSET_BANK: u8 = 0x11;
pub const BLOCKSET_BASE: u16 = 0x4000; pub const BLOCKSET_BASE: u16 = 0x4000;
/// Rows of the cartridge's move table, where `data/moves/moves.asm` puts it: `$0E:$4000`,
/// six bytes a row in move-id order, each opening with its own id. `(id, effect, power,
/// type)`; accuracy 100 and PP 35 stand in for the two bytes nothing here reads.
pub fn move_table(&mut self, rows: &[(u8, u8, u8, u8)]) -> &mut Self {
use super::state::poke::moves::{ROW_BYTES, TABLE_ADDRESS, TABLE_BANK};
for (id, effect, power, kind) in rows {
let base = TABLE_ADDRESS + u16::from(id - 1) * ROW_BYTES;
for (offset, byte) in [*id, *effect, *power, *kind, 0xff, 35].into_iter().enumerate() {
self.rom.insert((TABLE_BANK, base + offset as u16), byte);
}
}
self
}
/// One byte of a fake cartridge bank.
pub fn rom_byte(&mut self, bank: u8, address: u16, byte: u8) -> &mut Self {
self.rom.insert((bank, address), byte);
self
}
/// Every stage of both battlers at normal (7), as `InitBattleVariables`-era code leaves them.
pub fn normal_stages(&mut self) -> &mut Self {
for stat in 0..6 {
self.set(ram::wPlayerMonStatMods + stat, 7).set(ram::wEnemyMonStatMods + stat, 7);
}
for stat in 0..4 {
self.set_word_be(ram::wBattleMonAttack + 2 * stat, 12)
.set_word_be(ram::wEnemyMonAttack + 2 * stat, 9);
}
self
}
/// Which tileset the loaded map uses, for the tile-pair collision lists. /// Which tileset the loaded map uses, for the tile-pair collision lists.
pub fn tileset(&mut self, id: u8) -> &mut Self { pub fn tileset(&mut self, id: u8) -> &mut Self {
self.set(ram::wCurMapTileset, id) self.set(ram::wCurMapTileset, id)

View file

@ -377,6 +377,17 @@ pub trait MacroState: GameState {
false false
} }
/// Whether the battle engine will answer the fly's move `id` with nothing on this frame: a
/// stat stage already at its limit, a status move against a target it cannot affect.
///
/// Row 60 (`docs/design/macros.md` 12.23), read from the cartridge's own move table and the
/// bytes its effect routines test ([`crate::pokemon_red::state::move_without_effect`]). The
/// default is `false`: a state that cannot read the table has proved nothing, so the `MOVE n`
/// buttons stay where they were.
fn move_without_effect(&mut self, _id: u8) -> bool {
false
}
/// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable. /// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable.
/// ///
/// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state /// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state

View file

@ -1893,7 +1893,8 @@ pub const fn move_index(kind: MacroKind) -> Option<u8> {
} }
} }
/// Whether `kind`'s move slot holds a move with PP: the four buttons' precondition (section 14). /// Whether `kind`'s move slot holds a move with PP that the battle engine will not answer with
/// nothing: the four buttons' precondition (section 14, row 60).
/// ///
/// Three things it is *not*, each of them a bug this palette has had: /// Three things it is *not*, each of them a bug this palette has had:
/// ///
@ -1921,23 +1922,41 @@ pub fn move_slot_bound(state: &mut dyn MacroState, kind: MacroKind) -> bool {
// all, so the button is bound there whatever the seam can make of the battler. That is the // all, so the button is bound there whatever the seam can make of the battler. That is the
// backstop `NEXT` used to be on this row (section 12.10): the own turn's main menu always has // backstop `NEXT` used to be on this row (section 12.10): the own turn's main menu always has
// a button that ends the turn, and it is never one that merely reopens a list. // a button that ends the turn, and it is never one that merely reopens a list.
if index == 0 && matches!(battle.menu, BattleMenu::Main { .. }) { let main = matches!(battle.menu, BattleMenu::Main { .. });
return true;
}
// And the same backstop over an **open move list** whose battler the seam cannot read // And the same backstop over an **open move list** whose battler the seam cannot read
// (section 12.11). That frame used to deal `BACK` alone -- the only button on it closed the // (section 12.11). That frame used to deal `BACK` alone -- the only button on it closed the
// list `MOVE 1` on the menu underneath had just opened, which is 12.10's pair with `MOVE 1` in // list `MOVE 1` on the menu underneath had just opened, which is 12.10's pair with `MOVE 1` in
// `NEXT`'s place. `MOVE 1`'s script over an open list confirms wherever the cursor stands, so // `NEXT`'s place. `MOVE 1`'s script over an open list confirms wherever the cursor stands, so
// it reads no move either, and confirming a move is what ends a turn. // it reads no move either, and confirming a move is what ends a turn.
let Some(own) = battle.own else { let Some(own) = battle.own else {
return index == 0 && matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. }); return index == 0
&& (main || matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. }));
}; };
let holds = |slot: usize| -> Option<&Move> { let holds = |slot: usize| -> Option<&Move> {
own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0) own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0)
}; };
let Some(entry) = holds(usize::from(index)) else { return false }; // **A move the cartridge will answer with nothing is not dealt beside one it will not**
// (row 60, section 12.23). Live on Route 1: Squirtle's TAIL WHIP against a Pidgey whose
// DEFENSE was already at -6 was `MOVE 2` 183 times, "Nothing happened!" every time, and no
// battle ended by the fly's hand. What the move does is the move table's and the effect
// routine's answer ([`MacroState::move_without_effect`]), read the same way for every move,
// and it is PP's rule over again: a spent move is not offered beside a usable one, and when
// nothing is usable what was dealt stays dealt -- taking the last moves away would leave a list
// whose only button is `BACK`, which is 12.11's pair.
let mut useful = [false; 4];
for (slot, flag) in useful.iter_mut().enumerate() {
if let Some(entry) = holds(slot).copied() {
*flag = entry.pp > 0 && !state.move_without_effect(entry.id);
}
}
let any_useful = useful.iter().any(|flag| *flag);
let entry = holds(usize::from(index)).copied();
if index == 0 && main {
return !any_useful || useful[0] || entry.is_none_or(|entry| entry.pp == 0);
}
let Some(entry) = entry else { return false };
if entry.pp > 0 { if entry.pp > 0 {
return true; return useful[usize::from(index)] || !any_useful;
} }
// Out of PP. Only `MOVE 1` stays, and only when nothing else has any either -- otherwise the // Out of PP. Only `MOVE 1` stays, and only when nothing else has any either -- otherwise the
// fly would be offered a spent move beside a usable one. // fly would be offered a spent move beside a usable one.

View file

@ -111,6 +111,8 @@ struct World {
money: u32, money: u32,
bag: Vec<(u8, u8)>, bag: Vec<(u8, u8)>,
stock: Vec<u8>, stock: Vec<u8>,
/// Move ids the battle engine would answer with "Nothing happened!" on this frame (row 60).
no_effect: BTreeSet<u8>,
/// Tiles the game lets the player talk *over*: a mart's or a centre's counter. /// Tiles the game lets the player talk *over*: a mart's or a centre's counter.
counters: BTreeSet<Tile>, counters: BTreeSet<Tile>,
/// Errands this run has discharged (`docs/design/macros.md` section 13). /// Errands this run has discharged (`docs/design/macros.md` section 13).
@ -242,6 +244,7 @@ impl World {
pushes: BTreeSet::new(), pushes: BTreeSet::new(),
exhausted: BTreeSet::new(), exhausted: BTreeSet::new(),
stock: Vec::new(), stock: Vec::new(),
no_effect: BTreeSet::new(),
visited: BTreeSet::new(), visited: BTreeSet::new(),
stood: BTreeSet::new(), stood: BTreeSet::new(),
seen_maps: BTreeSet::new(), seen_maps: BTreeSet::new(),
@ -696,6 +699,10 @@ impl MacroState for World {
self.prompt && self.scene == Scene::Dialog self.prompt && self.scene == Scene::Dialog
} }
fn move_without_effect(&mut self, id: u8) -> bool {
self.no_effect.contains(&id)
}
fn shop_stock(&mut self) -> Vec<u8> { fn shop_stock(&mut self) -> Vec<u8> {
self.stock.clone() self.stock.clone()
} }
@ -3939,6 +3946,95 @@ fn a_turn_with_nothing_to_attack_switch_or_flee_with_still_has_a_button() {
assert!(pad_of(&mut world).contains(&"MOVE 1")); assert!(pad_of(&mut world).contains(&"MOVE 1"));
} }
/// `constants/move_constants.asm`: the two moves a level-5 Squirtle knows.
const TACKLE: u8 = 0x21;
const TAIL_WHIP: u8 = 0x27;
#[test]
fn a_move_the_cartridge_answers_with_nothing_is_off_the_pad_beside_one_it_does_not() {
// Row 60, live on Route 1: Squirtle L5 with TACKLE and TAIL WHIP, a Pidgey whose DEFENSE is
// already at -6, and `MOVE 2` chosen 183 times to "Nothing happened!". Over the menu and over
// the open list, TAIL WHIP leaves the pad and TACKLE stays.
let mut world = World::battle();
world.mons = vec![mon(0, 8, 20, &[(TACKLE, 35), (TAIL_WHIP, 30)])];
world.active = Some(0);
world.list = List::BattleMain;
assert!(on_the_pad(&mut world, MacroKind::Move2), "before the stage is at its limit");
world.no_effect.insert(TAIL_WHIP);
for list in [List::BattleMain, List::Moves(2)] {
world.list = list;
world.grid = list == List::BattleMain;
let pad = pad_of(&mut world);
assert!(!pad.contains(&"MOVE 2"), "{list:?} deals {pad:?}");
assert!(pad.contains(&"MOVE 1"), "{list:?} deals {pad:?}");
}
// Nothing presses for the fly: the button is gone, and nothing is chosen in its place.
world.list = List::BattleMain;
world.grid = true;
assert!(!move_slot_bound(&mut world, MacroKind::Move2));
// Slot one is read the same way: FIGHT's backstop over the menu is not a way to deal a move
// that does nothing beside one that does.
let mut swapped = World::battle();
swapped.mons = vec![mon(0, 8, 20, &[(TAIL_WHIP, 30), (TACKLE, 35)])];
swapped.active = Some(0);
swapped.list = List::BattleMain;
swapped.no_effect.insert(TAIL_WHIP);
assert_eq!(
pad_of(&mut swapped).iter().filter(|name| name.starts_with("MOVE")).collect::<Vec<_>>(),
[&"MOVE 2"]
);
}
#[test]
fn with_no_move_that_does_anything_the_moves_stay_as_pp_deals_them() {
// PP's own rule (section 12.8, row 30a): with nothing usable, what ends the turn stays on the
// pad. Taking every move away over an open list would leave `BACK` alone, which closes what
// `MOVE 1` on the menu underneath opened -- 12.11's pair.
let mut world = World::battle();
world.mons = vec![mon(0, 8, 20, &[(TACKLE, 0), (TAIL_WHIP, 30)])];
world.active = Some(0);
world.no_effect.insert(TAIL_WHIP);
world.list = List::Moves(2);
world.grid = false;
world.cursor_max = 1;
let pad = pad_of(&mut world);
assert!(pad.contains(&"MOVE 2"), "the one move with PP still ends the turn: {pad:?}");
assert_ne!(pad, ["BACK"]);
world.list = List::BattleMain;
world.grid = true;
let pad = pad_of(&mut world);
assert!(pad.contains(&"MOVE 1") && pad.contains(&"MOVE 2"), "{pad:?}");
// And both moves without effect: nothing changes from what PP alone deals.
let mut both = World::battle();
both.mons = vec![mon(0, 8, 20, &[(TACKLE, 35), (TAIL_WHIP, 30)])];
both.active = Some(0);
both.list = List::BattleMain;
let before = pad_of(&mut both);
both.no_effect.extend([TACKLE, TAIL_WHIP]);
assert_eq!(pad_of(&mut both), before);
}
#[test]
fn a_spent_move_and_a_move_without_effect_leave_the_one_that_works() {
// The two readings together: slot one spent, slot two refused, slot three usable. Only
// `MOVE 3` is a move; `MOVE 1` over the menu is FIGHT's backstop only while slot one is the
// thing that can end the turn, and it is spent -- row 34's behaviour, unchanged.
let mut world = World::battle();
world.mons = vec![mon(0, 8, 20, &[(TACKLE, 0), (TAIL_WHIP, 30), (0x2d, 40)])];
world.active = Some(0);
world.no_effect.insert(TAIL_WHIP);
world.list = List::Moves(3);
world.grid = false;
world.cursor_max = 2;
let moves: Vec<&str> =
pad_of(&mut world).into_iter().filter(|name| name.starts_with("MOVE")).collect();
assert_eq!(moves, ["MOVE 3"]);
}
/// The bound buttons of the macros-mode pad for the scene the world is in, unbound slots dropped. /// The bound buttons of the macros-mode pad for the scene the world is in, unbound slots dropped.
fn pad_of(world: &mut World) -> Vec<&'static str> { fn pad_of(world: &mut World) -> Vec<&'static str> {
let scene = world.scene(); let scene = world.scene();

View file

@ -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 //! A port of the prototype's `src/reward/pokemon-red.ts`. The gates and budgets
//! are unchanged; `docs/rewards-learning.md` holds the live rule table and the //! are unchanged; `docs/rewards-learning.md` holds the live rule table and the
@ -6,9 +6,12 @@
//! ladder with the 38 rungs of `docs/design/ladder.md`; v5 adds one reward rule, //! ladder with the 38 rungs of `docs/design/ladder.md`; v5 adds one reward rule,
//! `boundary` (`docs/design/room-escape.md` section 2), which pays the first step //! `boundary` (`docs/design/room-escape.md` section 2), which pays the first step
//! next to and the first step onto each of a map's exits; v6 adds `catch`, the //! next to and the first step onto each of a map's exits; v6 adds `catch`, the
//! operator's decision of 2026-09-22, which pays for keeping a wild Pokémon. //! 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 catalog;
pub mod engage;
#[cfg(test)] #[cfg(test)]
pub(crate) mod fake_wram; pub(crate) mod fake_wram;
pub mod macros; pub mod macros;
@ -33,33 +36,39 @@ use symbols::ram;
/// Adapter version, pinned into the checkpoint compatibility string. /// Adapter version, pinned into the checkpoint compatibility string.
/// ///
/// `v6` is the `catch` rule. Bumping it is what makes a `v5` checkpoint a decision /// `v7` is the engagement rules: `talk` and `item` pay, and `boundary` stops paying on an
/// rather than an accident: the compatibility string is compared whole before a /// indoor map (the operator, 2026-09-23). Bumping it is what makes a `v6` checkpoint a
/// restore is attempted, so a `v5` run is refused by default and resumed only when /// decision rather than an accident: the compatibility string is compared whole before a
/// the operator names it in `FLY_ACCEPT_ADAPTERS` /// restore is attempted, so a `v6` run is refused by default and resumed only when the
/// ([`crate::compatibility::RestoreDecision`], `docs/design/flysim.md`). That /// operator names it in `FLY_ACCEPT_ADAPTERS` ([`crate::compatibility::RestoreDecision`],
/// migration is safe in one direction only, and only for this pair: `v5`'s ledger is /// `docs/design/flysim.md`). That migration is safe in one direction only, and only for this
/// a `v6` ledger with the catch counter absent, and an absent counter reads as zero. /// 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 /// recorded a `boundary:` key could not be resumed as though its exits were already
/// collected. `v4` was the 38-rung ladder, and rejected `v3` because a stored rank /// collected. `v4` was the 38-rung ladder, and rejected `v3` because a stored rank
/// that meant "4 badges" on the old ladder is not a rung on the new one. Neither of /// that meant "4 badges" on the old ladder is not a rung on the new one. Neither of
/// those is a migration: this one is, because nothing a `v5` ledger holds means /// those is a migration; the last two are, because nothing an older ledger holds means
/// something different under `v6`.) /// something different under the newer rules.)
pub const REWARD_ADAPTER: &str = "pokered-unique8-v6"; 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: /// Exactly one, and it is one because the engagement rules add ledger keys and change nothing
/// a `v5` ledger restores as a `v6` ledger with `catchCounts` empty, and every other byte of the /// else a `v6` state holds: every field keeps its name, shape and meaning, the `talk:` keys start
/// state means what it meant. `v4` is not here -- its `seen` ledger holds no `boundary:` keys, so /// empty (no conversation was ever paid), and the `item:`/`hidden:` keys are seeded from the
/// resuming it would pay a second time for every exit the run had already found -- and neither is /// game's own flags on the first sample, so no pickup made under `v6` pays when a rollback
/// `v3`, whose stored rank is a rung on a different ladder. /// 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 /// Listing an id here is necessary but not sufficient: `FLY_ACCEPT_ADAPTERS` must name it too
/// (`crate::compatibility::decide`, `docs/design/flysim.md`). /// (`crate::compatibility::decide`, `docs/design/flysim.md`).
pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v5"]; pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v6"];
/// The only cartridge semantic rewards are enabled for. Even the canonical /// The only cartridge semantic rewards are enabled for. Even the canonical
/// pret build stays disabled until reviewed; see `docs/rewards-learning.md`. /// pret build stays disabled until reviewed; see `docs/rewards-learning.md`.
@ -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 /// empty, which is the truth about a run that was never paid for a catch. That is the
/// whole of the documented `v5` -> `v6` migration; see /// whole of the documented `v5` -> `v6` migration; see
/// [`crate::compatibility::RestoreDecision`]. /// [`crate::compatibility::RestoreDecision`].
///
/// *Not* bumped for the engagement rules either. `talk` and `item` key their payouts into the
/// existing `seen` array, the way `boundary` did, and the item seed is marked there too
/// ([`ITEMS_SEEDED`]); a `v6` state is structurally a `v7` state with none of those keys.
pub const STATE_VERSION: u64 = 4; pub const STATE_VERSION: u64 = 4;
/// The `seen` key that says the item keys have been seeded from the cartridge's flags.
///
/// Absent from every `v6` state and from a fresh adapter; the first playable sample that finds
/// it absent writes one `item:`/`hidden:` key per item the game already shows as taken, pays
/// nothing for any of them, and writes this.
const ITEMS_SEEDED: &str = "items:seeded";
/// Catch payouts one species may earn in the lifetime of a run's ledger. /// Catch payouts one species may earn in the lifetime of a run's ledger.
/// ///
/// The same cap and the same reason as the wild-KO rule's three: a species the fly can /// The same cap and the same reason as the wild-KO rule's three: a species the fly can
@ -441,6 +461,13 @@ pub struct PokemonRedReward {
battle: Option<Battle>, battle: Option<Battle>,
mode: String, mode: String,
/// The `talk` rule's frame-to-frame watch. Transient: never checkpointed, cleared by a
/// rollback and by a restore.
talk: engage::TalkWatch,
/// The item bitsets as of the last playable sample, so a pickup is a bit that *rose*.
/// Transient for the same reason.
item_flags: Option<engage::ItemFlags>,
/// Transient, recomputed every sample and never checkpointed. /// Transient, recomputed every sample and never checkpointed.
safe: bool, safe: bool,
progress: u32, progress: u32,
@ -491,6 +518,8 @@ impl PokemonRedReward {
stable: 0, stable: 0,
battle: None, battle: None,
mode: "BOOT".to_string(), mode: "BOOT".to_string(),
talk: engage::TalkWatch::default(),
item_flags: None,
safe: false, safe: false,
progress: 0, progress: 0,
badges: 0, badges: 0,
@ -635,6 +664,10 @@ impl PokemonRedReward {
self.stable = 0; self.stable = 0;
self.battle = None; self.battle = None;
self.safe = false; self.safe = false;
// A conversation or a pickup in flight across a rollback is not paid: the game the
// fly returns to has not had it. What *was* paid stays in `seen` and blocks a replay.
self.talk.clear();
self.item_flags = None;
let keys: Vec<String> = self.wild_wins.keys().cloned().collect(); let keys: Vec<String> = self.wild_wins.keys().cloned().collect();
for key in keys { for key in keys {
self.replay_blocked.insert(&key); self.replay_blocked.insert(&key);
@ -750,11 +783,13 @@ impl PokemonRedReward {
self.boundary(&mut emitted, memory, map, x, y, width, height, true, brain_ms); self.boundary(&mut emitted, memory, map, x, y, width, height, true, brain_ms);
self.initialized = true; self.initialized = true;
} }
self.items(&mut emitted, memory, brain_ms);
let in_battle = memory.read8(ram::wIsInBattle); let in_battle = memory.read8(ram::wIsInBattle);
if in_battle == 1 || in_battle == 2 || in_battle == 255 { if in_battle == 1 || in_battle == 2 || in_battle == 255 {
self.mode = "BATTLE".to_string(); self.mode = "BATTLE".to_string();
self.stable = 0; self.stable = 0;
self.talk.interrupt();
let species_paid = self.counts.get(kind::SPECIES); let species_paid = self.counts.get(kind::SPECIES);
if self.battle.is_none() && in_battle != 255 { if self.battle.is_none() && in_battle != 255 {
self.battle = Some(Battle { self.battle = Some(Battle {
@ -849,6 +884,18 @@ impl PokemonRedReward {
self.catch_counts.insert(key, (paid + 1).min(MAX_CATCH_PAYOUTS)); self.catch_counts.insert(key, (paid + 1).min(MAX_CATCH_PAYOUTS));
} }
} }
// The talk rule (`docs/rewards-learning.md`, the operator 2026-09-23): a conversation
// the fly opened indoors, paid once per (map, object) when its box closes.
if let Some(conversation) = self.talk.observe(memory, map, x, y) {
self.once(
&mut emitted,
&conversation.key(),
kind::TALK,
conversation.label(),
false,
brain_ms,
);
}
let location = format!("{map}:{x}:{y}"); let location = format!("{map}:{x}:{y}");
self.stable = if self.location == location { self.stable + 1 } else { 1 }; self.stable = if self.location == location { self.stable + 1 } else { 1 };
self.location = location.clone(); self.location = location.clone();
@ -1056,6 +1103,11 @@ impl PokemonRedReward {
/// What this is not: a path. No button is chosen here, nothing is planned, and no map /// What this is not: a path. No button is chosen here, nothing is planned, and no map
/// knowledge reaches the readout. It is a reward the fly may or may not find, like every other /// knowledge reaches the readout. It is a reward the fly may or may not find, like every other
/// rule in the catalog. /// rule in the catalog.
///
/// **Indoors it pays nothing** (the operator, 2026-09-23): on a map [`engage::indoor`] calls a
/// building, every key is still written to the ledger -- so [`GameAdapter::exit_visited`]
/// answers exactly what it did, and a door found indoors is found -- but no payout is emitted.
/// The exits of a town, a route, a forest or a cave pay as they always have.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn boundary( fn boundary(
&mut self, &mut self,
@ -1072,6 +1124,7 @@ impl PokemonRedReward {
// Collected first, paid second: the ledger writes need `&mut self` and the table walk // Collected first, paid second: the ledger writes need `&mut self` and the table walk
// needs the sample cache, and the order of the collection is the order of the payouts. // needs the sample cache, and the order of the collection is the order of the payouts.
let mut hits: Vec<(String, bool)> = Vec::new(); let mut hits: Vec<(String, bool)> = Vec::new();
let pays = !engage::indoor(memory.read8(ram::wCurMapTileset));
let warps = memory.read8(ram::wNumberOfWarps).min(MAX_WARP_EVENTS); let warps = memory.read8(ram::wNumberOfWarps).min(MAX_WARP_EVENTS);
for index in 0..u16::from(warps) { for index in 0..u16::from(warps) {
@ -1112,6 +1165,10 @@ impl PokemonRedReward {
continue; continue;
} }
let key = format!("{key}:{}", if on_exit { "on" } else { "near" }); let key = format!("{key}:{}", if on_exit { "on" } else { "near" });
if !pays {
self.seen.insert(&key);
continue;
}
self.once_scaled( self.once_scaled(
emitted, emitted,
&key, &key,
@ -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 { pub fn export_state(&self) -> Value {
json!({ json!({
"version": STATE_VERSION, "version": STATE_VERSION,
@ -1287,6 +1372,8 @@ impl PokemonRedReward {
self.mode = mode.to_string(); self.mode = mode.to_string();
self.progress = progress; self.progress = progress;
self.badges = badges; self.badges = badges;
self.talk.clear();
self.item_flags = None;
Ok(()) Ok(())
} }
} }

View file

@ -232,6 +232,54 @@ pub mod poke {
/// this fixed point. /// this fixed point.
pub const PLAYER_SCREEN_X: i32 = 8; pub const PLAYER_SCREEN_X: i32 = 8;
pub const PLAYER_SCREEN_Y: i32 = 9; pub const PLAYER_SCREEN_Y: i32 = 9;
/// The cartridge's move table and the battle engine's answers to it (row 60,
/// `docs/design/macros.md` 12.23).
pub mod moves {
/// `data/moves/moves.asm`: `Moves` opens `SECTION "Battle Engine 7"`, which
/// `layout.link` places first in ROM bank `$0E`, so the table starts at `$0E:$4000`.
/// Six bytes a row (`MOVE_LENGTH`): animation (the move id itself), effect, power,
/// type, accuracy, PP, rows in move-id order from `POUND` (1).
pub const TABLE_BANK: u8 = 0x0e;
pub const TABLE_ADDRESS: u16 = 0x4000;
pub const ROW_BYTES: u16 = 6;
/// `constants/move_constants.asm`: `NUM_ATTACKS`, `STRUGGLE` (`$a5`) the last.
pub const LAST_MOVE: u8 = 0xa5;
/// `constants/move_effect_constants.asm`: the stat-stage effects, each run in stage
/// order ATTACK, DEFENSE, SPEED, SPECIAL, ACCURACY, EVASION.
pub const ATTACK_UP1: u8 = 0x0a;
pub const EVASION_UP1: u8 = 0x0f;
pub const ATTACK_DOWN1: u8 = 0x12;
pub const EVASION_DOWN1: u8 = 0x17;
pub const SLEEP: u8 = 0x20;
pub const ATTACK_UP2: u8 = 0x32;
pub const EVASION_UP2: u8 = 0x37;
pub const ATTACK_DOWN2: u8 = 0x3a;
pub const EVASION_DOWN2: u8 = 0x3f;
pub const POISON: u8 = 0x42;
pub const PARALYZE: u8 = 0x43;
/// `constants/battle_constants.asm`: a stage byte is 1 (-6) to `MAX_STAT_LEVEL` 13 (+6),
/// 7 normal; `MAX_STAT_VALUE` 999. The first four stages have a stat behind them
/// (`wBattleMonAttack` onwards, big-endian words); accuracy and evasion do not.
pub const MIN_STAGE: u8 = 1;
pub const MAX_STAGE: u8 = 13;
pub const STATS_WITH_VALUES: u8 = 4;
pub const MIN_STAT: u16 = 1;
pub const MAX_STAT: u16 = 999;
/// `wEnemyBattleStatus2` bits: `PROTECTED_BY_MIST` 1, `HAS_SUBSTITUTE_UP` 4,
/// `NEEDS_TO_RECHARGE` 5.
pub const MIST: u8 = 1 << 1;
pub const SUBSTITUTE: u8 = 1 << 4;
pub const RECHARGE: u8 = 1 << 5;
/// `constants/type_constants.asm`.
pub const TYPE_POISON: u8 = 0x03;
pub const TYPE_GROUND: u8 = 0x04;
pub const TYPE_ELECTRIC: u8 = 0x17;
}
} }
fn read(memory: &mut dyn MemoryReader, address: u16) -> u8 { fn read(memory: &mut dyn MemoryReader, address: u16) -> u8 {
@ -625,6 +673,114 @@ fn enemy_mon(memory: &mut dyn MemoryReader) -> Option<EnemyMon> {
}) })
} }
/// One row of the cartridge's move table (`data/moves/moves.asm`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MoveData {
pub id: u8,
pub effect: u8,
pub power: u8,
pub kind: u8,
pub accuracy: u8,
pub pp: u8,
}
/// Move `id`'s row of the move table, read from the cartridge image.
///
/// `None` when the seam has no cartridge behind it, when `id` is not a move, or when the row does
/// not open with its own id -- every row of `Moves` does (`move`'s first byte is the animation,
/// "interchangeable with move id"), so a table that is not where the disassembly puts it answers
/// nothing rather than a neighbour's effect.
pub fn move_data(memory: &mut dyn MemoryReader, id: u8) -> Option<MoveData> {
use poke::moves::{LAST_MOVE, ROW_BYTES, TABLE_ADDRESS, TABLE_BANK};
if id == 0 || id > LAST_MOVE {
return None;
}
let base = TABLE_ADDRESS + u16::from(id - 1) * ROW_BYTES;
let mut row = [0u8; 6];
for (offset, byte) in row.iter_mut().enumerate() {
*byte = memory.read_rom(TABLE_BANK, base + offset as u16)?;
}
if row[0] != id {
return None;
}
Some(MoveData {
id,
effect: row[1],
power: row[2],
kind: row[3],
accuracy: row[4],
pp: row[5],
})
}
/// Whether the battle engine will answer the fly's move `id` with nothing at all, on this frame.
///
/// Row 60 (`docs/design/macros.md` 12.23): Squirtle's TAIL WHIP against a Pidgey whose DEFENSE
/// was already at -6 printed "Nothing happened!" 183 times on Route 1. These are the refusals the
/// effect routines in `engine/battle/effects.asm` make on bytes that are already in WRAM when the
/// move is chosen, for a move that deals no damage (a move with power is always attempted (type immunity, e.g. TACKLE against GASTLY, is not answered here)):
///
/// - a stat-raising effect (`StatModifierUpEffect`): the user's stage is already +6, or the stat
/// itself is already 999;
/// - a stat-lowering effect (`StatModifierDownEffect`, `MoveHitTest`): the target has a
/// substitute or Mist, its stage is already -6, or the stat itself is already 1;
/// - `SleepEffect`: the target already has a status and is not recharging;
/// - `PoisonEffect`: a substitute, a status, or a Poison type;
/// - `ParalyzeEffect`: a status, or an Electric move against a Ground type.
///
/// `Some(false)` for every other move, which the cartridge may still miss -- a miss is a roll,
/// and this answers only what is already decided. `None` outside a battle this module
/// understands, when the move table cannot be read, or when a stage byte is out of its range:
/// a refusal this module cannot read is not one it reports.
pub fn move_without_effect(memory: &mut dyn MemoryReader, id: u8) -> Option<bool> {
use poke::moves::*;
in_battle(memory)?;
let data = move_data(memory, id)?;
if data.power != 0 {
return Some(false);
}
let stage = |memory: &mut dyn MemoryReader, base: u16, stat: u8| -> Option<u8> {
Some(read(memory, base + u16::from(stat))).filter(|stage| (MIN_STAGE..=MAX_STAGE).contains(stage))
};
let value = |memory: &mut dyn MemoryReader, base: u16, stat: u8| -> Option<u16> {
(stat < STATS_WITH_VALUES).then(|| word_be(memory, base + 2 * u16::from(stat)))
};
let effect = data.effect;
let raised = match effect {
ATTACK_UP1..=EVASION_UP1 => Some(effect - ATTACK_UP1),
ATTACK_UP2..=EVASION_UP2 => Some(effect - ATTACK_UP2),
_ => None,
};
if let Some(stat) = raised {
let at = stage(memory, ram::wPlayerMonStatMods, stat)?;
return Some(at >= MAX_STAGE || value(memory, ram::wBattleMonAttack, stat) == Some(MAX_STAT));
}
let target = read(memory, ram::wEnemyBattleStatus2);
let lowered = match effect {
ATTACK_DOWN1..=EVASION_DOWN1 => Some(effect - ATTACK_DOWN1),
ATTACK_DOWN2..=EVASION_DOWN2 => Some(effect - ATTACK_DOWN2),
_ => None,
};
if let Some(stat) = lowered {
let at = stage(memory, ram::wEnemyMonStatMods, stat)?;
return Some(
target & (SUBSTITUTE | MIST) != 0
|| at <= MIN_STAGE
|| value(memory, ram::wEnemyMonAttack, stat) == Some(MIN_STAT),
);
}
let status = read(memory, ram::wEnemyMonStatus);
let types = [read(memory, ram::wEnemyMonType1), read(memory, ram::wEnemyMonType1 + 1)];
Some(match effect {
SLEEP => status != 0 && target & RECHARGE == 0,
POISON => target & SUBSTITUTE != 0 || status != 0 || types.contains(&TYPE_POISON),
PARALYZE => {
status != 0 || (data.kind == TYPE_ELECTRIC && types.contains(&TYPE_GROUND))
}
_ => false,
})
}
/// Whether a text box is open, and whether the bottom-of-screen dialogue box is the one drawn. /// Whether a text box is open, and whether the bottom-of-screen dialogue box is the one drawn.
/// ///
/// `open` is `wFontLoaded`'s bit 0, which `DisplayTextIDInit` sets for every text display — the /// `open` is `wFontLoaded`'s bit 0, which `DisplayTextIDInit` sets for every text display — the
@ -1782,6 +1938,10 @@ impl MacroState for PokeState<'_> {
yes_no_prompt(self.memory) yes_no_prompt(self.memory)
} }
fn move_without_effect(&mut self, id: u8) -> bool {
move_without_effect(self.memory, id).unwrap_or(false)
}
/// The whole loaded map's walkability, from the cache when it is for this map /// The whole loaded map's walkability, from the cache when it is for this map
/// (`docs/design/macros.md` section 15). /// (`docs/design/macros.md` section 15).
/// ///

View file

@ -1067,3 +1067,158 @@ fn a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map() {
let off: Vec<u8> = offscreen_npcs(&mut wram).iter().map(|npc| npc.slot).collect(); let off: Vec<u8> = offscreen_npcs(&mut wram).iter().map(|npc| npc.slot).collect();
assert_eq!(off, vec![1], "only the leader is still off the screen from (4, 8)"); assert_eq!(off, vec![1], "only the leader is still off the screen from (4, 8)");
} }
/// Row 60's cartridge rows, as `data/moves/moves.asm` has them: `(id, effect, power, type)`.
const TACKLE: (u8, u8, u8, u8) = (0x21, 0x00, 35, 0x00);
const TAIL_WHIP: (u8, u8, u8, u8) = (0x27, 0x13, 0, 0x00);
const GROWL: (u8, u8, u8, u8) = (0x2d, 0x12, 0, 0x00);
const SCREECH: (u8, u8, u8, u8) = (0x67, 0x3b, 0, 0x00);
const WITHDRAW: (u8, u8, u8, u8) = (0x6e, 0x0b, 0, 0x15);
const SAND_ATTACK: (u8, u8, u8, u8) = (0x1c, 0x16, 0, 0x00);
const SLEEP_POWDER: (u8, u8, u8, u8) = (0x4f, 0x20, 0, 0x16);
const POISONPOWDER: (u8, u8, u8, u8) = (0x4d, 0x42, 0, 0x03);
const THUNDER_WAVE: (u8, u8, u8, u8) = (0x56, 0x43, 0, 0x17);
/// `AURORA_BEAM`: a damaging move with a stat side effect, which always does something.
const AURORA_BEAM: (u8, u8, u8, u8) = (0x3e, 0x44, 65, 0x19);
fn stage_battle() -> Wram {
let mut wram = Wram::new();
wram.battle_mon(0, 0xb1, 5, 8, 20, 0, &[(0x21, 35), (0x27, 30)])
.enemy_mon(0x24, 2, 13, 13)
.battle(1)
.normal_stages()
.move_table(&[
TACKLE,
TAIL_WHIP,
GROWL,
SCREECH,
WITHDRAW,
SAND_ATTACK,
SLEEP_POWDER,
POISONPOWDER,
THUNDER_WAVE,
AURORA_BEAM,
]);
wram
}
#[test]
fn the_move_table_is_read_by_id_and_checked_against_its_own_first_byte() {
let mut wram = stage_battle();
let tail_whip = move_data(&mut wram, TAIL_WHIP.0).expect("a row");
assert_eq!((tail_whip.effect, tail_whip.power), (0x13, 0));
assert_eq!(move_data(&mut wram, 0), None, "no move zero");
assert_eq!(move_data(&mut wram, 0xa6), None, "past STRUGGLE");
assert_eq!(move_data(&mut wram, 0x22), None, "a row the cartridge image does not answer");
// A table that is not where the disassembly says: a row that does not open with its own id
// is somebody else's row, and it is not read as this one.
let mut shifted = Wram::new();
shifted.move_table(&[(0x28, 0x13, 0, 0)]);
let base = poke::moves::TABLE_ADDRESS + 0x26 * poke::moves::ROW_BYTES;
for offset in 0..6 {
let byte = shifted.read_rom(poke::moves::TABLE_BANK, base + 6 + offset).unwrap();
shifted.rom_byte(poke::moves::TABLE_BANK, base + offset, byte);
}
assert_eq!(move_data(&mut shifted, 0x27), None);
assert!(move_data(&mut shifted, 0x28).is_some());
}
#[test]
fn tail_whip_does_nothing_at_minus_six_and_something_before() {
// Row 60: the Pidgey's DEFENSE stage walked down from 7 to 1 by TAIL WHIP after TAIL WHIP.
let mut wram = stage_battle();
for stage in 2..=7 {
wram.set(ram::wEnemyMonStatMods + 1, stage);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(false), "stage {stage}");
}
wram.set(ram::wEnemyMonStatMods + 1, 1);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
// The -2 variant reads the same stage.
assert_eq!(move_without_effect(&mut wram, SCREECH.0), Some(true));
// TACKLE deals damage whatever the stages say.
assert_eq!(move_without_effect(&mut wram, TACKLE.0), Some(false));
// And a damaging move with a stat side effect is never "nothing".
wram.set(ram::wEnemyMonStatMods + 3, 1);
assert_eq!(move_without_effect(&mut wram, AURORA_BEAM.0), Some(false));
// GROWL reads ATTACK's stage, not DEFENSE's.
assert_eq!(move_without_effect(&mut wram, GROWL.0), Some(false));
wram.set(ram::wEnemyMonStatMods, 1);
assert_eq!(move_without_effect(&mut wram, GROWL.0), Some(true));
// SAND-ATTACK reads ACCURACY's, which has no stat value behind it.
assert_eq!(move_without_effect(&mut wram, SAND_ATTACK.0), Some(false));
wram.set(ram::wEnemyMonStatMods + 4, 1);
assert_eq!(move_without_effect(&mut wram, SAND_ATTACK.0), Some(true));
}
#[test]
fn a_stat_already_at_one_or_nine_hundred_ninety_nine_refuses_before_the_stage_does() {
// `StatModifierDownEffect` restores the stage and prints "Nothing happened!" when the stat
// itself is already 1 -- a level-2 Pidgey's DEFENSE gets there before -6.
let mut wram = stage_battle();
wram.set(ram::wEnemyMonStatMods + 1, 4).set_word_be(ram::wEnemyMonAttack + 2, 1);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
wram.set_word_be(ram::wEnemyMonAttack + 2, 2);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(false));
// Raising: +6, or a stat of 999.
wram.set(ram::wPlayerMonStatMods + 1, 12);
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(false));
wram.set(ram::wPlayerMonStatMods + 1, 13);
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(true));
wram.set(ram::wPlayerMonStatMods + 1, 9).set_word_be(ram::wBattleMonAttack + 2, 999);
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(true));
}
#[test]
fn mist_and_a_substitute_turn_a_stat_lowering_move_away() {
let mut wram = stage_battle();
wram.set(ram::wEnemyBattleStatus2, poke::moves::MIST);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
wram.set(ram::wEnemyBattleStatus2, poke::moves::SUBSTITUTE);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
assert_eq!(move_without_effect(&mut wram, POISONPOWDER.0), Some(true));
// A raise is the user's own business.
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(false));
}
#[test]
fn a_status_move_against_a_target_it_cannot_affect_does_nothing() {
let mut wram = stage_battle();
for id in [SLEEP_POWDER.0, POISONPOWDER.0, THUNDER_WAVE.0] {
assert_eq!(move_without_effect(&mut wram, id), Some(false), "healthy target, {id:#04x}");
}
// Any status: already asleep, poisoned, paralysed.
wram.set(ram::wEnemyMonStatus, 1 << 6);
for id in [SLEEP_POWDER.0, POISONPOWDER.0, THUNDER_WAVE.0] {
assert_eq!(move_without_effect(&mut wram, id), Some(true), "statused target, {id:#04x}");
}
// A target that must recharge is put to sleep whatever its status (`SleepEffect`).
wram.set(ram::wEnemyBattleStatus2, poke::moves::RECHARGE);
assert_eq!(move_without_effect(&mut wram, SLEEP_POWDER.0), Some(false));
// Types: a Poison type is not poisoned; a Ground type is not paralysed by an Electric move.
let mut typed = stage_battle();
typed.set(ram::wEnemyMonType1 + 1, poke::moves::TYPE_POISON);
assert_eq!(move_without_effect(&mut typed, POISONPOWDER.0), Some(true));
typed.set(ram::wEnemyMonType1 + 1, 0).set(ram::wEnemyMonType1, poke::moves::TYPE_GROUND);
assert_eq!(move_without_effect(&mut typed, THUNDER_WAVE.0), Some(true));
assert_eq!(move_without_effect(&mut typed, POISONPOWDER.0), Some(false));
}
#[test]
fn a_refusal_that_cannot_be_read_is_not_reported() {
// Out of battle, no cartridge behind the seam, or a stage byte out of its 1..=13 range.
let mut wram = stage_battle();
wram.set(ram::wIsInBattle, 0);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), None);
let mut bare = Wram::new();
bare.battle(1).normal_stages().set(ram::wEnemyMonStatMods + 1, 1);
assert_eq!(move_without_effect(&mut bare, TAIL_WHIP.0), None, "no move table");
let mut wram = stage_battle();
wram.set(ram::wEnemyMonStatMods + 1, 0);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), None);
// And the seam's own answer is "leave the button where it was".
let mut unread = Wram::new();
assert!(!PokeState::new(&mut unread).move_without_effect(TAIL_WHIP.0));
let mut read = stage_battle();
read.set(ram::wEnemyMonStatMods + 1, 1);
assert!(PokeState::new(&mut read).move_without_effect(TAIL_WHIP.0));
}

View file

@ -23,29 +23,38 @@ pub mod ram {
pub const wMaxMenuItem: u16 = 0xcc28; // 52264 pub const wMaxMenuItem: u16 = 0xcc28; // 52264
pub const wMenuWatchedKeys: u16 = 0xcc29; // 52265 pub const wMenuWatchedKeys: u16 = 0xcc29; // 52265
pub const wPlayerMonNumber: u16 = 0xcc2f; // 52271 pub const wPlayerMonNumber: u16 = 0xcc2f; // 52271
pub const wPlayerMonStatMods: u16 = 0xcd1a; // 52506
pub const wEnemyMonStatMods: u16 = 0xcd2e; // 52526
pub const wSimulatedJoypadStatesIndex: u16 = 0xcd38; // 52536 pub const wSimulatedJoypadStatesIndex: u16 = 0xcd38; // 52536
pub const wMiscFlags: u16 = 0xcd60; // 52576 pub const wMiscFlags: u16 = 0xcd60; // 52576
pub const wJoyIgnore: u16 = 0xcd6b; // 52587 pub const wJoyIgnore: u16 = 0xcd6b; // 52587
pub const wNumMovesMinusOne: u16 = 0xcd6c; // 52588 pub const wNumMovesMinusOne: u16 = 0xcd6c; // 52588
pub const wBattleResult: u16 = 0xcf0b; // 53003 pub const wBattleResult: u16 = 0xcf0b; // 53003
pub const wSpriteIndex: u16 = 0xcf13; // 53011
pub const wItemList: u16 = 0xcf7b; // 53115 pub const wItemList: u16 = 0xcf7b; // 53115
pub const wListMenuID: u16 = 0xcf94; // 53140 pub const wListMenuID: u16 = 0xcf94; // 53140
pub const wFontLoaded: u16 = 0xcfc4; // 53188 pub const wFontLoaded: u16 = 0xcfc4; // 53188
pub const wWalkCounter: u16 = 0xcfc5; // 53189
pub const wEnemyMonSpecies: u16 = 0xcfe5; // 53221 pub const wEnemyMonSpecies: u16 = 0xcfe5; // 53221
pub const wEnemyMonHP: u16 = 0xcfe6; // 53222 pub const wEnemyMonHP: u16 = 0xcfe6; // 53222
pub const wEnemyMonStatus: u16 = 0xcfe9; // 53225
pub const wEnemyMonType1: u16 = 0xcfea; // 53226
pub const wEnemyMonLevel: u16 = 0xcff3; // 53235 pub const wEnemyMonLevel: u16 = 0xcff3; // 53235
pub const wEnemyMonMaxHP: u16 = 0xcff4; // 53236 pub const wEnemyMonMaxHP: u16 = 0xcff4; // 53236
pub const wEnemyMonAttack: u16 = 0xcff6; // 53238
pub const wBattleMonSpecies: u16 = 0xd014; // 53268 pub const wBattleMonSpecies: u16 = 0xd014; // 53268
pub const wBattleMonHP: u16 = 0xd015; // 53269 pub const wBattleMonHP: u16 = 0xd015; // 53269
pub const wBattleMonStatus: u16 = 0xd018; // 53272 pub const wBattleMonStatus: u16 = 0xd018; // 53272
pub const wBattleMonMoves: u16 = 0xd01c; // 53276 pub const wBattleMonMoves: u16 = 0xd01c; // 53276
pub const wBattleMonLevel: u16 = 0xd022; // 53282 pub const wBattleMonLevel: u16 = 0xd022; // 53282
pub const wBattleMonMaxHP: u16 = 0xd023; // 53283 pub const wBattleMonMaxHP: u16 = 0xd023; // 53283
pub const wBattleMonAttack: u16 = 0xd025; // 53285
pub const wBattleMonPP: u16 = 0xd02d; // 53293 pub const wBattleMonPP: u16 = 0xd02d; // 53293
pub const wTrainerClass: u16 = 0xd031; // 53297 pub const wTrainerClass: u16 = 0xd031; // 53297
pub const wIsInBattle: u16 = 0xd057; // 53335 pub const wIsInBattle: u16 = 0xd057; // 53335
pub const wBattleType: u16 = 0xd05a; // 53338 pub const wBattleType: u16 = 0xd05a; // 53338
pub const wTrainerNo: u16 = 0xd05d; // 53341 pub const wTrainerNo: u16 = 0xd05d; // 53341
pub const wEnemyBattleStatus2: u16 = 0xd068; // 53352
pub const wPartyMenuTypeOrMessageID: u16 = 0xd07d; // 53373 pub const wPartyMenuTypeOrMessageID: u16 = 0xd07d; // 53373
pub const wCapturedMonSpecies: u16 = 0xd11c; // 53532 pub const wCapturedMonSpecies: u16 = 0xd11c; // 53532
pub const wForcePlayerToChooseMon: u16 = 0xd11f; // 53535 pub const wForcePlayerToChooseMon: u16 = 0xd11f; // 53535
@ -71,11 +80,15 @@ pub mod ram {
pub const wSignCoords: u16 = 0xd4b1; // 54449 pub const wSignCoords: u16 = 0xd4b1; // 54449
pub const wSignTextIDs: u16 = 0xd4d1; // 54481 pub const wSignTextIDs: u16 = 0xd4d1; // 54481
pub const wNumSprites: u16 = 0xd4e1; // 54497 pub const wNumSprites: u16 = 0xd4e1; // 54497
pub const wMapSpriteExtraData: u16 = 0xd504; // 54532
pub const wTilesetBank: u16 = 0xd52b; // 54571 pub const wTilesetBank: u16 = 0xd52b; // 54571
pub const wTilesetBlocksPtr: u16 = 0xd52c; // 54572 pub const wTilesetBlocksPtr: u16 = 0xd52c; // 54572
pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576 pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576
pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578 pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578
pub const wNumHoFTeams: u16 = 0xd5a2; // 54690 pub const wNumHoFTeams: u16 = 0xd5a2; // 54690
pub const wToggleableObjectFlags: u16 = 0xd5a6; // 54694
pub const wToggleableObjectList: u16 = 0xd5ce; // 54734
pub const wObtainedHiddenItemsFlags: u16 = 0xd6f0; // 55024
pub const wStatusFlags5: u16 = 0xd730; // 55088 pub const wStatusFlags5: u16 = 0xd730; // 55088
pub const wStatusFlags6: u16 = 0xd732; // 55090 pub const wStatusFlags6: u16 = 0xd732; // 55090
pub const wStatusFlags7: u16 = 0xd733; // 55091 pub const wStatusFlags7: u16 = 0xd733; // 55091

View file

@ -865,8 +865,8 @@ fn the_recent_ticker_keeps_the_newest_eight_events_newest_first() {
#[test] #[test]
fn the_adapter_reports_its_identity_and_pinned_rom() { fn the_adapter_reports_its_identity_and_pinned_rom() {
let reward = PokemonRedReward::new(); let reward = PokemonRedReward::new();
assert_eq!(reward.id(), "pokered-unique8-v6"); assert_eq!(reward.id(), "pokered-unique8-v7");
assert_eq!(reward.migrates_from(), ["pokered-unique8-v5"]); assert_eq!(reward.migrates_from(), ["pokered-unique8-v6"]);
assert!(reward.rom_allowed(SUPPORTED_ROM)); assert!(reward.rom_allowed(SUPPORTED_ROM));
assert!(!reward.rom_allowed( assert!(!reward.rom_allowed(
"5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b" "5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b"
@ -1356,3 +1356,541 @@ fn a_rung_earned_out_of_order_does_not_skip_the_ones_under_it() {
f.visit(3, 3); f.visit(3, 3);
assert_eq!(f.reward.rank(), 9); assert_eq!(f.reward.rank(), 9);
} }
// --- Engagement rewards (the operator, 2026-09-23) -----------------------------------------------
/// `constants/tileset_constants.asm`: a gym, a house, a mart, the forest and a cave.
const GYM: u8 = 7;
const HOUSE: u8 = 8;
const MART: u8 = 2;
const FOREST: u8 = 3;
const CAVERN: u8 = 17;
/// `wSpriteStateData1`'s facing byte: `SPRITE_FACING_DOWN`, `_UP`, `_LEFT`, `_RIGHT`.
const FACING_DOWN: u8 = 0x00;
const FACING_UP: u8 = 0x04;
const FACING_LEFT: u8 = 0x08;
impl Fixture {
/// Stand on `map`, drawn with `tileset`, with no counter tiles in the tileset header.
fn on_map(&mut self, map: u8, tileset: u8) {
self.memory.set(ram::wCurMap, map);
self.memory.set(ram::wCurMapTileset, tileset);
for index in 0..3 {
self.memory.set(ram::wTilesetTalkingOverTiles + index, 0xff);
}
}
/// A visible sprite in `slot` at map tile `(x, y)`, stored plus four as `object_event` emits
/// it, with `extra` in its `wMapSpriteExtraData` entry.
fn sprite(&mut self, slot: u8, x: u8, y: u8, extra: (u8, u8)) {
let count = self.memory.bytes[ram::wNumSprites as usize].max(slot);
self.memory.set(ram::wNumSprites, count);
let data1 = ram::wSpriteStateData1 + u16::from(slot) * 16;
let data2 = ram::wSpriteStateData2 + u16::from(slot) * 16;
self.memory.set(data1, 1);
self.memory.set(data1 + 2, 0);
self.memory.set(data2 + 4, y + 4);
self.memory.set(data2 + 5, x + 4);
let entry = ram::wMapSpriteExtraData + (u16::from(slot) - 1) * 2;
self.memory.set(entry, extra.0);
self.memory.set(entry + 1, extra.1);
}
fn face(&mut self, facing: u8) {
self.memory.set(ram::wSpriteStateData1 + 9, facing);
}
/// `DisplayTextIDInit`'s `TextBoxBorder` at screen (0, 12)-(19, 17), the box every text id
/// but the start menu's is drawn in; or the map's own tiles again once it is gone.
fn dialogue_box(&mut self, drawn: bool) {
use super::state::poke::frame;
let at = |x: u16, y: u16| ram::wTileMap + y * 20 + x;
for y in 12..=17u16 {
for x in 0..20u16 {
let tile = if !drawn {
0
} else {
match (x, y) {
(0, 12) => frame::TOP_LEFT,
(19, 12) => frame::TOP_RIGHT,
(0, 17) => frame::BOTTOM_LEFT,
(19, 17) => frame::BOTTOM_RIGHT,
(_, 12) | (_, 17) => frame::HORIZONTAL,
(0, _) | (19, _) => frame::VERTICAL,
_ => 0x7f,
}
};
self.memory.set(at(x, y), tile);
}
}
}
/// The box opening: the font bit, the border, and -- `delay` samples later, the way the
/// cartridge loads the font's tiles first -- `DisplayTextID`'s argument.
fn open_box(&mut self, argument: u8, delay: usize) -> Vec<RewardEvent> {
self.memory.set(ram::wFontLoaded, 1);
self.dialogue_box(true);
let mut events = Vec::new();
for _ in 0..delay {
events.extend(self.sample());
}
self.memory.set(ram::wSpriteIndex, argument);
events.extend(self.sample());
events
}
fn close_box(&mut self) -> Vec<RewardEvent> {
self.memory.set(ram::wFontLoaded, 0);
self.dialogue_box(false);
self.sample()
}
/// One conversation as the cartridge draws it: a sample with the box closed (the frame the A
/// press was read on), `DisplayTextIDInit` setting the font bit and `DisplayTextID` copying
/// `argument` into `wSpriteIndex`, a few frames of text, and `CloseTextDisplay`.
fn talk(&mut self, argument: u8) -> Vec<RewardEvent> {
let mut events = self.sample();
events.extend(self.open_box(argument, 20));
events.extend(self.sample());
events.extend(self.close_box());
events
}
fn talk_events(&mut self, argument: u8) -> Vec<RewardEvent> {
self.talk(argument).into_iter().filter(|event| event.kind == kind::TALK).collect()
}
/// `wToggleableObjectList` for this map: `(sprite slot, global index)` pairs and `$ff`.
fn toggle_list(&mut self, entries: &[(u8, u8)]) {
for (index, (slot, global)) in entries.iter().enumerate() {
self.memory.set(ram::wToggleableObjectList + index as u16 * 2, *slot);
self.memory.set(ram::wToggleableObjectList + index as u16 * 2 + 1, *global);
}
self.memory.set(ram::wToggleableObjectList + entries.len() as u16 * 2, 0xff);
}
fn set_bit(&mut self, base: u16, index: u16, on: bool) {
let address = base + index / 8;
let mask = 1 << (index % 8);
let byte = self.memory.bytes[address as usize];
self.memory.set(address, if on { byte | mask } else { byte & !mask });
}
fn item_events(&mut self) -> Vec<RewardEvent> {
self.sample().into_iter().filter(|event| event.kind == kind::ITEM).collect()
}
}
#[test]
fn indoors_is_the_cartridges_building_tilesets_and_nothing_else() {
// CheckIfInOutsideMap's outside (OVERWORLD, PLATEAU) and BikeRidingTilesets (OVERWORLD,
// FOREST, UNDERGROUND, SHIP_PORT, CAVERN) are the two tables; indoor is neither.
let outdoor = [0, 3, 11, 14, 17, 23];
for tileset in 0..24u8 {
assert_eq!(engage::indoor(tileset), !outdoor.contains(&tileset), "tileset {tileset}");
}
assert!(!engage::indoor(24), "a tileset past the table is not a building");
assert!(!engage::indoor(0xff));
}
#[test]
fn a_conversation_the_fly_opens_indoors_pays_once_when_its_box_closes() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
// Nothing on the frames the box is open: the payout waits for it to close.
let mut events = f.sample();
events.extend(f.open_box(1, 20));
assert_eq!(count_of_kind(&events, kind::TALK), 0, "not while the box is open");
let events = f.close_box();
assert_eq!(kinds(&events), ["talk"]);
assert_eq!(events[0].value, 0.10);
assert_eq!(events[0].stimulation_ms, 100);
assert_eq!(labels(&events), [format!("TALKED TO #1 IN AREA {}", maps::PEWTER_GYM)]);
// Talking to the same person again, as often as the fly likes, is not a farm.
for _ in 0..5 {
assert!(f.talk_events(1).is_empty(), "one payout per (map, object) for the ledger's life");
}
// A second person on the same map is a second key.
f.sprite(2, 4, 5, (0, 0));
f.face(FACING_LEFT);
assert_eq!(kinds(&f.talk_events(2)), ["talk"]);
// A sign is a text id past the sprite slots, on the tile the player faces.
f.memory.set(ram::wNumSigns, 1);
f.memory.set(ram::wSignCoords, 6);
f.memory.set(ram::wSignCoords + 1, 5);
f.memory.set(ram::wSignTextIDs, 7);
f.face(FACING_DOWN);
let sign = f.talk_events(7);
assert_eq!(labels(&sign), [format!("READ SIGN #7 IN AREA {}", maps::PEWTER_GYM)]);
assert!(f.talk_events(7).is_empty());
assert_eq!(f.reward.statistics().counts[kind::TALK], 3);
}
#[test]
fn the_same_slot_on_another_indoor_map_is_another_conversation() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
f.on_map(maps::OAKS_LAB, HOUSE);
f.visit(5, 5);
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
}
#[test]
fn talking_outdoors_in_a_forest_or_in_a_cave_pays_nothing() {
for (map, tileset) in [
(maps::PEWTER_CITY, 0),
(maps::VIRIDIAN_FOREST, FOREST),
(maps::MT_MOON_1F, CAVERN),
] {
let mut f = Fixture::booted();
f.on_map(map, tileset);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
assert!(f.talk_events(1).is_empty(), "map {map}, tileset {tileset}");
}
}
#[test]
fn text_the_fly_did_not_open_pays_nothing() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
// A script that took the joypad before it drew the box: a trainer walking up, a guard.
f.memory.set(ram::wJoyIgnore, 0xff);
assert!(f.talk_events(1).is_empty(), "the joypad was the cartridge's");
f.memory.set(ram::wJoyIgnore, 0);
// Simulated input, and scripted movement.
f.memory.set(ram::wSimulatedJoypadStatesIndex, 3);
assert!(f.talk_events(1).is_empty(), "the buttons were simulated");
f.memory.set(ram::wSimulatedJoypadStatesIndex, 0);
f.memory.set(ram::wStatusFlags5, 0x80);
assert!(f.talk_events(1).is_empty(), "the movement was scripted");
f.memory.set(ram::wStatusFlags5, 0);
// A trigger tile: the box opens on the frame a step ends, with the walk counter still
// running on the sample before it -- the overworld never reads A mid-step.
f.memory.set(ram::wWalkCounter, 1);
assert!(f.talk_events(1).is_empty(), "a step onto a trigger tile is not a press");
f.memory.set(ram::wWalkCounter, 0);
// Text about someone the fly is not facing: a script naming a sprite across the room.
f.sprite(2, 9, 9, (0, 0));
assert!(f.talk_events(2).is_empty(), "not the thing in front");
// A text id that is no sign in front of the fly.
assert!(f.talk_events(9).is_empty(), "no sign there");
// The start menu is text id 0.
assert!(f.talk_events(0).is_empty(), "the start menu is not a conversation");
// A script that opens the box with the joypad taken and hands it back mid-conversation:
// the frames after it are the fly's, but the box did not open on one of them.
f.memory.set(ram::wJoyIgnore, 0xff);
f.sample();
f.open_box(1, 0);
f.memory.set(ram::wJoyIgnore, 0);
f.sample();
f.sample();
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0, "no open edge after a ready frame");
// START in front of someone: the menu's box is not the dialogue box, and `wSpriteIndex`
// still names the last person spoken to for the frames before the menu's own id arrives.
f.memory.set(ram::wSpriteIndex, 1);
f.sample();
f.memory.set(ram::wFontLoaded, 1);
for _ in 0..20 {
f.sample();
}
f.memory.set(ram::wSpriteIndex, 0);
f.sample();
f.memory.set(ram::wFontLoaded, 0);
assert_eq!(count_of_kind(&f.sample(), kind::TALK), 0, "the start menu is not a conversation");
// The argument never arrives inside the window: nothing is guessed.
f.memory.set(ram::wSpriteIndex, 0);
f.sample();
f.open_box(1, 60);
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0, "a second of frames, then no more");
// None of that was recorded: the person is still worth one conversation.
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
}
#[test]
fn a_conversation_that_ends_somewhere_else_pays_nothing() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
f.sample();
f.open_box(1, 20);
// The script warped the fly out while the box was up.
f.on_map(maps::PEWTER_CITY, 0);
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0);
// A rollback mid-conversation, likewise: the watch is transient.
f.on_map(maps::PEWTER_GYM, GYM);
f.sample();
f.open_box(1, 20);
f.reward.clear_transient();
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0);
}
#[test]
fn an_item_ball_is_an_item_not_a_conversation() {
let mut f = Fixture::booted();
f.on_map(maps::OAKS_LAB, HOUSE);
f.visit(5, 5);
f.sprite(1, 5, 4, (0x14, 0));
f.face(FACING_UP);
assert!(f.talk_events(1).is_empty());
// A trainer is a person: `(class, number)`, and numbers start at one.
f.sprite(2, 4, 5, (0xcb, 2));
f.face(FACING_LEFT);
assert_eq!(kinds(&f.talk_events(2)), ["talk"]);
}
#[test]
fn a_clerk_across_a_counter_is_in_reach_only_where_the_tileset_has_counters() {
let mut f = Fixture::booted();
f.on_map(maps::VIRIDIAN_MART, MART);
f.visit(5, 5);
f.sprite(1, 5, 3, (0, 0));
f.face(FACING_UP);
assert!(f.talk_events(1).is_empty(), "two tiles away with no counter tiles in the header");
f.memory.set(ram::wTilesetTalkingOverTiles, 0x18);
assert_eq!(kinds(&f.talk_events(1)), ["talk"], "IsSpriteOrSignInFrontOfPlayer's long range");
}
#[test]
fn a_rollback_or_a_restore_cannot_replay_a_conversation() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
f.reward.clear_transient();
let state = f.reward.export_state();
assert!(
state["seen"]
.as_array()
.unwrap()
.contains(&json!(format!("talk:{}:sprite:1", maps::PEWTER_GYM))),
"the ledger is the checkpointed `seen` set, not the macros' session ledger"
);
let mut restored = PokemonRedReward::new();
restored.import_state(&state).unwrap();
f.reward = restored;
f.visit(5, 5);
assert!(f.talk_events(1).is_empty());
assert_eq!(f.reward.statistics().counts[kind::TALK], 1);
}
#[test]
fn an_item_ball_pays_once_when_its_bit_rises_and_never_again() {
let mut f = Fixture::booted();
f.on_map(maps::VIRIDIAN_FOREST, FOREST);
f.sprite(3, 5, 4, (0x14, 0)); // a Potion
f.toggle_list(&[(3, 0x2a)]);
f.visit(5, 5);
// PickUpItem: GiveItem, then HideObject sets the ball's global bit.
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
let events = f.item_events();
assert_eq!(labels(&events), ["FOUND ITEM #20"]);
assert_eq!(events[0].value, 0.15);
assert_eq!(events[0].stimulation_ms, 120);
assert!(f.item_events().is_empty(), "a bit that stays set pays once");
// A rollback to a slot where the ball is still there, and the fly takes it again.
f.reward.clear_transient();
f.set_bit(ram::wToggleableObjectFlags, 0x2a, false);
f.sample();
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
assert!(f.item_events().is_empty(), "once per item for the run");
assert_eq!(f.reward.statistics().counts[kind::ITEM], 1);
}
#[test]
fn only_an_item_balls_bit_pays_and_only_after_a_pickup() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_CITY, 0);
f.sprite(1, 2, 2, (0, 0)); // a person a script hides
f.sprite(2, 3, 3, (0xcb, 1)); // a trainer
f.sprite(4, 6, 6, (0x14, 0)); // a ball
f.toggle_list(&[(1, 0x03), (2, 0x04), (4, 0x05)]);
f.visit(5, 5);
f.set_bit(ram::wToggleableObjectFlags, 0x03, true);
f.set_bit(ram::wToggleableObjectFlags, 0x04, true);
assert!(f.item_events().is_empty(), "a hidden person or trainer is not an item");
// A bag too full to take the ball leaves the bit clear, and nothing pays.
assert!(f.item_events().is_empty());
f.set_bit(ram::wToggleableObjectFlags, 0x05, true);
assert_eq!(kinds(&f.item_events()), ["item"]);
}
#[test]
fn a_hidden_item_pays_once_per_index() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, true);
assert_eq!(labels(&f.item_events()), ["FOUND A HIDDEN ITEM"]);
assert!(f.item_events().is_empty());
f.set_bit(ram::wObtainedHiddenItemsFlags, 18, true);
assert_eq!(kinds(&f.item_events()), ["item"]);
f.reward.clear_transient();
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, false);
f.sample();
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, true);
assert!(f.item_events().is_empty(), "a rollback cannot replay a hidden item");
}
#[test]
fn items_already_taken_are_seeded_and_the_two_script_shown_balls_are_not() {
let mut f = Fixture::new();
f.memory.set(ram::wCurMap, maps::REDS_HOUSE_2F);
f.set_bit(ram::wToggleableObjectFlags, 0x10, true);
f.set_bit(ram::wToggleableObjectFlags, 0x87, true);
f.set_bit(ram::wToggleableObjectFlags, 0x88, true);
f.set_bit(ram::wObtainedHiddenItemsFlags, 4, true);
assert!(f.item_events().is_empty(), "the seed pays nothing");
let seen = f.reward.export_state()["seen"].clone();
let seen: Vec<&str> = seen.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
for key in ["item:16", "hidden:4", "items:seeded"] {
assert!(seen.contains(&key), "{key}");
}
assert!(!seen.contains(&"item:135") && !seen.contains(&"item:136"));
// Giovanni beaten: the script shows the Silph Scope's ball (its bit clears), and the fly
// takes it.
f.on_map(0x8a, 22);
f.sprite(9, 25, 2, (0x48, 0));
f.toggle_list(&[(9, 0x87)]);
f.sample();
f.set_bit(ram::wToggleableObjectFlags, 0x87, false);
f.sample();
f.set_bit(ram::wToggleableObjectFlags, 0x87, true);
assert_eq!(kinds(&f.item_events()), ["item"]);
}
#[test]
fn boundary_pays_nothing_indoors_but_still_records_the_exit() {
let mut f = Fixture::booted();
f.on_map(maps::REDS_HOUSE_1F, 1);
f.warps(&[(4, 4)]);
assert!(boundary_values(&f.visit(3, 4)).is_empty(), "no payout beside an indoor door");
assert!(boundary_values(&f.visit(4, 4)).is_empty(), "nor on it");
assert!(
f.reward.exit_visited(MapExit::Warp { map: maps::REDS_HOUSE_1F, x: 4, y: 4 }),
"the ledger still knows the door, so the macros see what they always saw"
);
assert_eq!(f.reward.statistics().counts[kind::BOUNDARY], 0);
// Outdoors, in the forest and in a cave, exits pay exactly what they did.
let outdoors =
[(maps::PALLET_TOWN, 0), (maps::VIRIDIAN_FOREST, FOREST), (maps::MT_MOON_1F, CAVERN)];
for (map, tileset) in outdoors {
f.on_map(map, tileset);
assert_eq!(boundary_values(&f.visit(3, 4)), [0.05], "map {map}");
assert_eq!(boundary_values(&f.visit(4, 4)), [0.10], "map {map}");
}
}
#[test]
fn a_v6_state_restores_under_v7_with_empty_talk_and_seeded_item_ledgers() {
// A v6 run: some play, a pickup the v6 adapter did not pay for, and a v6 export -- which is
// a v7 export without any of the keys v7 writes.
let mut f = Fixture::booted();
f.on_map(maps::VIRIDIAN_FOREST, FOREST);
f.sprite(3, 5, 4, (0x14, 0));
f.toggle_list(&[(3, 0x2a)]);
f.visit(5, 5);
f.catch(0xb0, Some(3));
let mut v6 = f.reward.export_state();
let seen: Vec<Value> = v6["seen"]
.as_array()
.unwrap()
.iter()
.filter(|key| {
let key = key.as_str().unwrap();
!(key.starts_with("talk:") || key.starts_with("item") || key.starts_with("hidden:"))
})
.cloned()
.collect();
v6["seen"] = json!(seen);
v6["counts"].as_object_mut().unwrap().remove("talk");
v6["counts"].as_object_mut().unwrap().remove("item");
assert_eq!(v6["version"], json!(STATE_VERSION), "v6 and v7 share a schema version");
// Under v6 the fly took the ball.
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, true);
let mut restored = PokemonRedReward::new();
restored.import_state(&v6).unwrap();
assert_eq!(restored.statistics().counts[kind::TALK], 0);
assert_eq!(restored.statistics().counts[kind::ITEM], 0);
assert_eq!(restored.statistics().counts[kind::CATCH], 1, "nothing else moves");
f.reward = restored;
assert!(f.sample().is_empty(), "no retroactive payout for anything taken under v6");
// A rollback to a v6-era slot where the ball is still on the ground: taking it again is
// the same pickup, and it does not pay.
f.reward.clear_transient();
f.set_bit(ram::wToggleableObjectFlags, 0x2a, false);
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, false);
f.sample();
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, true);
assert!(f.item_events().is_empty());
// The talk ledger starts empty: the first conversation under v7 pays.
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
}
#[test]
fn a_warp_is_classified_by_the_header_still_loaded_while_the_map_id_has_moved_on() {
// `WarpFound2` writes the destination into `wCurMap` and then plays the map-change sound for
// thirty-odd frames before `EnterMap` loads the new header (`tests/rom_engage.rs` measured
// it at Route 2's gate door). On those frames the tileset and the warp table are still the
// map the fly is leaving, and the exit it is standing on is that map's.
let mut f = Fixture::booted();
f.on_map(maps::ROUTE_2, 0);
f.warps(&[(3, 11)]);
f.visit(3, 10);
// Town -> building: the id is the gate's, the header is still Route 2's, and Route 2's
// door is an outdoor exit, so its on-exit half pays as it always did.
f.memory.set(ram::wCurMap, 0x2f);
assert_eq!(boundary_values(&f.visit(3, 11)), [0.10]);
// Building -> town: the id is Pewter's, the header still the museum's, whose door is an
// indoor exit and pays nothing.
f.on_map(0x34, 10);
f.warps(&[(10, 7)]);
f.visit(10, 6);
f.memory.set(ram::wCurMap, maps::PEWTER_CITY);
assert!(boundary_values(&f.visit(10, 7)).is_empty());
}

View file

@ -15,10 +15,10 @@
use std::time::Instant; 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::emulator::{AUDIO_SILENCE_LEVEL, CPU_TICKS_PER_SECOND};
use flybrain_gb::pokemon_red::symbols::ram; use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM}; use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM, engage};
use flybrain_gb::{ use flybrain_gb::{
DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, FRAMEBUFFER_LEN, GameAdapter, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, FRAMEBUFFER_LEN, GameAdapter,
buttons, buttons,
@ -444,7 +444,10 @@ fn read_u32(bytes: &[u8], offset: &mut usize) -> Option<u32> {
/// The boundary rule, against the cartridge rather than a synthetic trace. /// The boundary rule, against the cartridge rather than a synthetic trace.
/// ///
/// `docs/design/room-escape.md` section 2, Verification: "from the bedroom archive, the first /// `docs/design/room-escape.md` section 2, Verification: "from the bedroom archive, the first
/// payouts are boundary events near the stairs". 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: /// WRAM trace can check:
/// ///
/// 1. **the warp table layout.** `RedsHouse2F_Object` declares exactly one warp, /// 1. **the warp table layout.** `RedsHouse2F_Object` declares exactly one warp,
@ -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. /// layout were X-then-Y, or the stride were not four, this assertion is what fails.
/// 2. **that the rule fires at the right place.** The fly spawns at (3, 6) with the stairs at /// 2. **that the rule fires at the right place.** The fly spawns at (3, 6) with the stairs at
/// (7, 1), four tiles and five rows away, so the stairs are not baselined by the first /// (7, 1), four tiles and five rows away, so the stairs are not baselined by the first
/// playable sample and have to be walked to. /// 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] #[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()); let mut emulator = skip_without_rom!(boot());
assert_eq!(emulator.rom_sha256(), SUPPORTED_ROM, "FLY_ROM is not the pinned cartridge"); assert_eq!(emulator.rom_sha256(), SUPPORTED_ROM, "FLY_ROM is not the pinned cartridge");
let mut adapter = PokemonRedReward::new(); let mut adapter = PokemonRedReward::new();
@ -498,6 +503,8 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() {
0, 0,
"an indoor map has no connected edges" "an indoor map has no connected edges"
); );
assert_eq!(emulator.read_wram(ram::wCurMapTileset), 4, "REDS_HOUSE_2");
assert!(engage::indoor(4), "and the rule calls it a building");
let (spawn_x, spawn_y) = let (spawn_x, spawn_y) =
(emulator.read_wram(ram::wXCoord), emulator.read_wram(ram::wYCoord)); (emulator.read_wram(ram::wXCoord), emulator.read_wram(ram::wYCoord));
assert!( assert!(
@ -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 seed: u64 = 0x5eed_1234_5678_9abc;
let mut boundary: Vec<(u8, u8, f64)> = Vec::new(); let mut boundary: Vec<(u8, u8, f64)> = Vec::new();
let mut kinds: Vec<&'static str> = Vec::new(); let mut kinds: Vec<&'static str> = Vec::new();
let mut found_at: Option<(u8, u8)> = None;
for frame in 0..24_000u32 { for frame in 0..24_000u32 {
seed = seed seed = seed
.wrapping_mul(6_364_136_223_846_793_005) .wrapping_mul(6_364_136_223_846_793_005)
@ -531,26 +539,19 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() {
boundary.push((x, y, event.value)); boundary.push((x, y, event.value));
} }
} }
if found_at.is_none() && adapter.exit_visited(MapExit::Warp { map: 0x26, x: 7, y: 1 }) {
found_at = Some((x, y));
}
if adapter.map_id() != Some(0x26) { if adapter.map_id() != Some(0x26) {
break; break;
} }
} }
eprintln!("boundary: payouts in the bedroom {boundary:?}, all kinds {kinds:?}"); eprintln!("boundary: payouts in the bedroom {boundary:?}, all kinds {kinds:?}");
let (x, y, value) = *boundary.first().expect("the stairs were never found"); let (x, y) = found_at.expect("the stairs were never found");
assert!( assert!(
x.abs_diff(7) + y.abs_diff(1) <= 1, x.abs_diff(7) + y.abs_diff(1) <= 1,
"the 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!( assert!(boundary.is_empty(), "an indoor exit pays nothing, got {boundary:?}");
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})");
}
} }

View file

@ -1434,6 +1434,9 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
let pad = format!("{:?} {names:?}", observed.scene); let pad = format!("{:?} {names:?}", observed.scene);
if pad != last_pad { if pad != last_pad {
println!("f{frame:<6} {:?} pad {pad}", player.map(|p| (p.map, p.x, p.y))); println!("f{frame:<6} {:?} pad {pad}", player.map(|p| (p.map, p.x, p.y)));
if let Some(line) = battle_line(gb) {
println!(" {line}");
}
last_pad = pad; last_pad = pad;
} }
let mut mask = 0u8; let mut mask = 0u8;
@ -2064,3 +2067,36 @@ fn main() {
), ),
} }
} }
/// Row 60: the battle bytes a `MOVE n` button's effect rests on, in one line -- the fly's moves
/// with PP and whether the cartridge would answer each with nothing, both sides' stat stages
/// (7 is normal, 1 is -6), the enemy's stats, status and HP.
fn battle_line(gb: &mut Emulator) -> Option<String> {
let battle = state::battle(gb)?;
let own = battle.own?;
let moves: Vec<String> = own
.moves
.iter()
.flatten()
.map(|entry| {
let nothing = state::move_without_effect(gb, entry.id);
let row = state::move_data(gb, entry.id).map(|data| (data.effect, data.power));
format!("{:#04x} pp{} row{row:?} nothing={nothing:?}", entry.id, entry.pp)
})
.collect();
let stages = |gb: &mut Emulator, base: u16| -> Vec<u8> { (0..6).map(|i| gb.read8(base + i)).collect() };
let own_stages = stages(gb, ram::wPlayerMonStatMods);
let enemy_stages = stages(gb, ram::wEnemyMonStatMods);
let enemy_stats: Vec<u16> = (0..4)
.map(|i| u16::from(gb.read8(ram::wEnemyMonAttack + 2 * i)) * 256 + u16::from(gb.read8(ram::wEnemyMonAttack + 2 * i + 1)))
.collect();
Some(format!(
"menu={:?} own hp {}/{} moves [{}] stages {own_stages:?} | enemy {:?} stages {enemy_stages:?} stats {enemy_stats:?} status {:#04x}",
battle.menu,
own.hp,
own.max_hp,
moves.join(", "),
battle.enemy.map(|enemy| (enemy.species, enemy.level, enemy.hp, enemy.max_hp)),
gb.read8(ram::wEnemyMonStatus),
))
}

View file

@ -247,6 +247,12 @@ struct Trace {
/// The pad's composition, measured rather than read off the table: "which sub-state offers /// The pad's composition, measured rather than read off the table: "which sub-state offers
/// `BACK`" is a question about the build under test and not about the document. /// `BACK`" is a question about the build under test and not about the document.
battle_pads: BTreeMap<&'static str, BTreeSet<String>>, battle_pads: BTreeMap<&'static str, BTreeSet<String>>,
/// Row 60: every battle as `(frames, macro starts, won)`, the one running, payouts by kind,
/// and `MOVE n` starts against how many chose a move the cartridge answers with nothing.
battles: Vec<(u64, u64, bool)>,
battle_now: Option<(u64, u64, bool)>,
payouts_by_kind: BTreeMap<&'static str, (u64, f64)>,
move_starts: (u64, u64),
wall_seconds: f64, wall_seconds: f64,
} }
@ -423,6 +429,10 @@ fn run(
battle_frames: BTreeMap::new(), battle_frames: BTreeMap::new(),
battle_starts: BTreeMap::new(), battle_starts: BTreeMap::new(),
battle_pads: BTreeMap::new(), battle_pads: BTreeMap::new(),
battles: Vec::new(),
battle_now: None,
payouts_by_kind: BTreeMap::new(),
move_starts: (0, 0),
wall_seconds: 0.0, wall_seconds: 0.0,
seeded: seeded_note, seeded: seeded_note,
refusals: BTreeMap::new(), refusals: BTreeMap::new(),
@ -507,6 +517,23 @@ fn run(
if let Some(sub) = battle_sub { if let Some(sub) = battle_sub {
*trace.battle_starts.entry((event.name, sub)).or_insert(0) += 1; *trace.battle_starts.entry((event.name, sub)).or_insert(0) += 1;
} }
if let Some(battle) = trace.battle_now.as_mut() {
battle.1 += 1;
}
if let Some(slot) = event.name.strip_prefix("MOVE ") {
trace.move_starts.0 += 1;
let id = flybrain_gb::pokemon_red::state::battle(&mut emulator)
.and_then(|battle| battle.own)
.zip(slot.parse::<usize>().ok())
.and_then(|(own, slot)| own.moves.get(slot - 1).copied().flatten())
.map(|entry| entry.id);
if id.is_some_and(|id| {
flybrain_gb::pokemon_red::state::move_without_effect(&mut emulator, id)
== Some(true)
}) {
trace.move_starts.1 += 1;
}
}
running = Some(Running { running = Some(Running {
name: event.name, name: event.name,
from: location, from: location,
@ -588,6 +615,29 @@ fn run(
frame.copy_from_slice(emulator.framebuffer()); frame.copy_from_slice(emulator.framebuffer());
payouts = adapter.sample(&mut emulator, ms); payouts = adapter.sample(&mut emulator, ms);
for payout in &payouts {
let entry = trace.payouts_by_kind.entry(payout.kind).or_insert((0, 0.0));
*entry = (entry.0 + 1, entry.1 + payout.value);
}
{
use flybrain_gb::MemoryReader;
let fighting =
emulator.read8(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle) != 0;
let won = payouts.iter().any(|payout| matches!(payout.kind, "battle" | "trainer"));
match (fighting, trace.battle_now.as_mut()) {
(true, Some(battle)) => {
battle.0 += 1;
battle.2 |= won;
}
(true, None) => trace.battle_now = Some((1, 0, won)),
(false, Some(_)) => {
let mut battle = trace.battle_now.take().expect("a battle");
battle.2 |= won;
trace.battles.push(battle);
}
(false, None) => {}
}
}
if let Some(layer) = macros.as_mut() { if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter); let ledger = AdapterLedger(&adapter);
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms); let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
@ -987,6 +1037,7 @@ fn main() {
println!("| {name} | {sub} | {n} |"); println!("| {name} | {sub} | {n} |");
} }
} }
battle_report(&trace);
println!("\n| scene | frames | longest run | run began (brain min) |"); println!("\n| scene | frames | longest run | run began (brain min) |");
println!("| --- | ---: | ---: | ---: |"); println!("| --- | ---: | ---: | ---: |");
for (scene, frames) in &trace.scenes { for (scene, frames) in &trace.scenes {
@ -1017,3 +1068,43 @@ fn main() {
WINDOW_MS / 1000.0 WINDOW_MS / 1000.0
); );
} }
/// Row 60's numbers: payouts by kind, every battle's length and whether it paid a win, and the
/// `MOVE n` starts that chose a move the cartridge answers with nothing.
fn battle_report(trace: &Trace) {
println!("\n| payout kind | n | total |");
println!("| --- | ---: | ---: |");
for (kind, (n, total)) in &trace.payouts_by_kind {
println!("| {kind} | {n} | {total:.2} |");
}
let mut lengths: Vec<u64> = trace.battles.iter().map(|battle| battle.0).collect();
lengths.sort_unstable();
let at = |q: f64| -> u64 {
if lengths.is_empty() {
return 0;
}
lengths[((lengths.len() - 1) as f64 * q).round() as usize]
};
println!("\n| battles | n |");
println!("| --- | ---: |");
println!("| ended | {} |", trace.battles.len());
println!("| won (a battle or trainer payout) | {} |", trace.battles.iter().filter(|b| b.2).count());
println!(
"| still running at the end | {} |",
trace.battle_now.map_or("no".to_string(), |b| format!("{} frames, {} macros", b.0, b.1))
);
println!(
"| frames, median / p90 / max | {} / {} / {} |",
at(0.5),
at(0.9),
lengths.last().copied().unwrap_or(0)
);
println!(
"| macros per battle, max | {} |",
trace.battles.iter().map(|b| b.1).max().unwrap_or(0)
);
println!(
"| `MOVE n` starts / on a move without effect | {} / {} |",
trace.move_starts.0, trace.move_starts.1
);
}

View file

@ -321,7 +321,7 @@ mod tests {
wall_ms: 1_700_000_000_000, wall_ms: 1_700_000_000_000,
rom_sha256: "ab".repeat(32), rom_sha256: "ab".repeat(32),
emulator_frame: 12_345, emulator_frame: 12_345,
compatibility: "kernel/pokered-unique8-v6/fingerprint".to_string(), compatibility: "kernel/pokered-unique8-v7/fingerprint".to_string(),
speed: 1.0, speed: 1.0,
buttons: 0, buttons: 0,
rank_since_ms: 4_242.0, rank_since_ms: 4_242.0,

View file

@ -242,7 +242,7 @@ pub struct FeedMacroOutcome {
/// Reward categories the feed reports counts for. The adapter's own interned kinds /// Reward categories the feed reports counts for. The adapter's own interned kinds
/// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, /// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`,
/// `catch`) map onto these. /// `catch`, `talk`, `item`) map onto these.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum RewardKind { pub enum RewardKind {
@ -291,6 +291,15 @@ impl RewardKind {
// for it on the same frame, so the `pokedex` counter already moves. Mapping `catch` // for it on the same frame, so the `pokedex` counter already moves. Mapping `catch`
// there as well would count one event twice. // there as well would count one event twice.
"catch" => Self::Wildwin, "catch" => Self::Wildwin,
// `talk` and `item` (the operator, 2026-09-23) are the fly finding what is in a place:
// a person or a sign it opened, an item it picked up. That is the family `explore`
// already counts -- new ground, a door found -- at the same quiet scale (0.05 to
// 0.15), so both publish there and the feed's closed kind set does not move. Not
// `area`, which counts maps and is a notable row; not `story`, which is the plot;
// not `wildwin`, which is a battle. The Pokémon Red ticker's copy for `explore` stays
// "new place" for all four, the operator's call (`apps/stage/src/games/pokemon-red.ts`).
"talk" => Self::Explore,
"item" => Self::Explore,
// The platformer. // The platformer.
"band" => Self::Explore, "band" => Self::Explore,
"coin" => Self::Wildwin, "coin" => Self::Wildwin,
@ -749,6 +758,8 @@ mod tests {
assert_eq!(RewardKind::from_adapter("nonsense"), None); assert_eq!(RewardKind::from_adapter("nonsense"), None);
assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore)); assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore));
assert_eq!(RewardKind::from_adapter("catch"), Some(RewardKind::Wildwin)); assert_eq!(RewardKind::from_adapter("catch"), Some(RewardKind::Wildwin));
assert_eq!(RewardKind::from_adapter("talk"), Some(RewardKind::Explore));
assert_eq!(RewardKind::from_adapter("item"), Some(RewardKind::Explore));
} }
#[test] #[test]

View file

@ -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 //! The unit tests in `flybrain-gb` cover the decision function and the adapter's own state
//! migration separately. This is the two of them against one artefact: a real `FLYSIM01` //! migration separately. This is the two of them against one artefact: a real `FLYSIM01`
//! envelope carrying a `pokered-unique8-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 //! 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 //! 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::GameAdapter;
use flybrain_gb::MemoryReader;
use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::compatibility::{RestoreDecision, accepted_adapters, decide}; use flybrain_gb::compatibility::{RestoreDecision, accepted_adapters, decide};
use flybrain_gb::pokemon_red::PokemonRedReward; use flybrain_gb::pokemon_red::PokemonRedReward;
use flysim::store::{self, RuntimeState}; use flysim::store::{self, RuntimeState};
@ -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 /// 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 /// `v7` state with keys deleted — this is the shape the release box's checkpoints really carry,
/// carry, field for field. /// field for field, including a `boundary:` key earned indoors (Red's staircase, map 38) that
fn v5_reward() -> serde_json::Value { /// `v7` would not have paid for and keeps anyway.
fn v6_reward() -> serde_json::Value {
serde_json::json!({ serde_json::json!({
"version": 4, "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"], "tiles": ["0:5:6", "0:5:7"],
"tileCounts": { "0": 2 }, "tileCounts": { "0": 2 },
"wildWins": { "0:112:4": 2 }, "wildWins": { "0:112:4": 2 },
"catchCounts": { "176": 1 },
"replayBlocked": [], "replayBlocked": [],
"counts": { "counts": {
"milestone": 2, "exploration": 0, "map": 1, "species": 1, "milestone": 2, "exploration": 0, "map": 1, "species": 1,
"trainer": 0, "battle": 2, "badge": 0, "boundary": 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 }], "recent": [{ "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 }],
"last": { "species": { "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 } }, "last": { "species": { "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 } },
"initialized": true, "initialized": true,
@ -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::decoder::DecoderState;
use flybrain_core::lif::LifState; use flybrain_core::lif::LifState;
use flybrain_core::ordered::NumberMap; use flybrain_core::ordered::NumberMap;
@ -105,12 +117,12 @@ fn v5_checkpoint() -> Vec<u8> {
wall_ms: 1_790_000_000_000, wall_ms: 1_790_000_000_000,
rom_sha256: flybrain_gb::pokemon_red::SUPPORTED_ROM.to_string(), rom_sha256: flybrain_gb::pokemon_red::SUPPORTED_ROM.to_string(),
emulator_frame: 1_000_000, emulator_frame: 1_000_000,
compatibility: compatibility("pokered-unique8-v5"), compatibility: compatibility("pokered-unique8-v6"),
speed: 1.0, speed: 1.0,
buttons: 0, buttons: 0,
rank_since_ms: 1_000.0, rank_since_ms: 1_000.0,
last_event_id: 4_242, last_event_id: 4_242,
reward: v5_reward(), reward: v6_reward(),
ratchet: flybrain_gb::RatchetState { best: 3, attempts: 1, recoveries: 4, ..Default::default() }, ratchet: flybrain_gb::RatchetState { best: 3, attempts: 1, recoveries: 4, ..Default::default() },
emulator: vec![3; 64], emulator: vec![3; 64],
framebuffer: vec![0; 32], framebuffer: vec![0; 32],
@ -121,13 +133,13 @@ fn v5_checkpoint() -> Vec<u8> {
} }
#[test] #[test]
fn a_v5_checkpoint_is_refused_under_v6_without_the_opt_in() { fn a_v6_checkpoint_is_refused_under_v7_without_the_opt_in() {
let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes"); let checkpoint = store::decode(&v6_checkpoint()).expect("the fixture decodes");
let adapter = PokemonRedReward::new(); let adapter = PokemonRedReward::new();
let current = compatibility(adapter.id()); let current = compatibility(adapter.id());
assert_ne!(checkpoint.runtime.compatibility, current, "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!( assert!(
matches!( matches!(
decide( 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] #[test]
fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_zero() { fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_seeded() {
let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes"); let checkpoint = store::decode(&v6_checkpoint()).expect("the fixture decodes");
let mut adapter = PokemonRedReward::new(); let mut adapter = PokemonRedReward::new();
let current = compatibility(adapter.id()); let current = compatibility(adapter.id());
@ -154,25 +175,25 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z
&checkpoint.runtime.compatibility, &checkpoint.runtime.compatibility,
&current, &current,
adapter.migrates_from(), 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. // 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(); let after = adapter.export_state();
assert_eq!(after["catchCounts"], serde_json::json!({}), "the new counter starts at 0"); assert_eq!(after["counts"]["talk"], serde_json::json!(0), "no conversation was ever paid");
assert_eq!(after["counts"]["catch"], serde_json::json!(0)); 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 nothing else moved: every field the v6 state carried round-trips to the same value,
// and the only key v6 adds is the counter. // 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: // `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 // it serializes every kind in the catalog, so a v7 state lists `talk` and `item` where a v6
// nothing to list. Every kind the v5 state did carry keeps its number. // state had nothing to list. Every kind the v6 state did carry keeps its number.
let before = v5_reward(); let before = v6_reward();
for (key, value) in before.as_object().unwrap() { for (key, value) in before.as_object().unwrap() {
if key == "counts" { if key == "counts" {
for (kind, count) in value.as_object().unwrap() { for (kind, count) in value.as_object().unwrap() {
@ -184,7 +205,7 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z
.keys() .keys()
.filter(|kind| !value.as_object().unwrap().contains_key(*kind)) .filter(|kind| !value.as_object().unwrap().contains_key(*kind))
.collect(); .collect();
assert_eq!(added, vec!["catch"], "v6 counts one more kind and no others"); assert_eq!(added, vec!["talk", "item"], "v7 counts two more kinds and no others");
continue; continue;
} }
assert_eq!(&after[key], value, "{key} must survive the migration byte for byte"); assert_eq!(&after[key], value, "{key} must survive the migration byte for byte");
@ -195,7 +216,29 @@ fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_z
.keys() .keys()
.filter(|key| !before.as_object().unwrap().contains_key(*key)) .filter(|key| !before.as_object().unwrap().contains_key(*key))
.collect(); .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. // The rest of what a restore reads is untouched by the migration.
assert_eq!(adapter.progress().rank, 3); 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] #[test]
fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() { fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() {
let adapter = PokemonRedReward::new(); let adapter = PokemonRedReward::new();
let accepted = accepted_adapters(Some("pokered-unique8-v5")); let accepted = accepted_adapters(Some("pokered-unique8-v6"));
let current = compatibility(adapter.id()); let current = compatibility(adapter.id());
// A v5 string whose state format also moved: a different build, not a rule change. // A v6 string whose state format also moved: a different build, not a rule change.
let other_abi = compatibility("pokered-unique8-v5").replace("199616", "199617"); let other_abi = compatibility("pokered-unique8-v6").replace("199616", "199617");
assert!(matches!( assert!(matches!(
decide(&other_abi, &current, adapter.migrates_from(), &accepted), decide(&other_abi, &current, adapter.migrates_from(), &accepted),
RestoreDecision::Refuse(_) RestoreDecision::Refuse(_)
@ -221,4 +264,15 @@ fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() {
decide(&current, &current, adapter.migrates_from(), &[]), decide(&current, &current, adapter.migrates_from(), &[]),
RestoreDecision::Exact RestoreDecision::Exact
); );
// And v5 -> v7 is not a migration this adapter wrote, whatever the operator names.
assert!(matches!(
decide(
&compatibility("pokered-unique8-v5"),
&current,
adapter.migrates_from(),
&accepted_adapters(Some("pokered-unique8-v5,pokered-unique8-v6")),
),
RestoreDecision::Refuse(_)
));
} }

View file

@ -267,7 +267,7 @@ async fn the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_kil
let (status, versions) = service.get("/status"); let (status, versions) = service.get("/status");
assert_eq!(status, 200); assert_eq!(status, 200);
assert_eq!(versions["version"]["kernel"], json!("lif-1ms-f64-v2")); assert_eq!(versions["version"]["kernel"], json!("lif-1ms-f64-v2"));
assert_eq!(versions["version"]["adapter"], json!("pokered-unique8-v6")); assert_eq!(versions["version"]["adapter"], json!("pokered-unique8-v7"));
assert!( assert!(
versions["version"]["dataset"].as_str().unwrap_or_default().len() > 32, versions["version"]["dataset"].as_str().unwrap_or_default().len() > 32,
"the dataset fingerprint is in /status: {}", "the dataset fingerprint is in /status: {}",

View 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);
}

View file

@ -3290,3 +3290,159 @@ fn the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint() {
"the fly never went up the room past the doormat rows: highest row {highest_row:?}" "the fly never went up the room past the doormat rows: highest row {highest_row:?}"
); );
} }
fn row60_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_ROW60_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// Route 1, from the checkpoint taken mid-trap: Squirtle L5 (TACKLE, TAIL WHIP) against wild
/// Pidgey and Rattata.
///
/// **What was live** (2026-09-23, v0.5.5, rank 9 after the reset to milestone 1): `MOVE 2` 183
/// times and `MOVE 1` once, "Nothing happened!" on the screen, twenty-five brain minutes with no
/// reward. TAIL WHIP took the Pidgey's DEFENSE to where the cartridge refuses it (the stat at 1,
/// or the stage at -6) and the pad kept dealing it beside TACKLE; every battle ended with Squirtle
/// fainted and the fly back home (`infra/docs/macros-traps.md` row 60). The checkpoint itself is
/// the frame Squirtle fainted.
///
/// The driver is the real palette with the live readout's measured favourite: `MOVE 2` whenever
/// the pad deals it, otherwise a uniform choice per hold -- a harness choice, not the fly's. The
/// claims:
///
/// - **no `MOVE n` is dealt for a move the cartridge answers with nothing while another move
/// would do something**, on any frame of the fly's own turn, and the run does reach that state;
/// - **a wild battle is won** inside the budget, which the base never does with this driver.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_ROW60_CHECKPOINT=.local/checkpoints/release-rank9-row60.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture tail_whip
/// ```
#[test]
fn tail_whip_at_its_limit_is_not_dealt_and_a_route_one_battle_is_won() {
use flybrain_gb::pokemon_red::macros::PokemonPalette;
use flybrain_gb::pokemon_red::state;
use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::{MacroPalette, MemoryReader, Started};
let rom = skip_without_rom!();
let Some(checkpoint) = row60_checkpoint() else {
eprintln!("skipped: no FLY_ROW60_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert_ne!(run.gb.read8(ram::wIsInBattle), 0, "the checkpoint is inside the Route 1 battle");
let budget = 72_000u32;
let hold_frames = 48u32;
let mut palette = PokemonPalette::new(SEED);
let mut rng = 20_260_923u32;
let mut running = false;
let mut since_decision = hold_frames;
let mut ms = run.ms;
let mut at_a_limit = 0u32;
let mut dealt_without_effect = 0u32;
let mut starts = std::collections::BTreeMap::<&'static str, u32>::new();
// (frames, the enemy fainted, the fly's Pokemon standing at the end)
let mut battles: Vec<(u32, bool, bool)> = Vec::new();
let mut current: Option<(u32, bool, bool)> = Some((0, false, false));
for _ in 0..budget {
palette.clock(ms);
let observed = {
let ledger = AdapterLedger(&run.adapter);
palette.observe(&mut run.gb, &ledger)
};
let names: Vec<&str> = observed.bindings.iter().map(|binding| binding.name).collect();
if let Some(battle) = state::battle(&mut run.gb)
&& battle.own_turn
&& let Some(own) = battle.own
{
let mut nothing = [false; 4];
let mut useful = [false; 4];
for (slot, entry) in own.moves.iter().enumerate() {
if let Some(entry) = entry.filter(|entry| entry.id != 0) {
nothing[slot] = state::move_without_effect(&mut run.gb, entry.id) == Some(true);
useful[slot] = entry.pp > 0 && !nothing[slot];
}
}
if nothing.iter().any(|flag| *flag) && useful.iter().any(|flag| *flag) {
at_a_limit += 1;
for (slot, name) in ["MOVE 1", "MOVE 2", "MOVE 3", "MOVE 4"].iter().enumerate() {
if nothing[slot] && names.contains(name) {
dealt_without_effect += 1;
}
}
}
}
let mut mask = 0u8;
{
let ledger = AdapterLedger(&run.adapter);
if running {
match palette.step(&mut run.gb, &ledger) {
Some(held) => mask = held,
None => running = false,
}
} else if since_decision >= hold_frames && !observed.bindings.is_empty() {
since_decision = 0;
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
let binding = observed
.bindings
.iter()
.find(|binding| binding.name == "MOVE 2")
.unwrap_or(&observed.bindings[rng as usize % observed.bindings.len()]);
if let Started::Running(_) = palette.start(binding.slot, &mut run.gb, &ledger) {
*starts.entry(binding.name).or_default() += 1;
running = true;
match palette.step(&mut run.gb, &ledger) {
Some(held) => mask = held,
None => running = false,
}
}
}
}
since_decision += 1;
run.gb.set_buttons(mask);
run.gb.run_frame().expect("a frame should complete");
ms += MS_PER_FRAME;
run.adapter.sample(&mut run.gb, ms);
let fighting = run.gb.read8(ram::wIsInBattle) != 0;
match (&mut current, fighting) {
(Some((frames, fainted, standing)), true) => {
*frames += 1;
if let Some(battle) = state::battle(&mut run.gb) {
*fainted |= battle.enemy.is_some_and(|enemy| enemy.hp == 0);
if let Some(own) = battle.own {
*standing = own.hp > 0;
}
}
}
(None, true) => current = Some((1, false, true)),
(Some(battle), false) => {
battles.push(*battle);
current = None;
}
(None, false) => {}
}
}
let won = battles.iter().filter(|battle| battle.1 && battle.2).count();
eprintln!(
"{:.1} brain minutes: battles ended {} (won {won}), (frames, enemy fainted, standing) \
{battles:?}, still in one {current:?}; own-turn frames with a move without effect beside a \
useful one {at_a_limit}, the refused move dealt on {dealt_without_effect}; starts \
{starts:?}; rank {}",
(ms - run.ms) / 60_000.0,
battles.len(),
run.adapter.progress().rank,
);
assert!(at_a_limit > 0, "the run never reached the trap's own state");
assert_eq!(
dealt_without_effect, 0,
"a MOVE n the cartridge answers with nothing was dealt beside one it does not"
);
assert!(won > 0, "no wild battle was won in {budget} frames: {battles:?}");
}

View file

@ -209,6 +209,22 @@ EXTRA_RAM = (
# services/flysim/tools/resolve_wram.py is the second reading of it, from ram/wram.asm at # services/flysim/tools/resolve_wram.py is the second reading of it, from ram/wram.asm at
# this commit, bracketed by wFontLoaded and wForcePlayerToChooseMon. # this commit, bracketed by wFontLoaded and wForcePlayerToChooseMon.
'wCapturedMonSpecies', 'wCapturedMonSpecies',
# The engagement rewards (`docs/rewards-learning.md`, the operator 2026-09-23).
# `talk`: DisplayTextID copies its argument -- the sprite slot, or a sign's text id --
# into wSpriteIndex before it looks the text up, and the overworld's A press only
# reaches it while wWalkCounter is zero. `item`: an item ball is a sprite whose
# wMapSpriteExtraData entry is (item id, 0) -- LoadMapHeader writes that shape for
# an ITEM-flagged object_event and no other -- found in the map's
# wToggleableObjectList; PickUpItem hides it by setting its global bit in
# wToggleableObjectFlags, and FoundHiddenItemText sets the hidden item's bit in
# wObtainedHiddenItemsFlags, each only after GiveItem succeeded.
# services/flysim/tools/resolve_wram.py is the second reading of all six.
'wSpriteIndex',
'wWalkCounter',
'wMapSpriteExtraData',
'wToggleableObjectFlags',
'wToggleableObjectList',
'wObtainedHiddenItemsFlags',
) )

View file

@ -57,6 +57,38 @@ WANTED = {
# this one caught", and the only signal that needs no second rule to tell a # this one caught", and the only signal that needs no second rule to tell a
# catch apart from a gift, a trade or an evolution. # catch apart from a gift, a trade or an evolution.
'wCapturedMonSpecies': 'the species a ball just caught, 0 for none', 'wCapturedMonSpecies': 'the species a ball just caught, 0 for none',
# The engagement rewards (`docs/rewards-learning.md`, the operator 2026-09-23).
# DisplayTextID stores its argument here -- a sprite slot, or a sign's text id --
# before it looks the text up, so it names what a conversation is with.
'wSpriteIndex': "DisplayTextID's argument, a sprite slot or a text id",
# Non-zero for the frames of a step; the overworld only reads an A press at zero.
'wWalkCounter': 'frames left in the step the player is taking',
# Two bytes per sprite slot: (item id, 0) for an ITEM-flagged object_event,
# (trainer class, trainer number) for a TRAINER one, zeroes otherwise.
'wMapSpriteExtraData': 'per sprite slot: item id, or trainer class and number',
# One bit per global toggleable object; PickUpItem's HideObject sets an item
# ball's bit once GiveItem has succeeded.
'wToggleableObjectFlags': 'global hidden bits of every toggleable object',
# The current map's (sprite slot, global toggleable index) pairs, $ff-terminated.
'wToggleableObjectList': "this map's toggleable sprites and their global indices",
# One bit per hidden item, set by FoundHiddenItemText once GiveItem succeeded.
'wObtainedHiddenItemsFlags': 'hidden items already found',
# Row 60 (`docs/design/macros.md` 12.23): a move the cartridge will answer
# with "Nothing happened!" is not dealt. StatModifierUpEffect and
# StatModifierDownEffect (engine/battle/effects.asm) refuse on the stage byte
# (1 is -6, 7 normal, 13 is +6) and on the modified stat itself (1 or 999);
# SleepEffect, PoisonEffect and ParalyzeEffect on the target's status byte and
# type. Each is the battle_struct field or stage array those routines read.
'wPlayerMonStatMods': "the active Pokemon's six stat stages, 7 is normal",
'wEnemyMonStatMods': "the enemy's six stat stages, 7 is normal",
'wEnemyMonStatus': "the enemy's status condition byte",
'wEnemyMonType1': "the enemy's first type (wEnemyMonType2 follows it)",
'wEnemyMonAttack': "the enemy's modified Attack, Defense, Speed, Special",
'wBattleMonAttack': "the active Pokemon's modified Attack, Defense, Speed, Special",
# Mist and a substitute turn a stat-lowering move away, a substitute a
# poisoning one, and a target that must recharge is put to sleep whatever
# its status (MoveHitTest, CheckTargetSubstitute, SleepEffect).
'wEnemyBattleStatus2': "the enemy's Mist, substitute and recharge bits",
} }
@ -76,9 +108,33 @@ WANTED = {
COUNTED = { COUNTED = {
'NUM_HMS': ('constants/item_constants.asm', r'^\s*add_hm\s+\w+'), 'NUM_HMS': ('constants/item_constants.asm', r'^\s*add_hm\s+\w+'),
'NUM_TMS': ('constants/item_constants.asm', r'^\s*add_tm\s+\w+'), 'NUM_TMS': ('constants/item_constants.asm', r'^\s*add_tm\s+\w+'),
# The engagement rewards need two more, for the same reason: each is an
# `EQU const_value` over an enumeration, and each is the size of a declaration
# the cursor has to cross. `ds NUM_STATS` sits between wFontLoaded and
# wTrainerClass, the bracket of wWalkCounter; `flag_array NUM_CITY_MAPS` sits
# between wNumHoFTeams and wStatusFlags5, the bracket of the toggleable-object
# and hidden-item flags. Both are counted only up to the line that defines
# them, which is where `const_value` is read.
'NUM_STATS': (
'constants/battle_constants.asm', r'^\s*const\s+STAT_\w+', r'^DEF NUM_STATS\b'
),
'NUM_CITY_MAPS': (
'constants/map_constants.asm', r'^\s*map_const\s+\w+', r'^DEF NUM_CITY_MAPS\b'
),
} }
def count_in(root: Path, path: str, pattern: str, stop: str | None = None) -> int:
"""Lines of `path` matching `pattern`, up to the first line matching `stop`."""
text = (root / path).read_text()
if stop is not None:
match = re.search(stop, text, re.M)
if match is None:
raise SystemExit(f'{path}: no line matches {stop}')
text = text[: match.start()]
return len(re.findall(pattern, text, re.M))
def pinned(text: str) -> dict[str, int]: def pinned(text: str) -> dict[str, int]:
"""Every address `symbols.rs` carries today, by symbol name.""" """Every address `symbols.rs` carries today, by symbol name."""
return { return {
@ -95,19 +151,20 @@ def constants(root: Path) -> dict[str, int]:
BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left
out, which kills the cursor at any declaration that uses it. out, which kills the cursor at any declaration that uses it.
""" """
counted = { counted = {name: count_in(root, *spec) for name, spec in COUNTED.items()}
name: len(re.findall(pattern, (root / path).read_text(), re.M))
for name, (path, pattern) in COUNTED.items()
}
pending: dict[str, str] = {} pending: dict[str, str] = {}
sources = sorted((root / 'constants').glob('*.asm')) + sorted( sources = sorted((root / 'constants').glob('*.asm')) + sorted(
(root / 'constants').glob('*.inc') (root / 'constants').glob('*.inc')
) )
for path in sources: for path in sources:
text = path.read_text()
for name, value in re.findall( for name, value in re.findall(
r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', text, re.M
): ):
pending.setdefault(name, value.strip()) pending.setdefault(name, value.strip())
# A name counted by `const` or `rb` is an expression over the running
# counter at its own line, so it overrides the raw `EQU const_value - 1`.
pending.update(enumerated(text))
out: dict[str, int] = dict(counted) out: dict[str, int] = dict(counted)
while pending: while pending:
progressed = False progressed = False
@ -129,6 +186,91 @@ def constants(root: Path) -> dict[str, int]:
return out return out
def enumerated(text: str) -> dict[str, str]:
"""The names one constants file defines by counting, as expressions.
rgbasm keeps two running counters the decomp enumerates with: `const_value`
(`const_def`, `const`, `const_skip`, `const_next` in macros/const.asm) and
`_RS` (`rsreset`, `rsset`, `DEF NAME rb/rw n`, `rb_skip`). This follows both
in file order and writes each name down as the *expression* the counter held
at its line, never as a number: [`constants`]' passes evaluate it with the
rest, so a count over a constant this tool cannot resolve stays unresolved
rather than becoming a guess. A counter form it does not know (a
`shift_const`, a non-literal step) kills that counter until the next reset.
"""
out: dict[str, str] = {}
value: str | None = None
step = '1'
rs: str | None = None
for raw in text.splitlines():
line = raw.split(';')[0].strip()
if not line:
continue
match = re.fullmatch(r'const_def(?:\s+([^,]+?))?(?:\s*,\s*(.+))?', line)
if match:
value = f'({match.group(1) or "0"})'
step = f'({match.group(2) or "1"})'
continue
match = re.fullmatch(r'(?:const|const_export)\s+(\w+)', line)
if match:
if value is not None:
out[match.group(1)] = value
value = f'({value} + {step})'
continue
match = re.fullmatch(r'const_skip(?:\s+(.+))?', line)
if match:
if value is not None:
value = f'({value} + {step} * ({match.group(1) or "1"}))'
continue
match = re.fullmatch(r'const_next\s+(.+)', line)
if match:
value = f'({match.group(1)})'
continue
if line.startswith(('shift_const', 'dw_const')):
value = None
continue
if line == 'rsreset':
rs = '(0)'
continue
match = re.fullmatch(r'rsset\s+(.+)', line)
if match:
rs = f'({match.group(1)})' if '_RS' not in match.group(1) else None
continue
match = re.fullmatch(r'(rb|rw)_skip(?:\s+(.+))?', line)
if match:
if rs is not None:
unit = 1 if match.group(1) == 'rb' else 2
rs = f'({rs} + {unit} * ({match.group(2) or "1"}))'
continue
match = re.fullmatch(r'(?:DEF|def)\s+(\w+)\s+(rb|rw)(?:\s+(.+))?', line)
if match:
if rs is not None:
out[match.group(1)] = rs
unit = 1 if match.group(2) == 'rb' else 2
rs = f'({rs} + {unit} * ({match.group(3) or "1"}))'
continue
match = re.fullmatch(r'(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+(.+)', line)
if match is None:
# Anything else may be a macro that moves a counter this does not
# follow (`add_tm` advances `const_value`), so both stop here.
if not line.startswith(('ASSERT', 'assert', 'EXPORT', 'export')):
value = None
rs = None
continue
if re.search(r'\b(?:_RS|const_value)\b', match.group(2)):
expression = match.group(2)
if '_RS' in expression:
if rs is None:
continue
expression = re.sub(r'\b_RS\b', rs, expression)
if 'const_value' in expression:
if value is None:
continue
expression = re.sub(r'\bconst_value\b', value, expression)
out[match.group(1)] = expression
return out
def number(token: str) -> int: def number(token: str) -> int:
token = token.strip() token = token.strip()
if token.startswith('$'): if token.startswith('$'):
@ -176,16 +318,27 @@ def size_of(expression: str, known: dict[str, int]) -> int:
return value * scale return value * scale
def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]: def macro_sizes(
"""Sizes of the RAM struct macros, counted from their own declarations.""" root: Path, known: dict[str, int], fields: dict[str, list[tuple[str, int]]] | None = None
) -> dict[str, int]:
"""Sizes of the RAM struct macros, counted from their own declarations.
With `fields`, also each macro's `\\1Name::` field labels and their offsets,
for a macro whose whole body was sized: `battle_struct wEnemyMon` declares
`wEnemyMonStatus` at the offset its own lines put it.
"""
out: dict[str, int] = {} out: dict[str, int] = {}
for path in sorted((root / 'macros').glob('*.asm')): for path in sorted((root / 'macros').glob('*.asm')):
text = path.read_text() text = path.read_text()
for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S): for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S):
name, body = match.group(1), match.group(2) name, body = match.group(1), match.group(2)
total = 0 total = 0
offsets: list[tuple[str, int]] = []
for line in body.splitlines(): for line in body.splitlines():
line = line.split(';')[0].strip() line = line.split(';')[0].strip()
field = re.match(r'^\\1(\w+)::', line)
if field is not None and total is not None:
offsets.append((field.group(1), total))
# A struct macro labels each field with its argument # A struct macro labels each field with its argument
# (`\\1YCoord:: db`), so the label is stripped and the # (`\\1YCoord:: db`), so the label is stripped and the
# declaration after it is what reserves the bytes. # declaration after it is what reserves the bytes.
@ -209,6 +362,8 @@ def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]:
break break
if total is not None: if total is not None:
out[name] = total out[name] = total
if fields is not None:
fields[name] = offsets
return out return out
@ -216,7 +371,8 @@ def walk(
root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False
) -> tuple[dict[str, int], list[str], int]: ) -> tuple[dict[str, int], list[str], int]:
"""Resolve every symbol of wram.asm the anchored cursor can reach exactly.""" """Resolve every symbol of wram.asm the anchored cursor can reach exactly."""
macros = macro_sizes(root, known) fields: dict[str, list[tuple[str, int]]] = {}
macros = macro_sizes(root, known, fields)
lines = (root / 'ram/wram.asm').read_text().splitlines() lines = (root / 'ram/wram.asm').read_text().splitlines()
cursor: int | None = None cursor: int | None = None
resolved: dict[str, int] = {} resolved: dict[str, int] = {}
@ -333,6 +489,21 @@ def walk(
continue continue
if line.endswith('::') or re.fullmatch(r'\.\w+', line): if line.endswith('::') or re.fullmatch(r'\.\w+', line):
continue continue
# A struct macro's own field labels, at the offsets its body puts them:
# held back like any other label until the next pinned address agrees.
invocation = re.fullmatch(r'(\w+)\s+(w\w+)', line)
if invocation is not None and cursor is not None:
for field, offset in fields.get(invocation.group(1), []):
name = invocation.group(2) + field
if name not in table:
pending_run[name] = cursor + offset
elif table[name] != cursor + offset:
problems.append(
f'{name}: wram.asm gives ${cursor + offset:04x}, '
f'symbols.rs pins ${table[name]:04x}'
)
else:
checked += 1
try: try:
if cursor is not None: if cursor is not None:
cursor += declaration_size(line, known, macros) cursor += declaration_size(line, known, macros)