Compare commits
21 commits
7784a9d172
...
2f7b7cebd0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f7b7cebd0 | ||
|
|
ff49b56e2c | ||
|
|
b484a077d4 | ||
|
|
bc117ad470 | ||
|
|
fa021918b4 | ||
|
|
4591e7907a | ||
|
|
07f88c443a | ||
|
|
b33af9e772 | ||
|
|
a02d5f7995 | ||
|
|
f3e3e9cf98 | ||
|
|
438b2d8540 | ||
|
|
c8ecf06db5 | ||
|
|
37adeb9417 | ||
|
|
5d527f0910 | ||
|
|
56b02ff758 | ||
|
|
8d1d5c44f5 | ||
|
|
3cf706c96a | ||
|
|
2d288b0146 | ||
|
|
52811bf512 | ||
|
|
2e7eed0a78 | ||
|
|
ed0080ab2c |
40 changed files with 3029 additions and 164 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
5
infra/env/example.env
vendored
5
infra/env/example.env
vendored
|
|
@ -335,8 +335,9 @@ FLY_FEED_VIA=direct
|
||||||
# two strings AND the new build's adapter declares a migration from that one. A
|
# 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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(¤t, ¤t, &[], &[]), RestoreDecision::Exact);
|
assert_eq!(decide(¤t, ¤t, &[], &[]), 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()]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
423
services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs
Normal file
423
services/flysim/crates/flybrain-gb/src/pokemon_red/engage.rs
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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})");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
¤t,
|
¤t,
|
||||||
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, ¤t, adapter.migrates_from(), &accepted),
|
decide(&other_abi, ¤t, 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(¤t, ¤t, adapter.migrates_from(), &[]),
|
decide(¤t, ¤t, adapter.migrates_from(), &[]),
|
||||||
RestoreDecision::Exact
|
RestoreDecision::Exact
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// And v5 -> v7 is not a migration this adapter wrote, whatever the operator names.
|
||||||
|
assert!(matches!(
|
||||||
|
decide(
|
||||||
|
&compatibility("pokered-unique8-v5"),
|
||||||
|
¤t,
|
||||||
|
adapter.migrates_from(),
|
||||||
|
&accepted_adapters(Some("pokered-unique8-v5,pokered-unique8-v6")),
|
||||||
|
),
|
||||||
|
RestoreDecision::Refuse(_)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: {}",
|
||||||
|
|
|
||||||
432
services/flysim/crates/flysim/tests/rom_engage.rs
Normal file
432
services/flysim/crates/flysim/tests/rom_engage.rs
Normal file
|
|
@ -0,0 +1,432 @@
|
||||||
|
//! The engagement rewards against the real cartridge: a conversation indoors and an item ball.
|
||||||
|
//!
|
||||||
|
//! Gated on `FLY_ROM` *and* on a checkpoint, the way every ROM test in this workspace is, and
|
||||||
|
//! skips cleanly without either:
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! FLY_ROM="$HOME/roms/pokemon-red.gb" \
|
||||||
|
//! FLY_ENGAGE_CHECKPOINT=.local/checkpoints/<a rung-10 Pewter checkpoint> \
|
||||||
|
//! cargo test --release -p flysim --test rom_engage -- --nocapture
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## What only the cartridge can answer
|
||||||
|
//!
|
||||||
|
//! The synthetic traces in `pokemon_red/tests.rs` write `wFontLoaded`, `wSpriteIndex`,
|
||||||
|
//! `wToggleableObjectFlags` and the rest from the disassembly. They cannot say that an A press at
|
||||||
|
//! a person on this cartridge opens the box on a frame whose previous sample was the fly's own,
|
||||||
|
//! that `wSpriteIndex` names that person by the time the adapter samples, or that `PickUpItem`
|
||||||
|
//! raises the ball's bit on a frame the adapter sees. This test does, with the shipping adapter
|
||||||
|
//! sampling once a frame, and it restores the checkpoint's own `v6` reward ledger -- so it is also
|
||||||
|
//! the `v6` -> `v7` migration on real game state: the item keys are seeded from the cartridge's
|
||||||
|
//! bits and nothing already taken pays.
|
||||||
|
//!
|
||||||
|
//! ## How the fly is moved
|
||||||
|
//!
|
||||||
|
//! Not by the macro layer, and not by a brain: a small scripted walker with a breadth-first route
|
||||||
|
//! over the whole-map grid (`state::map_grid`), pressing one direction at a time, answering text
|
||||||
|
//! with B and battles with A. The question is what the adapter reads, not whether anything finds
|
||||||
|
//! its way there. From the Pewter checkpoint it walks south out of the city, down Route 2 into the
|
||||||
|
//! Viridian Forest north gate -- a building, where it talks to the old man twice -- and on into
|
||||||
|
//! the forest to the Antidote ball at (25, 11), which it picks up, and then, after rolling the
|
||||||
|
//! emulator back to before the pickup, picks up again.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use flybrain_gb::adapter::RewardEvent;
|
||||||
|
use flybrain_gb::pokemon_red::macros::state::{Facing, Walkable};
|
||||||
|
use flybrain_gb::pokemon_red::symbols::ram;
|
||||||
|
use flybrain_gb::pokemon_red::{PokemonRedReward, catalog, engage, state};
|
||||||
|
use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons};
|
||||||
|
|
||||||
|
const MS_PER_FRAME: f64 = 1000.0 / 59.7275;
|
||||||
|
const PEWTER_CITY: u8 = 0x02;
|
||||||
|
const ROUTE_2: u8 = 0x0d;
|
||||||
|
const NORTH_GATE: u8 = 0x2f;
|
||||||
|
const VIRIDIAN_FOREST: u8 = 0x33;
|
||||||
|
/// `constants/item_constants.asm`: `ANTIDOTE` is `$0b`.
|
||||||
|
const ANTIDOTE: &str = "FOUND ITEM #11";
|
||||||
|
|
||||||
|
fn rom() -> Option<Vec<u8>> {
|
||||||
|
let path = std::env::var_os("FLY_ROM")?;
|
||||||
|
match std::fs::read(&path) {
|
||||||
|
Ok(bytes) => Some(bytes),
|
||||||
|
Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checkpoint() -> Option<flysim::store::Checkpoint> {
|
||||||
|
let path = std::env::var_os("FLY_ENGAGE_CHECKPOINT")?;
|
||||||
|
Some(
|
||||||
|
flysim::store::load(std::path::Path::new(&path))
|
||||||
|
.expect("the checkpoint should be a FLYSIM01 envelope"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Run {
|
||||||
|
gb: Emulator,
|
||||||
|
adapter: PokemonRedReward,
|
||||||
|
ms: f64,
|
||||||
|
frames: u64,
|
||||||
|
payouts: Vec<RewardEvent>,
|
||||||
|
/// `(wCurMap, wCurMapTileset)` on the frame each payout in `payouts` was made.
|
||||||
|
payout_maps: Vec<(u8, u8)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delta(facing: Facing) -> (i16, i16) {
|
||||||
|
facing.delta()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask(facing: Facing) -> u8 {
|
||||||
|
match facing {
|
||||||
|
Facing::Up => buttons::UP,
|
||||||
|
Facing::Down => buttons::DOWN,
|
||||||
|
Facing::Left => buttons::LEFT,
|
||||||
|
Facing::Right => buttons::RIGHT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACINGS: [Facing; 4] = [Facing::Up, Facing::Down, Facing::Left, Facing::Right];
|
||||||
|
|
||||||
|
impl Run {
|
||||||
|
fn resume(rom: &[u8], checkpoint: &flysim::store::Checkpoint) -> Self {
|
||||||
|
let mut gb = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
|
||||||
|
.expect("binjgb should accept the cartridge");
|
||||||
|
gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state");
|
||||||
|
let mut adapter = PokemonRedReward::new();
|
||||||
|
// The checkpoint was written by `pokered-unique8-v6`: this is the migration.
|
||||||
|
adapter.import_state(&checkpoint.runtime.reward).expect("a v6 ledger is a v7 ledger");
|
||||||
|
Self { gb, adapter, ms: 0.0, frames: 0, payouts: Vec::new(), payout_maps: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte(&mut self, address: u16) -> u8 {
|
||||||
|
self.gb.read_wram(address)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frame(&mut self, mask: u8) {
|
||||||
|
self.gb.set_buttons(mask);
|
||||||
|
self.gb.run_frame().expect("a frame should complete");
|
||||||
|
self.ms += MS_PER_FRAME;
|
||||||
|
self.frames += 1;
|
||||||
|
let ms = self.ms;
|
||||||
|
let events = self.adapter.sample(&mut self.gb, ms);
|
||||||
|
let here = (self.byte(ram::wCurMap), self.byte(ram::wCurMapTileset));
|
||||||
|
self.payout_maps.extend(events.iter().map(|_| here));
|
||||||
|
self.payouts.extend(events);
|
||||||
|
assert!(self.frames < 200_000, "the walker is lost: {}", self.whereabouts());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn whereabouts(&mut self) -> String {
|
||||||
|
format!(
|
||||||
|
"map {:#04x} at ({}, {}), battle {}, font {}",
|
||||||
|
self.byte(ram::wCurMap),
|
||||||
|
self.byte(ram::wXCoord),
|
||||||
|
self.byte(ram::wYCoord),
|
||||||
|
self.byte(ram::wIsInBattle),
|
||||||
|
self.byte(ram::wFontLoaded)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map(&mut self) -> u8 {
|
||||||
|
self.byte(ram::wCurMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at(&mut self) -> (u8, u8) {
|
||||||
|
(self.byte(ram::wXCoord), self.byte(ram::wYCoord))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whatever is on screen that is not the fly's to walk through: a battle (A through it) or a
|
||||||
|
/// text box (B through it). Returns once the overworld is controllable again.
|
||||||
|
fn settle(&mut self) {
|
||||||
|
for tick in 0..20_000u32 {
|
||||||
|
let in_battle = self.byte(ram::wIsInBattle) != 0;
|
||||||
|
let open = self.byte(ram::wFontLoaded) & 1 != 0;
|
||||||
|
if !in_battle && !open && state::controllable(&mut self.gb) {
|
||||||
|
if self.byte(ram::wWalkCounter) == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.frame(buttons::NONE);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let press = if in_battle { buttons::A } else { buttons::B };
|
||||||
|
self.frame(if tick % 8 < 3 { press } else { buttons::NONE });
|
||||||
|
}
|
||||||
|
panic!("the screen never settled: {}", self.whereabouts());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Press `facing` until the player has moved one tile, the map has changed, or it is plain
|
||||||
|
/// that the step is refused (which turns the player to face that way).
|
||||||
|
fn step(&mut self, facing: Facing) {
|
||||||
|
let (map, from) = (self.map(), self.at());
|
||||||
|
for _ in 0..48 {
|
||||||
|
self.frame(mask(facing));
|
||||||
|
if self.map() != map || self.at() != from {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if self.byte(ram::wIsInBattle) != 0 || self.byte(ram::wFontLoaded) & 1 != 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.frame(buttons::NONE);
|
||||||
|
self.settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Breadth-first over the decoded map, people excluded, from where the player stands to the
|
||||||
|
/// nearest tile `goal` accepts; the first step of that route, or `None`.
|
||||||
|
fn route(&mut self, goal: &dyn Fn(u8, u8) -> bool) -> Option<Facing> {
|
||||||
|
let grid = state::map_grid(&mut self.gb).ok()?;
|
||||||
|
let people: Vec<(u8, u8)> =
|
||||||
|
state::npcs(&mut self.gb).iter().map(|npc| (npc.x, npc.y)).collect();
|
||||||
|
let (width, height) = (grid.width(), grid.height());
|
||||||
|
let start = self.at();
|
||||||
|
let mut first: Vec<Option<Facing>> = vec![None; usize::from(width) * usize::from(height)];
|
||||||
|
let mut seen = vec![false; first.len()];
|
||||||
|
let index = |x: u8, y: u8| usize::from(y) * usize::from(width) + usize::from(x);
|
||||||
|
let mut queue = VecDeque::from([start]);
|
||||||
|
seen[index(start.0, start.1)] = true;
|
||||||
|
while let Some((x, y)) = queue.pop_front() {
|
||||||
|
if (x, y) != start && goal(x, y) {
|
||||||
|
return first[index(x, y)];
|
||||||
|
}
|
||||||
|
for facing in FACINGS {
|
||||||
|
if grid.walled(x, y, facing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (dx, dy) = delta(facing);
|
||||||
|
let (Ok(nx), Ok(ny)) =
|
||||||
|
(u8::try_from(i16::from(x) + dx), u8::try_from(i16::from(y) + dy))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if nx >= width || ny >= height || seen[index(nx, ny)] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if grid.walkable(nx, ny) != Walkable::Yes || people.contains(&(nx, ny)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen[index(nx, ny)] = true;
|
||||||
|
first[index(nx, ny)] = if (x, y) == start { Some(facing) } else { first[index(x, y)] };
|
||||||
|
queue.push_back((nx, ny));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk until standing on a tile `goal` accepts, on this map.
|
||||||
|
fn walk_to(&mut self, goal: &dyn Fn(u8, u8) -> bool) {
|
||||||
|
let map = self.map();
|
||||||
|
for _ in 0..400 {
|
||||||
|
self.settle();
|
||||||
|
assert_eq!(self.map(), map, "the walk left the map: {}", self.whereabouts());
|
||||||
|
let (x, y) = self.at();
|
||||||
|
if goal(x, y) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(facing) = self.route(goal) else {
|
||||||
|
// A grid refused on this frame, or a person in the way: let a frame go by.
|
||||||
|
self.frame(buttons::NONE);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
self.step(facing);
|
||||||
|
}
|
||||||
|
let grid = state::map_grid(&mut self.gb);
|
||||||
|
let detail = match &grid {
|
||||||
|
Ok(grid) => {
|
||||||
|
let (x, y) = self.at();
|
||||||
|
let mut rows = String::new();
|
||||||
|
for ty in 0..grid.height().min(24) {
|
||||||
|
for tx in 0..grid.width() {
|
||||||
|
rows.push(if (tx, ty) == (x, y) {
|
||||||
|
'@'
|
||||||
|
} else {
|
||||||
|
match grid.walkable(tx, ty) {
|
||||||
|
Walkable::Yes => '.',
|
||||||
|
Walkable::No => '#',
|
||||||
|
Walkable::Unknown => '?',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rows.push('\n');
|
||||||
|
}
|
||||||
|
format!("reachable {}\n{rows}", grid.reachable_from(x, y))
|
||||||
|
}
|
||||||
|
Err(refusal) => format!("grid refused: {}", refusal.label()),
|
||||||
|
};
|
||||||
|
panic!("never reached the goal: {}; {detail}", self.whereabouts());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk to `(x, y)` and keep pressing `out` until the map changes.
|
||||||
|
fn leave_by(&mut self, x: u8, y: u8, out: Facing) {
|
||||||
|
let map = self.map();
|
||||||
|
self.walk_to(&|tx, ty| (tx, ty) == (x, y));
|
||||||
|
for _ in 0..8 {
|
||||||
|
self.step(out);
|
||||||
|
if self.map() != map {
|
||||||
|
// The warp's fade and the new map's first frames.
|
||||||
|
for _ in 0..60 {
|
||||||
|
self.frame(buttons::NONE);
|
||||||
|
}
|
||||||
|
self.settle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("pressing {out:?} at ({x}, {y}) never left the map: {}", self.whereabouts());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stand beside `(x, y)`, face it, press A once and let the conversation run to its end.
|
||||||
|
/// Returns the payouts the conversation produced.
|
||||||
|
fn press_a_at(&mut self, x: u8, y: u8) -> Vec<RewardEvent> {
|
||||||
|
self.walk_to(&|tx, ty| tx.abs_diff(x) + ty.abs_diff(y) == 1);
|
||||||
|
let (px, py) = self.at();
|
||||||
|
let facing = FACINGS
|
||||||
|
.into_iter()
|
||||||
|
.find(|facing| {
|
||||||
|
let (dx, dy) = delta(*facing);
|
||||||
|
i16::from(px) + dx == i16::from(x) && i16::from(py) + dy == i16::from(y)
|
||||||
|
})
|
||||||
|
.expect("a neighbour faces the target one way");
|
||||||
|
// Turn in place: the step is refused because the thing is in the way.
|
||||||
|
self.step(facing);
|
||||||
|
assert_eq!(self.at(), (px, py), "turning must not move the player");
|
||||||
|
self.press_a_here()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Press A where the player stands and faces, and let whatever it opens run to its end.
|
||||||
|
fn press_a_here(&mut self) -> Vec<RewardEvent> {
|
||||||
|
for _ in 0..4 {
|
||||||
|
self.frame(buttons::NONE);
|
||||||
|
}
|
||||||
|
let before = self.payouts.len();
|
||||||
|
for _ in 0..6 {
|
||||||
|
self.frame(buttons::A);
|
||||||
|
}
|
||||||
|
self.frame(buttons::NONE);
|
||||||
|
self.settle();
|
||||||
|
for _ in 0..8 {
|
||||||
|
self.frame(buttons::NONE);
|
||||||
|
}
|
||||||
|
self.payouts[before..].to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn of_kind(&self, kind: &str) -> usize {
|
||||||
|
self.payouts.iter().filter(|event| event.kind == kind).count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kinds(events: &[RewardEvent]) -> Vec<&'static str> {
|
||||||
|
events.iter().map(|event| event.kind).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_conversation_indoors_and_an_item_ball_each_pay_exactly_once_on_the_cartridge() {
|
||||||
|
let Some(rom) = rom() else {
|
||||||
|
eprintln!("skipped: FLY_ROM is not set");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(checkpoint) = checkpoint() else {
|
||||||
|
eprintln!("skipped: no FLY_ENGAGE_CHECKPOINT");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut run = Run::resume(&rom, &checkpoint);
|
||||||
|
if run.map() != PEWTER_CITY {
|
||||||
|
eprintln!("skipped: the checkpoint is on map {:#04x}, not Pewter City", run.map());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let before = run.adapter.progress().counts;
|
||||||
|
|
||||||
|
// The restore's first sample: the v6 ledger holds no item keys, so this is the seed.
|
||||||
|
run.settle();
|
||||||
|
assert!(run.payouts.is_empty(), "the migration pays nothing: {:?}", run.payouts);
|
||||||
|
|
||||||
|
// Pewter City's south edge, onto Route 2.
|
||||||
|
let height = run.byte(ram::wCurMapHeight) * 2;
|
||||||
|
run.walk_to(&|_, y| y == height - 1);
|
||||||
|
let (x, y) = run.at();
|
||||||
|
run.leave_by(x, y, Facing::Down);
|
||||||
|
assert_eq!(run.map(), ROUTE_2, "{}", run.whereabouts());
|
||||||
|
|
||||||
|
// Route 2's door into the forest's north gate, `warp_event 3, 11`: the gate is south of
|
||||||
|
// the city, so its door is entered heading south, from the tile above it.
|
||||||
|
run.leave_by(3, 10, Facing::Down);
|
||||||
|
assert_eq!(run.map(), NORTH_GATE, "{}", run.whereabouts());
|
||||||
|
let tileset = run.byte(ram::wCurMapTileset);
|
||||||
|
assert!(engage::indoor(tileset), "the gate is a building (tileset {tileset})");
|
||||||
|
|
||||||
|
// The old man at (2, 5): one conversation, one payout, when the box closes.
|
||||||
|
let first = run.press_a_at(2, 5);
|
||||||
|
eprintln!("gate, first conversation: {first:?}");
|
||||||
|
let talks: Vec<&RewardEvent> =
|
||||||
|
first.iter().filter(|event| event.kind == catalog::kind::TALK).collect();
|
||||||
|
assert_eq!(talks.len(), 1, "one conversation, one payout: {first:?}");
|
||||||
|
assert_eq!(talks[0].value, 0.10);
|
||||||
|
assert!(talks[0].label.starts_with("TALKED TO #"), "{}", talks[0].label);
|
||||||
|
// The same man again, and again: not a farm.
|
||||||
|
for _ in 0..2 {
|
||||||
|
let again = run.press_a_at(2, 5);
|
||||||
|
assert!(
|
||||||
|
!again.iter().any(|event| event.kind == catalog::kind::TALK),
|
||||||
|
"a second conversation with the same person pays nothing: {again:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// On through the gate, `warp_event 4, 7`, into the forest.
|
||||||
|
run.leave_by(4, 7, Facing::Down);
|
||||||
|
assert_eq!(run.map(), VIRIDIAN_FOREST, "{}", run.whereabouts());
|
||||||
|
assert!(!engage::indoor(run.byte(ram::wCurMapTileset)), "the forest is not a building");
|
||||||
|
|
||||||
|
// The Antidote ball at (25, 11). Save the game just before, to take it twice.
|
||||||
|
run.walk_to(&|tx, ty| tx.abs_diff(25) + ty.abs_diff(11) == 1);
|
||||||
|
let slot = run.gb.export_state().expect("an emulator state");
|
||||||
|
let first = run.press_a_at(25, 11);
|
||||||
|
eprintln!("forest, the ball: {first:?}");
|
||||||
|
let items: Vec<&RewardEvent> =
|
||||||
|
first.iter().filter(|event| event.kind == catalog::kind::ITEM).collect();
|
||||||
|
assert_eq!(items.len(), 1, "one pickup, one payout: {first:?}");
|
||||||
|
assert_eq!(items[0].value, 0.15);
|
||||||
|
assert_eq!(items[0].label, ANTIDOTE);
|
||||||
|
assert!(
|
||||||
|
!first.iter().any(|event| event.kind == catalog::kind::TALK),
|
||||||
|
"a ball is not a conversation, and the forest is not indoors"
|
||||||
|
);
|
||||||
|
// The ball is gone: pressing A at the empty tile pays nothing.
|
||||||
|
let empty = run.press_a_here();
|
||||||
|
assert!(!empty.iter().any(|event| event.kind == catalog::kind::ITEM), "{empty:?}");
|
||||||
|
|
||||||
|
// A rollback to the slot with the ball still there, the way the ratchet restores one, and
|
||||||
|
// the fly takes it again: the same item, and it does not pay twice.
|
||||||
|
run.gb.import_state(&slot).expect("the slot restores");
|
||||||
|
run.adapter.clear_transient();
|
||||||
|
run.frame(buttons::NONE);
|
||||||
|
run.settle();
|
||||||
|
let again = run.press_a_at(25, 11);
|
||||||
|
eprintln!("forest, the ball after a rollback: {again:?}");
|
||||||
|
assert!(
|
||||||
|
!again.iter().any(|event| event.kind == catalog::kind::ITEM),
|
||||||
|
"once per item for the run: {again:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let after = run.adapter.progress().counts;
|
||||||
|
eprintln!(
|
||||||
|
"{:.1} brain minutes; talk {} -> {}, item {} -> {}; every payout (kind, (map, tileset)): {:?}",
|
||||||
|
run.ms / 60_000.0,
|
||||||
|
before[catalog::kind::TALK],
|
||||||
|
after[catalog::kind::TALK],
|
||||||
|
before[catalog::kind::ITEM],
|
||||||
|
after[catalog::kind::ITEM],
|
||||||
|
kinds(&run.payouts).iter().zip(&run.payout_maps).collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
// The gate's exits were stood beside and walked through, and none of them paid: an indoor
|
||||||
|
// exit pays nothing. What *does* show up under the gate's id is Route 2's door, the
|
||||||
|
// outdoor exit the fly took: for the thirty-odd frames of `PlayMapChangeSound` the cartridge
|
||||||
|
// has already written the new `wCurMap` while the header, the warp table and the tileset
|
||||||
|
// are still Route 2's, and the rule reads that frame as what it is -- an outdoor exit.
|
||||||
|
for (event, (map, tileset)) in run.payouts.iter().zip(&run.payout_maps) {
|
||||||
|
assert!(
|
||||||
|
!(event.kind == catalog::kind::BOUNDARY && engage::indoor(*tileset)),
|
||||||
|
"a boundary payout on an indoor map's own header (map {map}): {event:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(run.of_kind(catalog::kind::TALK), 1);
|
||||||
|
assert_eq!(run.of_kind(catalog::kind::ITEM), 1);
|
||||||
|
assert_eq!(after[catalog::kind::TALK], before[catalog::kind::TALK] + 1);
|
||||||
|
assert_eq!(after[catalog::kind::ITEM], before[catalog::kind::ITEM] + 1);
|
||||||
|
}
|
||||||
|
|
@ -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:?}");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue