From 23a4d7379b245883fc5db9b697f05584e571dd0a Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 22 Sep 2026 17:30:00 +0000 Subject: [PATCH] rewards: a catch reward, adapter v6 with a v5 migration, and a rung restart The operator's decision of 2026-09-22: pay the fly for keeping a wild Pokemon, bump the adapter properly, and restart the live run from an early checkpoint rather than from scratch. The rule. `catch` is the catalog's ninth kind, appended so the key order `counts` serializes in does not move. 0.30 for a species this run had never owned, 0.10 for a repeat, three payouts per species for the lifetime of the ledger; the `species` rule is untouched, so a first catch of a new species pays 0.80 across two kinds. The catch is read from `wCapturedMonSpecies` ($d11c), whose comment in ram/wram.asm is "0 if no mon was captured": ItemUseBall zeroes it before every throw and writes wEnemyMonSpecies into it only on the branch that keeps the Pokemon, and UseBagItem's `.returnAfterCapturingMon` zeroes it again and sets wBattleResult to 2 -- a value written on exactly two paths in the game, that one and a link battle whose opponent ran. Both are required, so a byte read out of a half-initialised battle cannot pay. Not wPartyCount: a catch with a full party raises wBoxCount instead, and wPartyCount also rises for a gift, a trade and a PC withdrawal. "Never owned this run" is the `species` payout inside the same battle, because nothing else can set a Pokedex bit during one. It is not read off the captured species byte: that is the cartridge's internal index while the owned bitset is by Pokedex number, and nothing in WRAM converts between them. The address was resolved by tools/resolve_wram.py, not written by hand. The tool needed NUM_TMS and NUM_HMS, which the decomp defines through its `const` enumeration, so it now counts them from the file's own add_tm/add_hm definitions and cross-checks NUM_TMS against the literal the same file declares. The feed's kinds are closed, so `catch` publishes on `wildwin` and nothing in packages/feed or apps/stage changed. Deliberately not `pokedex`: the `species` rule already pays for the bit the same catch sets. The stage's ticker copy is keyed on the feed kind, so a catch row reads "wild win" -- stated in docs/rewards-learning.md rather than left to be discovered. v5 -> v6. STATE_VERSION stays 4: the rule adds one counter, `catchCounts`, and changes nothing else, so a v5 state restores with it empty. That migration is opt-in and needs all three of: the adapter segment being the only difference between the two compatibility strings, the running adapter listing the checkpoint's adapter in `migrates_from()`, and the deploy naming it in FLY_ACCEPT_ADAPTERS. flysim applies the rule at restore and 05-deploy's gate applies the same rule before it flips the symlink, writing the variable into fly.env so the two cannot disagree. The restart. infra/bin/fly-reset-to-milestone archives both stores to a dated directory, rewrites milestone-.checkpoint with the ratchet's attempts and recoveries at zero, installs it as the newest generation of both stores, clears the milestone archives above N and the event log, and prints what it did. It refuses while flysim is running and refuses a rung the run never reached. The envelope work is in flysim::reset (`flysim --reset-to-milestone N`); the shell script is the operator's wrapper. Tests: catalog values and order; a synthetic WRAM trace of a catch (new, repeat, cap, already-owned species, trainer/Safari/old-man/missed-ball negatives, rollback replay); a v5 state restoring with the counter at zero; a v5 checkpoint fixture accepted with the opt-in and refused without it; the reset tool against copies of a state dir in temp directories; and a ROM-gated catch from a rung-9 forest checkpoint, driven by the shipping THROW BALL macro. The compatibility string differs from main's in exactly one segment, checked by splitting both on `/`: pokered-unique8-v5 -> pokered-unique8-v6. --- docs/design/flysim.md | 44 ++ docs/design/macros-wram.md | 14 + docs/rewards-learning.md | 65 ++- infra/05-deploy.sh | 63 ++- infra/bin/fly-reset-to-milestone | 77 +++ infra/docs/runbook.md | 53 +++ infra/env/example.env | 14 + .../flysim/crates/flybrain-gb/src/adapter.rs | 20 +- .../crates/flybrain-gb/src/compatibility.rs | 157 +++++- .../flybrain-gb/src/pokemon_red/catalog.rs | 54 ++- .../crates/flybrain-gb/src/pokemon_red/mod.rs | 196 +++++++- .../flybrain-gb/src/pokemon_red/symbols.rs | 1 + .../flybrain-gb/src/pokemon_red/tests.rs | 165 ++++++- services/flysim/crates/flysim/src/lib.rs | 1 + services/flysim/crates/flysim/src/main.rs | 20 + services/flysim/crates/flysim/src/reset.rs | 450 ++++++++++++++++++ services/flysim/crates/flysim/src/simloop.rs | 32 +- services/flysim/crates/flysim/src/snapshot.rs | 14 +- .../crates/flysim/tests/compat_migration.rs | 224 +++++++++ .../flysim/crates/flysim/tests/rom_catch.rs | 257 ++++++++++ services/flysim/tools/gen_symbols.py | 9 + services/flysim/tools/resolve_wram.py | 49 +- 22 files changed, 1947 insertions(+), 32 deletions(-) create mode 100755 infra/bin/fly-reset-to-milestone create mode 100644 services/flysim/crates/flysim/src/reset.rs create mode 100644 services/flysim/crates/flysim/tests/compat_migration.rs create mode 100644 services/flysim/crates/flysim/tests/rom_catch.rs diff --git a/docs/design/flysim.md b/docs/design/flysim.md index abdddab..5e7f0b5 100644 --- a/docs/design/flysim.md +++ b/docs/design/flysim.md @@ -355,6 +355,50 @@ on-screen ticker cannot disagree with what the sim did. against the prototype's WASM size and diffs a known save. If they match, prototype checkpoints import and the segment records the shared tag; if not, milestone saves must be re-earned and that is a stated M3 finding. +- **Restoring across an adapter version** (2026-09-22). The compatibility string is compared + whole, so bumping the reward adapter refuses every checkpoint the previous one wrote -- which is + the right default and was, until now, the only behaviour. It is the wrong default for a change + that only *adds* a rule: `pokered-unique8-v6` adds the catch reward and one counter, + `catchCounts`, and means the same thing as `v5` for every other field, so a `v5` run is + resumable and throwing it away would be a choice nobody made deliberately. + + So there is one narrow, opt-in migration, `flybrain_gb::compatibility::decide`, and it requires + **all three** of: + + 1. the two compatibility strings differ in the adapter segment (segment 1) and **nowhere else**. + A dataset, kernel, plasticity, emulator-revision, symbol-provenance or state-format + difference is still a refusal: none of those has a migration, and a fly restored across one + is a different fly; + 2. the running adapter's `migrates_from()` lists the checkpoint's adapter, so the code that will + read that state says out loud that it can. Pokémon Red's list is `["pokered-unique8-v5"]` and + nothing else -- `v4` is excluded because its ledger holds no `boundary:` keys and resuming it + would pay a second time for every exit already found, and `v3` because its stored rank is a + rung on a different ladder; + 3. the deploy names the same adapter id in **`FLY_ACCEPT_ADAPTERS`** (comma- or + space-separated). Unset or empty migrates nothing, which is what every deploy before this one + did. + + Condition 2 without 3 would make the migration silent; condition 3 without 2 would let an + operator wave through a pair nobody wrote a migration for. `infra/05-deploy.sh`'s compatibility + gate applies the same rule before it flips the `current` symlink, and writes the variable into + `/etc/fly/fly.env` so flysim applies it at restore -- the two must agree, or a deploy would pass + a gate that flysim then fails, which is the black stream the gate exists to prevent. The + migration itself is `PokemonRedReward::import_state` doing what it already did: `catchCounts` is + 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. + +- **Restarting a run from an earlier rung** (2026-09-22). `FLY_RESET_STATE=1` throws the run away; + `infra/bin/fly-reset-to-milestone ` keeps it and rewinds it. It archives both stores to a + dated directory, rewrites `milestone-.checkpoint` with the ratchet's `attempts` and + `recoveries` at zero (so the restarted run does not begin with its recovery budget already + spent), installs it as the newest generation of the hot and durable stores, removes the + milestone archives above N, and clears the event log -- whose id sequence the restored + checkpoint's `lastEventId` rewinds. `best` is not touched: the archive's own `best` is the rung + it was taken at, and the rank the stream shows is recomputed by the adapter from the restored + game state. The implementation is `flysim::reset` (`flysim --reset-to-milestone N`) rather than + the shell script, because two of those steps are inside the envelope. The sequence around it is + in `infra/docs/runbook.md`. + - **A running macro is not checkpointed** (2026-09-16, `docs/design/macros.md`). Palette mode's state — the scene, the palette, the running macro, its plan and its frame count — is transient, like the readout's blocked-direction cooldown and for the same reason: a restore that resumed a diff --git a/docs/design/macros-wram.md b/docs/design/macros-wram.md index 0c721a3..a3253de 100644 --- a/docs/design/macros-wram.md +++ b/docs/design/macros-wram.md @@ -118,6 +118,20 @@ Addresses are at the pinned commit. "Verified" is one of: | which slot is out | `wPlayerMonNumber` | `$cc2f` | 0-based party slot | ROM, trace | | the enemy | `wEnemyMonSpecies`, `wEnemyMonHP`, `wEnemyMonLevel`, `wEnemyMonMaxHP` | `$cfe5`, `$cfe6`, `$cff3`, `$cff4` | HP big-endian. Not written on the frame a battle starts — the reward adapter's own comment says the same — so the enemy is `None` for the first few hundred frames of a battle. | ROM (the rival's Squirtle, level 5, 20/20, and `None` on the first frame), trace | | how many moves | `wNumMovesMinusOne` | `$cd6c` | the move count minus one, valid in a battle | trace | +| **a ball kept this one** | `wCapturedMonSpecies` | `$d11c` | **new 2026-09-22** (the catch reward, `docs/rewards-learning.md`). `ram/wram.asm`'s own comment is "0 if no mon was captured". `ItemUseBall` zeroes it before every throw (`.canUseBall`) and writes `wEnemyMonSpecies` into it only on the branch that keeps the Pokémon; `UseBagItem`'s `.returnAfterCapturingMon` zeroes it again and sets `wBattleResult` to 2 on the way out of the battle. It is therefore non-zero for the hundreds of frames the catch's text and Pokédex screen take, and zero everywhere else. The value is the **internal** species index, like `wEnemyMonSpecies` and unlike `wPokedexOwned`'s bit index. Address resolved by `services/flysim/tools/resolve_wram.py`, bracketed by `wFontLoaded` and `wForcePlayerToChooseMon`. | survey (`tests/rom_catch.rs`: a real wild battle from a rung-9 checkpoint, balls thrown by the `THROW BALL` macro, the byte read out of the running game), trace (`pokemon_red/tests.rs`) | + +`wBattleResult` (`$cf0b`) is the second half of that row and is worth its own sentence: it is 0 +for a win, 1 for a loss, and 2 on exactly two paths in the whole game -- `.returnAfterCapturingMon` +and a *link* battle whose opponent ran (`engine/battle/core.asm`), which this cartridge never has. +So "the captured-species byte was non-zero during the battle **and** the result is 2" is a catch +and nothing else. `InitBattleVariables`, `ResetStatusAndHalveMoneyOnBlackout` and +`HandleFlyWarpOrDungeonWarp` all clear it, so a stale 2 cannot survive into the next battle. + +Not used for the catch, and why: `wPartyCount` (`$d163`) rises on a catch **only** when the party +has room -- a full party sends the Pokémon to `wBoxCount` instead -- and it also rises for a gift, +a trade and a Pokémon withdrawn from the PC. Reading a catch off it would need a second rule to +tell those apart. The cartridge's own flag needs none, which is why the row above is the one the +adapter reads. ### Battle menu and cursor, own turn against forced switch diff --git a/docs/rewards-learning.md b/docs/rewards-learning.md index e3130ee..1975629 100644 --- a/docs/rewards-learning.md +++ b/docs/rewards-learning.md @@ -1,6 +1,6 @@ # Rewards and learning -The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v5`. The code of record is +The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v6`. The code of record is `services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the 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 @@ -24,6 +24,7 @@ change what the fly can do. | `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` | | `badge` | `badge` | +3 | 400 ms | Each newly set badge bit | | `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger | +| `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 | 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 @@ -33,6 +34,54 @@ The feed-kind column is `RewardKind::from_adapter` in `services/flysim/crates/fl `docs/feed-protocol.md` publishes seven counters, and an adapter kind that has no counter of its own shares the nearest one. It still reaches the page as an event with its own label. +Two consequences of that sharing are worth stating rather than discovering. `catch` publishes on +`wildwin` because a catch is a wild battle the fly won by keeping the Pokémon, and *not* on +`pokedex` because the `species` rule already pays for the Pokédex bit the same catch sets -- +counting it twice would be the dishonest option. And the stage's ticker copy is keyed on the feed +kind, not on the catalog kind (`apps/stage/src/games/pokemon-red.ts`), so the row for a catch +currently reads "wild win". The event's own label, `CAUGHT #`, is what reaches the event +log, `/status` and the checkpoint. Changing the ticker copy means opening the feed's closed kind +set, which this rule deliberately did not do. + +## Catch rewards + +The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not only for +knocking one out. The rule is one kind with two payouts, the way `boundary` is. + +**How a catch is read.** From `wCapturedMonSpecies` (`$d11c`), whose comment in `ram/wram.asm` at +the pinned commit is "0 if no mon was captured". `ItemUseBall` zeroes it before every throw +(`.canUseBall`) and writes `wEnemyMonSpecies` into it only on the branch that keeps the Pokémon; +`UseBagItem`'s `.returnAfterCapturingMon` zeroes it again and sets `wBattleResult` to 2 on the way +out of the battle. `wBattleResult` is 2 on exactly two paths in the whole game -- that one, and a +link battle whose opponent ran -- so requiring both the species and the result means a byte read +out of a half-initialised battle cannot pay. The adapter records the species during the battle and +pays on the way out, where the wild-KO payout already lives. + +Not from `wPartyCount`. A catch with a full party raises `wBoxCount` instead, and `wPartyCount` +also rises for a gift, a trade and a Pokémon taken out of the PC, so it would need a second rule +to mean anything. The cartridge's own flag needs none. + +**What counts as a new species.** The `species` payout inside the same battle. Nothing but a catch +can set a `wPokedexOwned` bit during a wild battle, so a `species` payout between the battle +starting and the ball keeping the Pokémon *is* that Pokémon being new to the run. It is read this +way rather than off `wCapturedMonSpecies` because that byte is the cartridge's **internal** species +index while the owned bitset is by **Pokédex number**, and nothing in WRAM converts between the two +(`docs/design/macros-wram.md` section 2, "species numbering"). A battle restored from a checkpoint +written before this rule existed carries no "species payouts when it started", which reads as +"cannot tell" and pays the repeat amount: the conservative half, and at most 0.20 once. + +**The budget.** Three payouts per species for the lifetime of the ledger, the same cap and the +same reason as the wild-KO rule's three: a species the fly can find over and over is a farm, and +three is enough for the behaviour to be learned. A rollback blocks every species already paid, +exactly as it blocks every wild-KO key already paid, so the same catch cannot be replayed for +reward. A Safari Zone or old-man battle pays nothing, because the whole sample is dropped a step +earlier with a visible mode; a trainer battle pays nothing, because balls cannot be thrown in one. + +**The scale.** 0.30 on its own is below a new Pokédex entry (0.50), below a story flag (1.0) and +well below a badge (3.0). A catch of a new species pays 0.80 across two kinds, which sits between +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. + ## Gates Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other @@ -128,7 +177,19 @@ body picks the macro; the descending neurons press the buttons.** ## Honesty -The catalog now includes exits. That is worth saying plainly on the honesty panel, because paying +The catalog now includes catches. The honesty panel's copy is not data-driven from the catalog -- +`apps/stage/src/lib/schedule.ts`'s rotating card is four written lines and lists no kinds -- so +there was nothing to regenerate and the copy is unchanged. The sentences below are where the +argument lives. + +Paying for a catch does not move the fly: the ball is thrown by a macro the mushroom body chose +among the ones the battle scene put on the pad, and the payout is read out of WRAM after the +frame. What it does do is make one of the palette's existing macros worth choosing, which is the +same kind of pressure every other rule applies. The cap is what keeps it from becoming a farm: a +run that finds one patch of grass and throws balls at the same species all night earns 0.50 from +it and then nothing. + +The catalog also includes exits. That is worth saying plainly on the honesty panel, because paying for a door is closer to telling the fly where to go than paying for a badge is: - **still no button path.** Nothing in the adapter chooses or biases a button. The reward is read diff --git a/infra/05-deploy.sh b/infra/05-deploy.sh index 892bf3e..ccc58c6 100755 --- a/infra/05-deploy.sh +++ b/infra/05-deploy.sh @@ -186,6 +186,36 @@ else log "05-deploy: CPUSET unset — heavy in-container steps run unpinned (no partition configured)" fi +# Whether the only difference between two compatibility strings is the adapter +# segment, and FLY_ACCEPT_ADAPTERS names the adapter the live checkpoints carry. +# +# The bash half of flybrain_gb::compatibility::decide, which is what flysim +# itself applies at restore. Both have to agree: a gate that let a deploy +# through and a flysim that then refused every checkpoint would be the black +# stream this whole section exists to prevent. The string is +# {kernel}/{adapter}/{fingerprint}/{plasticity}/binjgb:{rev}/pokered:{commit}/statefmt:{id}, +# so the adapter is segment 1 and nothing else may move. +adapter_migration_accepted() { + local live="$1" new="$2" accepted="$3" + local -a live_parts new_parts + IFS='/' read -r -a live_parts <<< "$live" + IFS='/' read -r -a new_parts <<< "$new" + [ "${#live_parts[@]}" -eq "${#new_parts[@]}" ] || return 1 + local i differing=0 index=-1 + for ((i = 0; i < ${#live_parts[@]}; i++)); do + if [ "${live_parts[$i]}" != "${new_parts[$i]}" ]; then + differing=$((differing + 1)) + index=$i + fi + done + [ "$differing" -eq 1 ] && [ "$index" -eq 1 ] || return 1 + local entry + for entry in ${accepted//,/ }; do + [ "$entry" = "${live_parts[1]}" ] && return 0 + done + return 1 +} + # cpu_pin CMD [ARGS...] — run CMD inside the container on the page cpus. # Falls through to a plain ct_exec when no partition is configured, so this is # a no-op on an unpartitioned container rather than a new failure mode (a @@ -261,6 +291,16 @@ if [ -n "$RELEASE_TARBALL" ]; then # checkpoints (kept, never deleted) and clears the tmpfs hot ring, so the # new build warms up fresh. Everything learned so far is thrown away, which # is why it is not the default. + # + # FLY_ACCEPT_ADAPTERS is the *other* override, and the opposite one: it keeps + # the run. It names adapter version strings whose checkpoints the new build + # may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 for the deploy + # that adds the catch reward. It only applies when the adapter segment is the + # ONLY difference between the two strings and the new build's adapter says it + # can read that one; a dataset, kernel, emulator or state-format change is + # still a refusal, because none of those has a migration. The same variable is + # written into /etc/fly/fly.env below, so flysim applies the same rule at + # restore that this gate applied at deploy. # ----------------------------------------------------------------------- state_dir="${FLY_STATE_DIR:-/srv/fly/state}" hot_dir="${FLY_STATE_HOT_DIR:-/run/fly/state}" @@ -286,6 +326,11 @@ if [ -n "$RELEASE_TARBALL" ]; then log "05-deploy: no decodable checkpoint in ${state_dir} — nothing to compare, continuing" elif [ "$new_compat" = "$live_compat" ]; then log "05-deploy: checkpoint compatibility matches the live state, the new build will restore it" + elif [ -n "${FLY_ACCEPT_ADAPTERS:-}" ] \ + && adapter_migration_accepted "$live_compat" "$new_compat" "$FLY_ACCEPT_ADAPTERS"; then + log "05-deploy: FLY_ACCEPT_ADAPTERS=${FLY_ACCEPT_ADAPTERS} — the adapter version is the only difference, and it is named; the run is KEPT and migrated" + log "05-deploy: live: $live_compat" + log "05-deploy: new: $new_compat" elif [ "${FLY_RESET_STATE:-0}" = 1 ]; then archive="${state_dir}.$(date -u +%Y%m%d%H%M%S)" log "05-deploy: FLY_RESET_STATE=1 — compatibility CHANGED, archiving the durable state to ${archive} and clearing the hot ring" @@ -299,8 +344,12 @@ if [ -n "$RELEASE_TARBALL" ]; then die "05-deploy: REFUSING to deploy release ${version}: its checkpoint compatibility string does not match the live state in ${state_dir}, so flysim would refuse every checkpoint there and then refuse to start at all — a black stream. live state: ${live_compat} new build: ${new_compat} -The difference is usually an adapter/ladder or dataset version bump. Two ways forward: +The difference is usually an adapter/ladder or dataset version bump. Three ways forward: * deploy a build whose string matches (check out the commit the running release was built from), or + * if the ADAPTER VERSION is the only segment that differs and the new build documents a + migration from the old one, re-run with FLY_ACCEPT_ADAPTERS set to the adapter id in the live + string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5). The run is kept; flysim applies the same + rule at restore. See docs/design/flysim.md, \"Restoring across an adapter version\", or * accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which archives ${state_dir}'s checkpoints to ${state_dir}. (kept, not deleted) and clears ${hot_dir} so the new build warms up fresh. @@ -456,6 +505,16 @@ trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT if [[ -n "${FLY_MACRO_BLOCKED_MINUTES:-}" ]]; then echo "FLY_MACRO_BLOCKED_MINUTES=${FLY_MACRO_BLOCKED_MINUTES}" fi + # Adapter versions whose checkpoints this build may migrate + # (flybrain_gb::compatibility, docs/design/flysim.md "Restoring across an + # adapter version"). Only written when it is set, because the safe state is + # absent: an empty or missing variable migrates nothing, which is what every + # deploy before 2026-09-22 did. It stays in fly.env for as long as the + # operator leaves it on the deploy command line, so removing the opt-in is + # one deploy without it. + if [[ -n "${FLY_ACCEPT_ADAPTERS:-}" ]]; then + echo "FLY_ACCEPT_ADAPTERS=${FLY_ACCEPT_ADAPTERS}" + fi # flybridge (services/bridge/src/config.ts). Nothing wrote these before, so # flybridge.service had no EnvironmentFile= at all and the service refused to # start with "CHANNEL is required / BOT_USER is required / GAME_TITLE is @@ -606,7 +665,7 @@ fi # --------------------------------------------------------------------------- log "05-deploy: converging bin/ helpers to /opt/fly/bin" ct_exec "$CTID" -- mkdir -p /opt/fly/bin -for name in fly-watchdog fly-recap fly-retention flypush flystage-launch flycast-launch wait-for-x wait-for-stage wait-for-health; do +for name in fly-watchdog fly-recap fly-retention fly-reset-to-milestone flypush flystage-launch flycast-launch wait-for-x wait-for-stage wait-for-health; do converge_file "$CTID" "$INFRA_DIR/bin/$name" "/opt/fly/bin/$name" 0755 root:root >/dev/null done diff --git a/infra/bin/fly-reset-to-milestone b/infra/bin/fly-reset-to-milestone new file mode 100755 index 0000000..732c343 --- /dev/null +++ b/infra/bin/fly-reset-to-milestone @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# infra/bin/fly-reset-to-milestone — restart the run from an earlier ladder rung, +# instead of from scratch. +# +# The operator's decision of 2026-09-22: "restart the live run from an early +# checkpoint instead of from scratch". 05-deploy's FLY_RESET_STATE=1 cannot do +# that — it archives the durable state and the next start warms up a fresh fly, +# losing everything the brain has learned. This promotes one milestone archive +# (milestone-.checkpoint, written at the first commit at a new best rank and +# never rotated away) to being what both stores restore. +# +# Usage: fly-reset-to-milestone +# Run INSIDE the container, as root, with flysim STOPPED. It refuses +# otherwise, and it refuses a rung this run never reached. +# +# The whole sequence — stop, reset, deploy with the adapter opt-in, start, +# verify the rank — is in infra/docs/runbook.md, "Restart the run from a rung". +# Nothing here is destructive on its own: every file in both stores is copied to +# a dated directory next to the durable one before anything is rewritten. +set -euo pipefail + +: "${FLY_STATE_DIR:=/srv/fly/state}" +: "${FLY_STATE_HOT_DIR:=/run/fly/state}" +: "${FLY_RELEASE_DIR:=/opt/fly/current}" +: "${FLY_SERVICE:=flysim.service}" +: "${FLY_USER:=fly}" + +FLYSIM="${FLY_BIN:-${FLY_RELEASE_DIR}/flysim}" + +log() { echo "fly-reset-to-milestone: $*" >&2; } +die() { log "$*"; exit 1; } + +RANK="${1:-}" +if [ "$#" -ne 1 ] || ! [[ "$RANK" =~ ^[0-9]+$ ]]; then + die "usage: fly-reset-to-milestone (e.g. fly-reset-to-milestone 9)" +fi + +# --- refusals ---------------------------------------------------------------- +# A running flysim owns both stores: it commits a hot checkpoint every few +# seconds and a durable one every few minutes, so a reset underneath it would be +# overwritten within the minute and the tool would have lied. +if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet "$FLY_SERVICE"; then + die "$FLY_SERVICE is running. Stop it first: systemctl stop $FLY_SERVICE" +fi +[ -x "$FLYSIM" ] || die "no flysim binary at $FLYSIM (set FLY_BIN to point at one)" + +milestone="${FLY_STATE_DIR}/milestone-${RANK}.checkpoint" +# The binary refuses this too, and refuses before it copies anything; checking +# here as well is what makes the message name the rungs that do exist. +if [ ! -f "$milestone" ]; then + log "no milestone archive for rung ${RANK}: $milestone does not exist." + log "rungs this run reached:" + ls -1 "${FLY_STATE_DIR}"/milestone-*.checkpoint 2>/dev/null \ + | sed 's|.*/milestone-||; s|\.checkpoint$||' | sort -n | tr '\n' ' ' >&2 || true + echo >&2 + exit 1 +fi + +# --- the reset --------------------------------------------------------------- +log "resetting to rung ${RANK} (durable ${FLY_STATE_DIR}, hot ${FLY_STATE_HOT_DIR})" +FLY_STATE="$FLY_STATE_DIR" FLY_STATE_HOT="$FLY_STATE_HOT_DIR" \ + "$FLYSIM" --reset-to-milestone "$RANK" + +# flysim runs unprivileged; this tool runs as root, so everything it wrote and +# everything it archived has to go back to the service account. +if command -v chown >/dev/null 2>&1 && id "$FLY_USER" >/dev/null 2>&1; then + chown -R "${FLY_USER}:${FLY_USER}" "$FLY_STATE_DIR" "$FLY_STATE_HOT_DIR" 2>/dev/null || true + for dir in "${FLY_STATE_DIR}".reset-*; do + [ -d "$dir" ] && chown -R "${FLY_USER}:${FLY_USER}" "$dir" + done +fi + +log "done. Next, per infra/docs/runbook.md:" +log " 1. deploy the build whose adapter wrote that checkpoint, or deploy the new" +log " one with FLY_ACCEPT_ADAPTERS set to the checkpoint's adapter id" +log " 2. systemctl start $FLY_SERVICE" +log " 3. curl -s localhost:7401/status | grep -o '\"rank\":[0-9]*'" diff --git a/infra/docs/runbook.md b/infra/docs/runbook.md index a07a413..97e4efa 100644 --- a/infra/docs/runbook.md +++ b/infra/docs/runbook.md @@ -231,6 +231,59 @@ auto-reset (`docs/design/flysim.md` section 8: "no automatic fresh start, ever") is deliberate — a silent reset would be indistinguishable from real progress on stream. A deliberate reset means moving `/srv/fly/state` aside by hand. +## Restart the run from a rung + +When the run has to go back to an earlier milestone rather than start over — the operator's +decision of 2026-09-22 was "restart the live run from an early checkpoint instead of from +scratch". `FLY_RESET_STATE=1` is the wrong tool: it archives the durable state and the next start +warms up a fresh fly, losing everything the brain has learned. + +`infra/bin/fly-reset-to-milestone ` promotes `milestone-.checkpoint` to being what both +stores restore, with the ratchet's attempts and recoveries back at zero. It copies every file in +both stores to `/srv/fly/state.reset-` first, so it is reversible by hand. It refuses while +flysim is running, and refuses a rung this run never reached. + +The whole sequence, in order. Claim the container in the host's agent claim log first, like any +other work on it. + +``` +CTID= +N=9 # the rung to restart from + +# 1. what rungs exist at all +pct exec $CTID -- ls -1 /srv/fly/state/milestone-*.checkpoint + +# 2. stop flysim (it owns both stores; a reset underneath it is overwritten within the minute) +pct exec $CTID -- systemctl stop flysim.service + +# 3. the reset. Prints what it did, one line per step. +pct exec $CTID -- /opt/fly/bin/fly-reset-to-milestone $N + +# 4. deploy. Two cases: +# (a) the running release already wrote that checkpoint -> nothing to deploy, skip to 5. +# (b) the new build bumps the ADAPTER VERSION and nothing else -> name the checkpoint's +# adapter so the gate and flysim both migrate instead of refusing: +FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 infra/05-deploy.sh +# The gate logs "the adapter version is the only difference, and it is named; the run is KEPT +# and migrated", and writes FLY_ACCEPT_ADAPTERS into /etc/fly/fly.env so flysim applies the +# same rule at restore. Anything else about the string differing is still a refusal. + +# 5. start +pct exec $CTID -- systemctl start flysim.service + +# 6. verify: the rank is the rung, and the restore came from the generation the tool wrote +pct exec $CTID -- curl -s http://127.0.0.1:7401/status | jq '.milestone.rank, .game.badges, .checkpoint' +pct exec $CTID -- journalctl -u flysim -n 40 --no-pager | grep -E 'restored|migration|compatibility' +``` + +Step 6 is the one that must be read rather than assumed. The rank is recomputed by the adapter +from the restored game state, not taken from the ratchet, so a rank that is *not* N means the +milestone archive was taken somewhere other than where its name says — stop and look before +starting a stream on it. + +To undo: stop flysim, move the contents of `/srv/fly/state.reset-/durable` back into +`/srv/fly/state`, delete the generation the tool wrote, and start again. + ## Restore from the backup host ``` diff --git a/infra/env/example.env b/infra/env/example.env index ddab36f..fc19970 100644 --- a/infra/env/example.env +++ b/infra/env/example.env @@ -317,6 +317,20 @@ FLY_MACRO_MODE=raw # target once more. Unset means the default, 10. # FLY_MACRO_BLOCKED_MINUTES=10 +# --- restoring across an adapter version ------------------------------------ +# Adapter version strings whose checkpoints this build may migrate, comma- or +# space-separated (docs/design/flysim.md, "Restoring across an adapter +# version"). Unset -- the default, and what every deploy before 2026-09-22 did +# -- migrates nothing: a build whose compatibility string differs from the live +# state's is refused by 05-deploy's gate and by flysim at restore. +# +# It applies only when the ADAPTER segment is the only difference between the +# two strings AND the new build's adapter declares a migration from that one. A +# dataset, kernel, plasticity, emulator or state-format difference is still a +# refusal. Set it for the one deploy that needs it and leave it out afterwards; +# 05-deploy writes it into /etc/fly/fly.env only while it is set. +# FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 + # --- push mode -------------------------------------------------------------- # local: flypush.service stays disabled, everything else identical to prod. # twitch: flypush.service is enabled by 07-enable.sh. diff --git a/services/flysim/crates/flybrain-gb/src/adapter.rs b/services/flysim/crates/flybrain-gb/src/adapter.rs index 15d8456..9e24815 100644 --- a/services/flysim/crates/flybrain-gb/src/adapter.rs +++ b/services/flysim/crates/flybrain-gb/src/adapter.rs @@ -56,7 +56,7 @@ impl MemoryReader for &mut dyn MemoryReader { /// One reward payout in one frame. /// /// `kind` is an adapter-owned interned name (Pokémon: `milestone`, -/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`); it is the +/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`); it is the /// key the statistics counters and the on-screen ticker group by. Field names /// serialize exactly as the prototype's `RewardEvent` did, so a checkpoint /// written by either implementation reads in the other. @@ -241,9 +241,21 @@ impl std::error::Error for AdapterError {} /// A game, as the sim loop sees it. pub trait GameAdapter: Send { /// Adapter version string, pinned into the checkpoint compatibility string. - /// Pokémon: `pokered-unique8-v5`. + /// Pokémon: `pokered-unique8-v6`. fn id(&self) -> &'static str; + /// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration + /// this adapter has written down and tested. + /// + /// The default is empty: an adapter migrates from nothing unless it says otherwise, which + /// is the behaviour every adapter had before this existed. It is only half of the gate -- + /// [`crate::compatibility::decide`] also requires the operator to have named the same id in + /// `FLY_ACCEPT_ADAPTERS` for that deploy -- so listing an id here never migrates a live run + /// on its own. + fn migrates_from(&self) -> &'static [&'static str] { + &[] + } + /// Whether semantic rewards are enabled for this cartridge. An adapter that /// says no must still sample without paying anything, so the stream keeps /// running with a visible "rewards off" mode. @@ -470,6 +482,10 @@ mod tests { let platformer = adapter_for_with_rom_pin("platformer", Some(&"a".repeat(64))).unwrap(); assert_ne!(pokemon.id(), platformer.id()); + assert!( + !platformer.migrates_from().contains(&pokemon.id()), + "a migration never crosses games" + ); assert_ne!(pokemon.symbol_provenance(), platformer.symbol_provenance()); // And two ROM revisions of the same game cannot either. let other = adapter_for_with_rom_pin("platformer", Some(&"b".repeat(64))).unwrap(); diff --git a/services/flysim/crates/flybrain-gb/src/compatibility.rs b/services/flysim/crates/flybrain-gb/src/compatibility.rs index 7e183c0..b3fc959 100644 --- a/services/flysim/crates/flybrain-gb/src/compatibility.rs +++ b/services/flysim/crates/flybrain-gb/src/compatibility.rs @@ -30,7 +30,7 @@ pub const PROTOTYPE_PLASTICITY_VERSION: &str = "fly-kc-mbon-rstdp-v2"; pub struct Compatibility<'a> { /// `kernelVersion(config)` from the neural library. pub neural_kernel_version: &'a str, - /// The adapter's version string, e.g. `pokered-unique8-v5`. + /// The adapter's version string, e.g. `pokered-unique8-v6`. pub adapter: &'a str, /// The dataset's seven SHA-256 digests joined with `:`. pub dataset_fingerprint: &'a str, @@ -67,6 +67,92 @@ impl Compatibility<'_> { } } +/// Position of the adapter's version string in [`Compatibility::string`]. +/// +/// `{kernel}/{adapter}/{fingerprint}/{plasticity}/binjgb:{rev}/pokered:{commit}/statefmt:{id}`, +/// so the adapter is segment one. Nothing else in the string may move for a migration to be +/// considered: a different kernel, dataset, plasticity, emulator revision, symbol provenance or +/// state format is a different *fly*, not a different reward rule. +const ADAPTER_SEGMENT: usize = 1; + +/// The environment variable that opts a deploy into the adapter migration. +/// +/// Read by flysim at restore and by `infra/05-deploy.sh`'s compatibility gate. Comma- or +/// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v5`. +pub const ACCEPT_ADAPTERS_ENV: &str = "FLY_ACCEPT_ADAPTERS"; + +/// What a build may do with a checkpoint whose compatibility string is not its own. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RestoreDecision { + /// Byte-identical. Restore it, as every build always has. + Exact, + /// Every segment but the adapter's is identical, this build's adapter says it can migrate + /// from that one, and the operator named it in [`ACCEPT_ADAPTERS_ENV`]. Restore it. + MigrateAdapter { from: String }, + /// Refuse, and say which of the three conditions failed. + Refuse(&'static str), +} + +/// Decide whether `checkpoint`'s compatibility string may be restored under `current`. +/// +/// Three conditions, all required, in the order they are cheapest to explain: +/// +/// 1. the two strings differ in the adapter segment and **nowhere else**; +/// 2. `migrates_from` -- the running adapter's own list -- contains the checkpoint's adapter, so +/// the code that will read that state says out loud that it can; +/// 3. `accepted` -- [`ACCEPT_ADAPTERS_ENV`] as the operator set it for this deploy -- contains it +/// too, so no build ever migrates a run by itself. +/// +/// Condition 2 without condition 3 would make the migration silent; condition 3 without condition +/// 2 would let an operator wave through a pair nobody wrote a migration for. Neither alone is +/// enough, which is why both are here. +pub fn decide( + checkpoint: &str, + current: &str, + migrates_from: &[&str], + accepted: &[String], +) -> RestoreDecision { + if checkpoint == current { + return RestoreDecision::Exact; + } + let old: Vec<&str> = checkpoint.split('/').collect(); + let new: Vec<&str> = current.split('/').collect(); + if old.len() != new.len() { + return RestoreDecision::Refuse("the two compatibility strings do not have the same shape"); + } + let differing: Vec = (0..old.len()).filter(|&index| old[index] != new[index]).collect(); + if differing != [ADAPTER_SEGMENT] { + return RestoreDecision::Refuse( + "more than the adapter version differs; nothing but a reward-rule change can migrate", + ); + } + let from = old[ADAPTER_SEGMENT]; + if !migrates_from.contains(&from) { + return RestoreDecision::Refuse("this build's adapter has no migration from that adapter"); + } + if !accepted.iter().any(|name| name == from) { + return RestoreDecision::Refuse( + "the checkpoint's adapter is not in FLY_ACCEPT_ADAPTERS, so the migration was not \ + asked for", + ); + } + RestoreDecision::MigrateAdapter { from: from.to_string() } +} + +/// Parse [`ACCEPT_ADAPTERS_ENV`]: comma- or whitespace-separated, empty entries dropped. +/// +/// An unset variable and an empty one are the same thing -- no migration -- so that clearing the +/// opt-in is one edit rather than two. +pub fn accepted_adapters(value: Option<&str>) -> Vec { + value + .unwrap_or_default() + .split([',', ' ', '\t', '\n']) + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + .collect() +} + /// `-`: the two things that decide whether a /// binjgb save state written elsewhere can be memcpy'd back in here. pub fn state_format_id() -> String { @@ -94,7 +180,7 @@ mod tests { assert_eq!( fixture().prototype_string(), concat!( - "lif-1ms-f64-v2/pokered-unique8-v5/aa:bb:cc:dd:ee:ff:00/", + "lif-1ms-f64-v2/pokered-unique8-v6/aa:bb:cc:dd:ee:ff:00/", "fly-kc-mbon-rstdp-v2/", "binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/", "pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", @@ -102,6 +188,73 @@ mod tests { ); } + fn with_adapter(adapter: &'static str) -> String { + Compatibility { adapter, ..fixture() }.string() + } + + #[test] + fn an_identical_string_restores_without_any_opt_in() { + let current = with_adapter("pokered-unique8-v6"); + assert_eq!(decide(¤t, ¤t, &[], &[]), RestoreDecision::Exact); + } + + #[test] + fn a_v5_checkpoint_restores_under_v6_only_with_the_opt_in() { + let old = with_adapter("pokered-unique8-v5"); + let new = with_adapter("pokered-unique8-v6"); + let migrates = ["pokered-unique8-v5"]; + + assert!(matches!(decide(&old, &new, &migrates, &[]), RestoreDecision::Refuse(_))); + assert_eq!( + decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v5"))), + RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() } + ); + // And only for a pair the running adapter says it can migrate. + assert!(matches!( + decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v5"))), + RestoreDecision::Refuse(_) + )); + } + + #[test] + fn nothing_but_the_adapter_segment_may_move() { + let migrates = ["pokered-unique8-v5"]; + let accepted = accepted_adapters(Some("pokered-unique8-v5")); + let new = with_adapter("pokered-unique8-v6"); + + // A different dataset, with the same adapter bump, is not a migration. + let other_dataset = Compatibility { + adapter: "pokered-unique8-v5", + dataset_fingerprint: "00:11:22:33:44:55:66", + ..fixture() + } + .string(); + assert!(matches!( + decide(&other_dataset, &new, &migrates, &accepted), + RestoreDecision::Refuse(_) + )); + + // Neither is a different kernel, and neither is a string of another shape. + let other_kernel = + Compatibility { adapter: "pokered-unique8-v5", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() } + .string(); + assert!(matches!( + decide(&other_kernel, &new, &migrates, &accepted), + RestoreDecision::Refuse(_) + )); + assert!(matches!(decide("a/b", &new, &migrates, &accepted), RestoreDecision::Refuse(_))); + } + + #[test] + fn the_opt_in_list_is_separated_by_commas_or_spaces() { + assert!(accepted_adapters(None).is_empty()); + assert!(accepted_adapters(Some(" ")).is_empty()); + assert_eq!( + accepted_adapters(Some("pokered-unique8-v5, pokered-unique8-v4")), + vec!["pokered-unique8-v5".to_string(), "pokered-unique8-v4".to_string()] + ); + } + #[test] fn the_state_format_segment_is_appended_not_interleaved() { let full = fixture().string(); diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/catalog.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/catalog.rs index d4016d3..4e7a0e6 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/catalog.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/catalog.rs @@ -1,4 +1,4 @@ -//! The `pokered-unique8-v5` reward catalog. +//! The `pokered-unique8-v6` reward catalog. //! //! A direct port of the prototype's `src/reward/catalog.ts`, including the //! declaration order, which is the order `counts` and `last` serialize in. @@ -20,8 +20,18 @@ pub mod kind { pub const BATTLE: &str = "battle"; pub const BADGE: &str = "badge"; pub const BOUNDARY: &str = "boundary"; + pub const CATCH: &str = "catch"; } +/// What a `catch` of a species this run has already caught pays. +/// +/// Not a multiple of the rule's catalog value, because no binary float scales 0.30 into +/// exactly 0.10: `0.3 * (1.0 / 3.0)` is `0.09999999999999999`, and that number would reach +/// the ticker, the checkpoint and `docs/rewards-learning.md`'s table as itself. `boundary`'s +/// two payouts are 0.05 and 0.10, which a scale of two does express exactly, so that rule +/// still goes through the scaling path. +pub const CATCH_REPEAT_VALUE: f64 = 0.10; + #[derive(Debug, Clone, Copy)] pub struct RewardRule { pub kind: &'static str, @@ -34,7 +44,7 @@ pub struct RewardRule { pub stimulation_ms: u32, } -pub const REWARDS: [RewardRule; 8] = [ +pub const REWARDS: [RewardRule; 9] = [ RewardRule { kind: kind::MILESTONE, label: "Story", @@ -99,6 +109,22 @@ pub const REWARDS: [RewardRule; 8] = [ value: 0.05, stimulation_ms: 100, }, + // The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not + // only for knocking one out. Appended rather than slotted next to `species` for the same + // reason `boundary` was appended -- the declaration order is the key order `counts` + // serializes in, and every checkpoint already written carries the first eight in this order. + // + // One rule, two payouts, like `boundary`: this value is what a species this run has never + // caught pays, and [`CATCH_REPEAT_VALUE`] is what a repeat pays. The existing `species` + // rule is untouched and still pays 0.50 the first time a species is owned by any means, so + // a first catch of a new species pays 0.50 + 0.30 across two kinds. + RewardRule { + kind: kind::CATCH, + label: "Catch", + trigger: "Wild Pokémon caught; 0.10 for a species already caught; max 3 per species", + value: 0.30, + stimulation_ms: 150, + }, ]; /// Position of `kind` in [`REWARDS`], or `None` for an unknown kind. This is @@ -199,10 +225,34 @@ mod tests { // separate kinds. assert_eq!(rule(kind::BOUNDARY).unwrap().value, 0.05); assert_eq!(rule(kind::BOUNDARY).unwrap().stimulation_ms, 100); + // Nor the prototype's: the operator's catch rule, `pokered-unique8-v6`. + assert_eq!(rule(kind::CATCH).unwrap().value, 0.30); + assert_eq!(CATCH_REPEAT_VALUE, 0.10); + assert_eq!(rule(kind::CATCH).unwrap().stimulation_ms, 150); assert!(rule("blackout").is_none(), "the catalog has no penalties"); assert!(REWARDS.iter().all(|rule| rule.value > 0.0)); } + #[test] + fn the_catch_rule_is_last_so_the_older_key_order_does_not_move() { + let order: Vec<&str> = REWARDS.iter().map(|rule| rule.kind).collect(); + assert_eq!( + order, + vec![ + kind::MILESTONE, + kind::EXPLORATION, + kind::MAP, + kind::SPECIES, + kind::TRAINER, + kind::BATTLE, + kind::BADGE, + kind::BOUNDARY, + kind::CATCH, + ] + ); + assert_eq!(index(kind::CATCH), Some(REWARDS.len() - 1)); + } + #[test] fn empty_counts_lists_every_kind_at_zero() { let counts = Counts::default(); diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs index 469d708..7d4058f 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs @@ -1,11 +1,12 @@ -//! The Pokémon Red reward adapter, `pokered-unique8-v5`. +//! The Pokémon Red reward adapter, `pokered-unique8-v6`. //! //! 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 //! source evidence behind each gate. v4 replaced the 16-rung boot-to-badges //! ladder with the 38 rungs of `docs/design/ladder.md`; v5 adds one reward rule, //! `boundary` (`docs/design/room-escape.md` section 2), which pays the first step -//! next to and the first step onto each of a map's exits. +//! 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. pub mod catalog; #[cfg(test)] @@ -32,13 +33,33 @@ use symbols::ram; /// Adapter version, pinned into the checkpoint compatibility string. /// -/// `v5` is the `boundary` rule. Bumping it is what rejects every checkpoint written -/// by `v4`: the string is compared whole before a restore is attempted, so a ledger -/// that has never recorded a single `boundary:` key can never be resumed as though -/// its exits were already collected. (`v4` was the 38-rung ladder, and rejected -/// `v3` for the same reason: a stored rank that meant "4 badges" on the old ladder -/// could not be read as a rung on the new one.) Pre-launch, so no run is lost. -pub const REWARD_ADAPTER: &str = "pokered-unique8-v5"; +/// `v6` is the `catch` rule. Bumping it is what makes a `v5` checkpoint a decision +/// rather than an accident: the compatibility string is compared whole before a +/// restore is attempted, so a `v5` run is refused by default and resumed only when +/// the operator names it in `FLY_ACCEPT_ADAPTERS` +/// ([`crate::compatibility::RestoreDecision`], `docs/design/flysim.md`). That +/// migration is safe in one direction only, and only for this pair: `v5`'s ledger is +/// a `v6` ledger with the catch counter absent, and an absent counter reads as zero. +/// +/// (`v5` was the `boundary` rule, and rejected `v4` because a ledger that had never +/// recorded a `boundary:` key could not be resumed as though its exits were already +/// collected. `v4` was the 38-rung ladder, and rejected `v3` because a stored rank +/// that meant "4 badges" on the old ladder is not a rung on the new one. Neither of +/// those is a migration: this one is, because nothing a `v5` ledger holds means +/// something different under `v6`.) +pub const REWARD_ADAPTER: &str = "pokered-unique8-v6"; + +/// Adapter ids whose checkpoints `v6` can read. +/// +/// Exactly one, and it is one because the `catch` rule adds a counter and changes nothing else: +/// a `v5` ledger restores as a `v6` ledger with `catchCounts` empty, and every other byte of the +/// state means what it meant. `v4` is not here -- its `seen` ledger holds no `boundary:` keys, so +/// resuming it would pay a second time for every exit the run had already found -- and neither is +/// `v3`, whose stored rank is a rung on a different ladder. +/// +/// Listing an id here is necessary but not sufficient: `FLY_ACCEPT_ADAPTERS` must name it too +/// (`crate::compatibility::decide`, `docs/design/flysim.md`). +pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v5"]; /// The only cartridge semantic rewards are enabled for. Even the canonical /// pret build stays disabled until reviewed; see `docs/rewards-learning.md`. @@ -60,8 +81,22 @@ pub const SUPPORTED_ROM: &str = /// [`REWARD_ADAPTER`] is the gate that refuses such a checkpoint anyway, and it is /// the right gate, because the objection to loading one is about semantics rather /// than shape. +/// +/// *Not* bumped for the `catch` rule either, and this time the answer matters, +/// because `v5` checkpoints are meant to be restorable under `v6`. The rule adds one +/// counter, `catchCounts`, and nothing else: every other field keeps its name, its +/// shape and its meaning, and a state written without the counter restores with it +/// empty, which is the truth about a run that was never paid for a catch. That is the +/// whole of the documented `v5` -> `v6` migration; see +/// [`crate::compatibility::RestoreDecision`]. pub const STATE_VERSION: u64 = 4; +/// 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 +/// find over and over is a farm, and three is enough for the behaviour to be learned. +const MAX_CATCH_PAYOUTS: u64 = 3; + const BADGE_NAMES: [&str; 8] = [ "BOULDER", "CASCADE", "THUNDER", "RAINBOW", "SOUL", "MARSH", "VOLCANO", "EARTH", ]; @@ -325,6 +360,26 @@ struct Battle { wild: bool, saw_living: bool, ko: bool, + /// Lifetime `species` payouts when this battle started. + /// + /// The "never owned this run" test for the catch rule, and an exact one: the only + /// thing that can set a `wPokedexOwned` bit during a wild battle is the catch + /// itself, so a `species` payout between the battle starting and the ball keeping + /// the Pokémon *is* that Pokémon being new. It is read this way rather than from + /// `wCapturedMonSpecies` directly because that byte is the cartridge's **internal** + /// species index and the owned bitset is by **Pokédex number**; the two numberings + /// differ and nothing in WRAM converts between them + /// (`docs/design/macros-wram.md` section 2, "species numbering"). + /// + /// `None` for a battle restored from a checkpoint written before this existed, + /// which reads as "cannot tell" and pays the repeat amount rather than guessing + /// generously. + species_at_start: Option, + /// The internal species index `wCapturedMonSpecies` named, once a ball has kept one. + captured: Option, + /// Whether that catch was a species this run had never owned, decided on the frame + /// the capture was observed. + captured_new: bool, } /// Immutable per-sample byte cache. Each address requested during one sample @@ -370,6 +425,10 @@ pub struct PokemonRedReward { tiles: OrderedSet, tile_counts: BTreeMap, wild_wins: BTreeMap, + /// Catch payouts per species, by the cartridge's internal species index as a decimal + /// string. The one field `v6` adds to the checkpoint; absent in a `v5` state, which + /// reads as every species at zero. + catch_counts: BTreeMap, replay_blocked: OrderedSet, counts: Counts, total: f64, @@ -420,6 +479,7 @@ impl PokemonRedReward { tiles: OrderedSet::new(), tile_counts: BTreeMap::new(), wild_wins: BTreeMap::new(), + catch_counts: BTreeMap::new(), replay_blocked: OrderedSet::new(), counts: Counts::default(), total: 0.0, @@ -567,8 +627,9 @@ impl PokemonRedReward { } /// Forget observations a rollback invalidates. Lifetime novelty survives, - /// and every wild-KO key paid so far is blocked from paying again, because - /// after a rollback the same battle could otherwise be replayed for reward. + /// and every wild-KO key and every caught species paid so far is blocked from + /// paying again, because after a rollback the same battle -- or the same catch -- + /// could otherwise be replayed for reward. pub fn clear_transient(&mut self) { self.location.clear(); self.stable = 0; @@ -578,6 +639,10 @@ impl PokemonRedReward { for key in keys { self.replay_blocked.insert(&key); } + let caught: Vec = self.catch_counts.keys().cloned().collect(); + for species in caught { + self.replay_blocked.insert(&format!("catch:{species}")); + } } /// Sample WRAM after one completed frame and return this frame's payouts. @@ -690,14 +755,29 @@ impl PokemonRedReward { if in_battle == 1 || in_battle == 2 || in_battle == 255 { self.mode = "BATTLE".to_string(); self.stable = 0; + let species_paid = self.counts.get(kind::SPECIES); if self.battle.is_none() && in_battle != 255 { self.battle = Some(Battle { key: battle_key(memory, map), wild: in_battle == 1, saw_living: false, ko: false, + species_at_start: Some(species_paid), + captured: None, + captured_new: false, }); } + // The cartridge's own answer to "was one caught": `ram/wram.asm`'s comment on + // this byte is "0 if no mon was captured". `ItemUseBall` zeroes it before every + // throw and writes `wEnemyMonSpecies` into it only on the branch that keeps the + // Pokémon, and `UseBagItem`'s `.returnAfterCapturingMon` zeroes it again on the + // way out of the battle -- so it is non-zero for the hundreds of frames the + // catch's own text and Pokédex screen take, and zero everywhere else. + // + // Read rather than derived from `wPartyCount`, because a catch with a full party + // raises `wBoxCount` instead, and because `wPartyCount` also rises for a gift, a + // trade and a revive out of the PC. + let captured = memory.read8(ram::wCapturedMonSpecies); if let Some(battle) = &mut self.battle { let hp = word(memory, ram::wEnemyMonHP); let max = word(memory, ram::wEnemyMonMaxHP); @@ -710,6 +790,11 @@ impl PokemonRedReward { if battle.saw_living && hp == 0 { battle.ko = true; } + if battle.wild && captured != 0 && battle.captured.is_none() { + battle.captured = Some(captured); + battle.captured_new = + battle.species_at_start.is_some_and(|before| species_paid > before); + } } } else if in_battle == 0 { self.mode = "OVERWORLD".to_string(); @@ -728,6 +813,41 @@ impl PokemonRedReward { } self.wild_wins.insert(battle.key.clone(), (count + 1).min(3)); } + // The catch rule (`docs/rewards-learning.md`, the operator 2026-09-22). + // + // Paid on the way out of the battle rather than on the capture frame, so that + // it lands in the same place the wild-KO payout does and cannot fire twice for + // one battle. `wBattleResult` is 2 on exactly two paths in the game: + // `UseBagItem`'s `.returnAfterCapturingMon`, which is this one, and a link + // battle whose opponent ran (`engine/battle/core.asm`), which this cartridge + // never has. Requiring it as well as the captured species means a byte read + // out of a half-initialised battle cannot pay. + if let Some(species) = battle.captured + && battle.wild + && result == 2 + { + let key = species.to_string(); + let paid = self.catch_counts.get(&key).copied().unwrap_or(0); + if paid < MAX_CATCH_PAYOUTS + && !self.replay_blocked.contains(&format!("catch:{key}")) + { + let value = if battle.captured_new { + catalog::rule(kind::CATCH) + .expect("the catch rule is in the catalog") + .value + } else { + catalog::CATCH_REPEAT_VALUE + }; + self.emit_amount( + &mut emitted, + kind::CATCH, + format!("CAUGHT #{species}"), + value, + brain_ms, + ); + } + self.catch_counts.insert(key, (paid + 1).min(MAX_CATCH_PAYOUTS)); + } } let location = format!("{map}:{x}:{y}"); self.stable = if self.location == location { self.stable + 1 } else { 1 }; @@ -825,13 +945,33 @@ impl PokemonRedReward { label: String, scale: f64, brain_ms: f64, + ) { + let rule = catalog::rule(kind).expect("emit is only called with catalog kinds"); + self.emit_amount(emitted, kind, label, rule.value * scale, brain_ms); + } + + /// [`PokemonRedReward::emit`] with the payout stated outright instead of as a multiple + /// of the catalog value. + /// + /// One rule needs it. `catch` pays 0.30 for a species this run has not caught and 0.10 + /// for one it has, and no binary float scales the first into exactly the second: + /// `0.3 * (1.0 / 3.0)` is `0.09999999999999999`, and that is the number that would reach + /// the ticker and the checkpoint. `boundary`'s pair, 0.05 and 0.10, *is* an exact scale + /// of two, so that rule still goes through [`PokemonRedReward::emit`]. + fn emit_amount( + &mut self, + emitted: &mut Vec, + kind: &'static str, + label: String, + value: f64, + brain_ms: f64, ) { let rule = catalog::rule(kind).expect("emit is only called with catalog kinds"); let event = RewardEvent { kind, label, brain_ms, - value: rule.value * scale, + value, stimulation_ms: rule.stimulation_ms, }; emitted.push(event.clone()); @@ -991,6 +1131,10 @@ impl PokemonRedReward { "tiles": self.tiles.as_slice(), "tileCounts": self.tile_counts, "wildWins": self.wild_wins, + // The one field v6 adds. A v5 state does not carry it and restores with it + // empty, which is the documented v5 -> v6 migration and the truth about a run + // that was never paid for a catch. + "catchCounts": self.catch_counts, "replayBlocked": self.replay_blocked.as_slice(), "counts": self.counts, "total": self.total, @@ -1011,6 +1155,9 @@ impl PokemonRedReward { "wild": battle.wild, "sawLiving": battle.saw_living, "ko": battle.ko, + "speciesAtStart": battle.species_at_start, + "captured": battle.captured, + "capturedNew": battle.captured_new, })), "mode": self.mode, }) @@ -1056,6 +1203,13 @@ impl PokemonRedReward { let counts_raw = counted_record(input.get("counts")).ok_or(BAD_CHECKPOINT)?; let tile_counts_raw = counted_record(input.get("tileCounts")).ok_or(BAD_CHECKPOINT)?; let wild_wins_raw = counted_record(input.get("wildWins")).ok_or(BAD_CHECKPOINT)?; + // Absent in every v5 state, and that absence is the migration: no species has been + // paid for a catch, because the rule did not exist. Present but malformed is still + // an error, the same as every other counter here. + let catch_counts = match input.get("catchCounts") { + None | Some(Value::Null) => BTreeMap::new(), + Some(value) => counted_record(Some(value)).ok_or(BAD_CHECKPOINT)?, + }; let recent = recent_raw .iter() @@ -1078,6 +1232,19 @@ impl PokemonRedReward { wild: value.get("wild").and_then(Value::as_bool).ok_or(BAD_HISTORY)?, saw_living: value.get("sawLiving").and_then(Value::as_bool).ok_or(BAD_HISTORY)?, ko: value.get("ko").and_then(Value::as_bool).ok_or(BAD_HISTORY)?, + // All three are v6's, and all three are optional for the same reason + // `catchCounts` is. A v5 battle carries no `speciesAtStart`, which reads as + // "cannot tell whether the caught species was new" and pays the repeat + // amount: the conservative half of the rule, and at most 0.20 once. + species_at_start: value.get("speciesAtStart").and_then(Value::as_u64), + captured: value + .get("captured") + .and_then(Value::as_u64) + .and_then(|species| u8::try_from(species).ok()), + captured_new: value + .get("capturedNew") + .and_then(Value::as_bool) + .unwrap_or(false), }), }; let replay_blocked = match input.get("replayBlocked") { @@ -1103,6 +1270,7 @@ impl PokemonRedReward { } self.tile_counts = tile_counts; self.wild_wins = wild_wins_raw; + self.catch_counts = catch_counts; self.replay_blocked = replay_blocked.iter().map(String::as_str).collect(); self.counts = Counts::default(); for (key, count) in &counts_raw { @@ -1174,6 +1342,10 @@ impl GameAdapter for PokemonRedReward { REWARD_ADAPTER } + fn migrates_from(&self) -> &'static [&'static str] { + MIGRATES_FROM + } + fn rom_allowed(&self, sha256: &str) -> bool { sha256 == SUPPORTED_ROM } diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs index 30119fa..014b4ad 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs @@ -47,6 +47,7 @@ pub mod ram { pub const wBattleType: u16 = 0xd05a; // 53338 pub const wTrainerNo: u16 = 0xd05d; // 53341 pub const wPartyMenuTypeOrMessageID: u16 = 0xd07d; // 53373 + pub const wCapturedMonSpecies: u16 = 0xd11c; // 53532 pub const wForcePlayerToChooseMon: u16 = 0xd11f; // 53535 pub const wTextBoxID: u16 = 0xd125; // 53541 pub const wPartyCount: u16 = 0xd163; // 53603 diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs index e0bede2..87559c1 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/tests.rs @@ -83,6 +83,34 @@ impl Fixture { self.visit(x, 0) } + /// One wild battle that ends in a ball keeping the Pokémon, byte for byte as the + /// cartridge writes it at the pinned commit. + /// + /// `InitBattleVariables` clears `wBattleResult`; `ItemUseBall`'s capture branch sets the + /// Pokédex bit (for a species the player did not already own) and writes + /// `wEnemyMonSpecies` into `wCapturedMonSpecies`; `UseBagItem`'s + /// `.returnAfterCapturingMon` then zeroes that byte, sets `wBattleResult` to 2 and leaves + /// the battle. `dex` is the Pokédex *number* minus one, i.e. the bit index, and `None` is a + /// species this run already owns. + fn catch(&mut self, species: u8, dex: Option) -> Vec { + self.memory.set(ram::wBattleResult, 0); + self.memory.set(ram::wIsInBattle, 1); + self.memory.set(ram::wEnemyMonSpecies, species); + self.memory.set(ram::wEnemyMonHP + 1, 10); + self.memory.set(ram::wEnemyMonMaxHP + 1, 10); + let mut events = self.sample(); + if let Some(index) = dex { + self.memory.or(ram::wPokedexOwned + (index >> 3), 1 << (index & 7)); + } + self.memory.set(ram::wCapturedMonSpecies, species); + events.extend(self.sample()); + self.memory.set(ram::wCapturedMonSpecies, 0); + self.memory.set(ram::wBattleResult, 2); + self.memory.set(ram::wIsInBattle, 0); + events.extend(self.sample()); + events + } + /// Write a warp table: `wNumberOfWarps` plus one four-byte `Y, X, warp id, map id` entry per /// `(x, y)`, the layout `ram/wram.asm` documents at the pinned commit. fn warps(&mut self, warps: &[(u8, u8)]) { @@ -320,6 +348,136 @@ fn a_wild_run_capture_or_single_faint_never_pays_a_ko_while_a_verified_ko_does() assert_eq!(f.reward.statistics().counts[kind::BATTLE], 1); } +#[test] +fn a_catch_pays_the_new_species_amount_once_the_repeat_amount_after_and_stops_at_three() { + let mut f = Fixture::new(); + f.sample(); + + // A species this run has never owned: the cartridge sets the Pokédex bit on the way + // through, so the existing `species` rule pays 0.50 and the new rule pays 0.30. + let first = f.catch(0xb0, Some(3)); + assert_eq!(kinds(&first), ["species", "catch"]); + assert_eq!(labels(&first), ["OWNED #4", "CAUGHT #176"]); + assert!((first[0].value - 0.5).abs() < 1e-12, "the species rule is untouched"); + assert!((first[1].value - 0.30).abs() < 1e-12); + + // The same species again: a repeat, twice, and then the cap. + for _ in 0..2 { + let again = f.catch(0xb0, None); + assert_eq!(kinds(&again), ["catch"]); + assert_eq!(again[0].value, 0.10, "the repeat amount is exactly 0.10, not 0.3/3"); + } + assert!(f.catch(0xb0, None).is_empty(), "three payouts per species is the cap"); + assert_eq!(f.reward.statistics().counts[kind::CATCH], 3); + + // Another species starts its own count, and its own 0.30. + let other = f.catch(0x99, Some(0)); + assert_eq!(kinds(&other), ["species", "catch"]); + assert!((other[1].value - 0.30).abs() < 1e-12); +} + +#[test] +fn a_catch_of_a_species_this_run_already_owns_pays_the_repeat_amount() { + let mut f = Fixture::new(); + f.sample(); + // Owned before the battle -- a gift, a trade, an evolution -- so no Pokédex bit is set + // during it and the catch is not a new species. + f.memory.or(ram::wPokedexOwned, 1); + assert_eq!(kinds(&f.sample()), ["species"]); + + let events = f.catch(0x99, None); + assert_eq!(kinds(&events), ["catch"]); + assert_eq!(events[0].value, 0.10); +} + +#[test] +fn nothing_but_a_wild_catch_pays_the_catch_rule() { + // A trainer battle: balls cannot be thrown, and `wIsInBattle` is 2. + let mut f = Fixture::new(); + f.sample(); + f.memory.set(ram::wIsInBattle, 2); + f.memory.set(ram::wEnemyMonHP + 1, 10); + f.memory.set(ram::wEnemyMonMaxHP + 1, 10); + f.sample(); + f.memory.set(ram::wCapturedMonSpecies, 0xb0); + f.sample(); + f.memory.set(ram::wCapturedMonSpecies, 0); + f.memory.set(ram::wBattleResult, 2); + f.memory.set(ram::wIsInBattle, 0); + assert!(f.sample().is_empty(), "a trainer battle never pays the catch rule"); + + // The Safari Zone and the old man's tutorial are excluded a step earlier: the whole + // sample is dropped with a visible mode, so no battle is ever opened. + for battle_type in [1u8, 2] { + let mut f = Fixture::new(); + f.sample(); + f.memory.set(ram::wBattleType, battle_type); + f.memory.set(ram::wIsInBattle, 1); + assert!(f.sample().is_empty()); + f.memory.set(ram::wCapturedMonSpecies, 0xb0); + assert!(f.sample().is_empty()); + f.memory.set(ram::wCapturedMonSpecies, 0); + f.memory.set(ram::wBattleResult, 2); + f.memory.set(ram::wIsInBattle, 0); + f.memory.set(ram::wBattleType, 0); + assert!(f.sample().is_empty()); + assert_eq!(f.reward.statistics().counts[kind::CATCH], 0); + } + + // A ball that missed: `wCapturedMonSpecies` never leaves zero and the battle ends as a + // run or a loss. + let mut f = Fixture::new(); + f.sample(); + f.memory.set(ram::wIsInBattle, 1); + f.memory.set(ram::wEnemyMonHP + 1, 10); + f.memory.set(ram::wEnemyMonMaxHP + 1, 10); + f.sample(); + f.memory.set(ram::wIsInBattle, 0); + assert!(f.sample().is_empty()); +} + +#[test] +fn a_rollback_cannot_replay_a_catch() { + let mut f = Fixture::new(); + f.sample(); + assert_eq!(kinds(&f.catch(0xb0, Some(3))), ["species", "catch"]); + + f.reward.clear_transient(); + let state = f.reward.export_state(); + f.reward.import_state(&state).unwrap(); + assert!(f.catch(0xb0, None).is_empty(), "an already-paid species cannot pay after rollback"); + assert_eq!(f.reward.statistics().counts[kind::CATCH], 1); +} + +#[test] +fn a_v5_state_restores_under_v6_with_the_catch_counter_at_zero() { + let mut f = Fixture::new(); + f.sample(); + f.catch(0xb0, Some(3)); + let v6 = f.reward.export_state(); + assert_eq!(v6["catchCounts"], json!({ "176": 1 })); + + // The v5 shape is this one without the counter the rule added: same `version`, same field + // names, same meanings. That is the whole of the documented migration. + let mut v5 = v6.clone(); + v5.as_object_mut().unwrap().remove("catchCounts"); + assert_eq!(v5["version"], json!(STATE_VERSION), "v5 and v6 states share a schema version"); + + let mut restored = PokemonRedReward::new(); + restored.import_state(&v5).unwrap(); + let mut expected = v6.clone(); + expected["catchCounts"] = json!({}); + assert_eq!(restored.export_state(), expected, "the counter starts at 0, nothing else moves"); + + // A genuine v5 `counts` object carries eight kinds and no `catch`, which reads as zero. + let mut older = v5.clone(); + older["counts"].as_object_mut().unwrap().remove("catch"); + let mut restored = PokemonRedReward::new(); + restored.import_state(&older).unwrap(); + assert_eq!(restored.statistics().counts[kind::CATCH], 0); + assert_eq!(restored.statistics().counts[kind::SPECIES], 1); +} + #[test] fn a_repeated_wild_ko_decays_then_stops() { let mut f = Fixture::new(); @@ -378,6 +536,7 @@ fn malformed_checkpoint_fields_are_named_in_the_error() { ("counts", json!([])), ("tileCounts", json!("not a record")), ("wildWins", json!(3)), + ("catchCounts", json!(3)), ] { let mut broken = good.clone(); broken[field] = wrong; @@ -706,7 +865,8 @@ fn the_recent_ticker_keeps_the_newest_eight_events_newest_first() { #[test] fn the_adapter_reports_its_identity_and_pinned_rom() { let reward = PokemonRedReward::new(); - assert_eq!(reward.id(), "pokered-unique8-v5"); + assert_eq!(reward.id(), "pokered-unique8-v6"); + assert_eq!(reward.migrates_from(), ["pokered-unique8-v5"]); assert!(reward.rom_allowed(SUPPORTED_ROM)); assert!(!reward.rom_allowed( "5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b" @@ -716,6 +876,9 @@ fn the_adapter_reports_its_identity_and_pinned_rom() { assert_eq!(symbols::ram::wNumberOfWarps, 0xd3ae); assert_eq!(symbols::ram::wWarpEntries, 0xd3af); assert_eq!(symbols::ram::wCurMapConnections, 0xd370); + // Resolved from ram/wram.asm by services/flysim/tools/resolve_wram.py, bracketed by + // wFontLoaded and wForcePlayerToChooseMon; never written out by hand. + assert_eq!(symbols::ram::wCapturedMonSpecies, 0xd11c); assert_eq!(symbols::MILESTONES.len(), 17); } diff --git a/services/flysim/crates/flysim/src/lib.rs b/services/flysim/crates/flysim/src/lib.rs index 65a1b70..be481b0 100644 --- a/services/flysim/crates/flysim/src/lib.rs +++ b/services/flysim/crates/flysim/src/lib.rs @@ -30,6 +30,7 @@ pub mod metrics; pub mod pacing; pub mod profile; pub mod ratelimit; +pub mod reset; pub mod sdnotify; pub mod simloop; pub mod snapshot; diff --git a/services/flysim/crates/flysim/src/main.rs b/services/flysim/crates/flysim/src/main.rs index 5d2aca3..b0c4a89 100644 --- a/services/flysim/crates/flysim/src/main.rs +++ b/services/flysim/crates/flysim/src/main.rs @@ -42,6 +42,15 @@ struct Args { /// the "nothing to compare" case for a fresh container. #[arg(long, value_name = "DIR")] print_state_compatibility: Option, + + /// Restart the run from the milestone archive for this ladder rung, and exit. + /// + /// Run with flysim stopped: it rewrites both checkpoint stores. + /// `infra/bin/fly-reset-to-milestone` is the operator-facing wrapper and the sequence + /// around it is in `infra/docs/runbook.md`. The current state is copied to a dated + /// directory first, so this is reversible by hand. + #[arg(long, value_name = "RANK")] + reset_to_milestone: Option, } fn main() -> Result<()> { @@ -66,6 +75,17 @@ fn main() -> Result<()> { return Ok(()); } + if let Some(rank) = args.reset_to_milestone { + let stamp = flysim::reset::utc_stamp(flysim::eventlog::now_wall_ms()); + let durable = config.paths.save_dir.clone(); + let hot = config.paths.hot_dir.clone(); + let archive = flysim::reset::default_archive_dir(&durable, &stamp); + for line in flysim::reset::reset_to_milestone(&durable, &hot, rank, &archive)? { + println!("{line}"); + } + return Ok(()); + } + flysim::run(config) } diff --git a/services/flysim/crates/flysim/src/reset.rs b/services/flysim/crates/flysim/src/reset.rs new file mode 100644 index 0000000..acf575e --- /dev/null +++ b/services/flysim/crates/flysim/src/reset.rs @@ -0,0 +1,450 @@ +//! Restarting a run from an earlier rung, on disk, with flysim stopped. +//! +//! The operator's decision of 2026-09-22 was "restart the live run from an early checkpoint +//! instead of from scratch". `FLY_RESET_STATE=1` cannot do that: it archives everything and the +//! next start warms up a fresh fly. What this does instead is promote one milestone archive -- +//! `milestone-.checkpoint`, which `store::Store::commit` writes at the first commit at a new +//! best rank and which no rotation ever unlinks -- to being the only thing either store will +//! restore. +//! +//! `infra/bin/fly-reset-to-milestone` is the operator-facing wrapper; it refuses to run while +//! flysim is up, calls `flysim --reset-to-milestone N`, and fixes ownership afterwards. The work +//! is here rather than in that script because two of the steps are inside the `FLYSIM01` +//! envelope: the ratchet's attempts and recoveries counters live in the checkpoint's manifest, +//! and a shell script has no business rewriting one. +//! +//! What it does, in order, and nothing else: +//! +//! 1. **archives** every file in the durable and hot stores into a dated directory, by copying, +//! so a step that fails later has destroyed nothing; +//! 2. **rewrites** the rung's archive with the ratchet's `attempts` and `recoveries` at zero, so +//! the recovery budget is not already spent when the restarted run begins. `best` is left +//! alone: the archive's own `best` is the rung it was taken at, which is exactly what the +//! restarted run is at, and the rank the stream shows is recomputed by the adapter from the +//! restored game state anyway; +//! 3. **installs** it as the newest generation in both stores, so the restore order +//! (`store::restore_order`: hot latest, hot previous, durable latest, ...) reaches it first; +//! 4. **clears** the milestone archives above N -- rungs the run had reached and is now below -- +//! and the generation files of the run being abandoned; +//! 5. **clears the session ledgers**: the event log `events.jsonl` and its rotations. The +//! checkpoint carries `lastEventId`, so restoring an old checkpoint over a newer log would +//! re-issue ids the log already holds. The macro layer's own session ledgers (blocked, +//! talked, reached, pushed-back) are memory-only by contract +//! (`docs/design/macros.md` section 12.1: "a restored run offers every target once more"), +//! so stopping flysim is what resets those and this has nothing to do. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +use crate::store::{self, Store, StoreManifest}; + +/// Generations kept by the stores this tool writes. Only used for the rotation bound, which +/// this tool does not trigger; the durable store's own value. +const KEEP_GENERATIONS: usize = 8; + +/// A generation number no commit ever allocates, so `Store::candidates` drops the +/// generation-file half of an archive entry and offers only `milestone-.checkpoint`. +/// +/// `Sim::boot` allocates `highest_generation() + 1`, which is 1 or more, so 0 names no file. +/// The milestone archives below the rung being restored are kept exactly this way: still on +/// disk, still restorable as a deeper fallback, and with no generation file pretending to be +/// their contents. +const NO_GENERATION: u64 = 0; + +/// What the reset did, one line per step, for the operator's terminal and the run record. +pub type Report = Vec; + +/// Promote `rank`'s milestone archive to be the restore source of both stores. +/// +/// `archive` is the dated directory the current state is copied into; it must not exist. +/// Refuses if the milestone archive is missing, which is the "that rung was never reached" +/// case and the one mistake worth refusing rather than guessing at. +pub fn reset_to_milestone( + durable_dir: &Path, + hot_dir: &Path, + rank: u32, + archive: &Path, +) -> Result { + let durable = Store::new(durable_dir, KEEP_GENERATIONS); + let hot = Store::new(hot_dir, KEEP_GENERATIONS); + let source = durable.archive_path(rank); + if !source.is_file() { + bail!( + "no milestone archive for rung {rank}: {} does not exist. `ls {}` shows the rungs \ + this run actually reached.", + source.display(), + durable_dir.display() + ); + } + if archive.exists() { + bail!("the archive directory {} already exists", archive.display()); + } + + let mut report: Report = Vec::new(); + + // 1. Copy everything aside first. + let copied_durable = copy_tree(durable_dir, &archive.join("durable"))?; + let copied_hot = copy_tree(hot_dir, &archive.join("hot"))?; + report.push(format!( + "archived {copied_durable} durable and {copied_hot} hot files to {}", + archive.display() + )); + + // 2. Zero the two recovery counters inside the envelope. + let mut checkpoint = store::load(&source) + .with_context(|| format!("decoding {}", source.display()))?; + let spent = (checkpoint.runtime.ratchet.attempts, checkpoint.runtime.ratchet.recoveries); + checkpoint.runtime.ratchet.attempts = 0; + checkpoint.runtime.ratchet.recoveries = 0; + + let generation = durable.highest_generation().max(hot.highest_generation()) + 1; + checkpoint.runtime.generation = generation; + let bytes = store::encode(&checkpoint.agent, &checkpoint.runtime)?; + report.push(format!( + "rung {rank} (best {}, ladder rank recomputed from the game state): ratchet attempts \ + {} -> 0, recoveries {} -> 0", + checkpoint.runtime.ratchet.best, spent.0, spent.1 + )); + + // 3/4. Clear both stores, keeping the milestone archives at or below this rung, and write + // the promoted state as the newest generation of each. + let kept = clear_store(durable_dir, Some(rank))?; + clear_store(hot_dir, None)?; + report.push(format!( + "cleared the hot store and every milestone archive above rung {rank}; kept {} at or \ + below it: {kept:?}", + kept.len() + )); + + // The hot store lives on a tmpfs that a stopped container may not have mounted yet, so it + // is created rather than assumed; the durable one already exists or the milestone archive + // above could not have been read. + durable.create()?; + hot.create()?; + store::write_atomic(&durable.generation_path(generation), &bytes)?; + store::write_atomic(&durable.archive_path(rank), &bytes)?; + store::write_atomic(&hot.generation_path(generation), &bytes)?; + + let mut archives: BTreeMap = + kept.iter().map(|rung| (*rung, NO_GENERATION)).collect(); + archives.insert(rank, generation); + let durable_manifest = StoreManifest { + generation, + latest: Some(generation), + previous: None, + archives, + }; + write_manifest(&durable, &durable_manifest)?; + write_manifest( + &hot, + &StoreManifest { + generation, + latest: Some(generation), + previous: None, + archives: BTreeMap::new(), + }, + )?; + report.push(format!( + "generation {generation} is now hot latest and durable latest in {} and {}", + hot_dir.display(), + durable_dir.display() + )); + + // 5. The session ledgers. + let logs = remove_matching(durable_dir, |name| { + name == "events.jsonl" || (name.starts_with("events-") && name.ends_with(".jsonl")) + })?; + report.push(format!( + "reset the session ledgers: {logs} event-log files removed (the macro layer's are \ + memory-only and are reset by stopping flysim)" + )); + + Ok(report) +} + +fn write_manifest(store: &Store, manifest: &StoreManifest) -> Result<()> { + store.create()?; + store::write_atomic(&store.manifest_path(), &serde_json::to_vec_pretty(manifest)?) +} + +/// Copy every regular file of `from` into `to`, creating `to`. A missing source is zero files, +/// not an error: the hot store lives on a tmpfs that a stopped container does not have. +fn copy_tree(from: &Path, to: &Path) -> Result { + if !from.is_dir() { + return Ok(0); + } + std::fs::create_dir_all(to) + .with_context(|| format!("creating {}", to.display()))?; + let mut copied = 0; + for entry in std::fs::read_dir(from)?.flatten() { + if !entry.file_type().is_ok_and(|kind| kind.is_file()) { + continue; + } + std::fs::copy(entry.path(), to.join(entry.file_name())) + .with_context(|| format!("copying {}", entry.path().display()))?; + copied += 1; + } + Ok(copied) +} + +/// Remove every checkpoint, tmp file and manifest from `dir`, keeping `milestone-.checkpoint` +/// for `r <= keep_up_to`. Returns the rungs kept, ascending. +fn clear_store(dir: &Path, keep_up_to: Option) -> Result> { + let mut kept = Vec::new(); + if !dir.is_dir() { + return Ok(kept); + } + for entry in std::fs::read_dir(dir)?.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let milestone = name + .strip_prefix("milestone-") + .and_then(|rest| rest.strip_suffix(".checkpoint")) + .and_then(|rung| rung.parse::().ok()); + let remove = match milestone { + // The promoted rung's own archive is rewritten straight after this, so it is + // removed here like the rest and reinstated with the counters cleared. + Some(rung) => match keep_up_to { + Some(limit) if rung < limit => { + kept.push(rung); + false + } + _ => true, + }, + None => { + name == "manifest.json" + || name.ends_with(".checkpoint") + || name.ends_with(".checkpoint.tmp") + } + }; + if remove { + std::fs::remove_file(entry.path()) + .with_context(|| format!("removing {}", entry.path().display()))?; + } + } + kept.sort_unstable(); + Ok(kept) +} + +fn remove_matching(dir: &Path, wanted: impl Fn(&str) -> bool) -> Result { + if !dir.is_dir() { + return Ok(0); + } + let mut removed = 0; + for entry in std::fs::read_dir(dir)?.flatten() { + if wanted(&entry.file_name().to_string_lossy()) { + std::fs::remove_file(entry.path())?; + removed += 1; + } + } + Ok(removed) +} + +/// `YYYYMMDDTHHMMSSZ` in UTC, for the dated archive directory's name. +/// +/// Built on the event log's own calendar conversion, which is the service's only one; the time of +/// day is arithmetic on the same millisecond count. +pub fn utc_stamp(wall_ms: u64) -> String { + let second_of_day = (wall_ms % 86_400_000) / 1_000; + format!( + "{}T{:02}{:02}{:02}Z", + crate::eventlog::utc_day(wall_ms), + second_of_day / 3_600, + (second_of_day / 60) % 60, + second_of_day % 60, + ) +} + +/// The default dated archive directory: a sibling of the durable store, which is its own +/// mountpoint and so cannot be renamed -- the same shape `infra/05-deploy.sh` uses for +/// `FLY_RESET_STATE=1`. +pub fn default_archive_dir(durable_dir: &Path, stamp: &str) -> PathBuf { + let mut name = durable_dir.as_os_str().to_os_string(); + name.push(format!(".reset-{stamp}")); + PathBuf::from(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A real `FLYSIM01` envelope, small but structurally complete, so these tests decode and + /// re-encode what the running service writes rather than a stand-in. + fn envelope(generation: u64, ratchet: flybrain_gb::RatchetState) -> Vec { + use flybrain_core::decoder::DecoderState; + use flybrain_core::lif::LifState; + use flybrain_core::ordered::NumberMap; + use flybrain_core::plasticity::PlasticityState; + + let agent = flybrain_core::agent::AgentState { + version: 1, + remainder: 0.75, + warmed_up: true, + network: LifState { + membrane: vec![0.5, -0.25], + refractory: vec![0, 3], + last_spike_ms: vec![-1_000_000.0, 12.0], + visual_drive: vec![0.1], + rng: -12_345, + reward_remaining: 40.0, + ms: 9_000.0, + population_rate: 1.5, + rates: NumberMap::from_pairs([("forward", 2.0)]), + plasticity: PlasticityState { + version: "fly-kc-mbon-rstdp-v2".to_string(), + topology: 42, + enabled: true, + updates: 3.0, + signal: 0.25, + gains: vec![1.0, 0.9], + traces: vec![0.0, 0.1], + touched: vec![0.0, 8_000.0], + }, + }, + decoder: DecoderState { + version: 4, + calibrated: true, + baseline: NumberMap::from_pairs([("forward", 1.0)]), + held_until: NumberMap::new(), + next_allowed: NumberMap::new(), + next_decision: 100.0, + current: None, + fatigue: NumberMap::new(), + macro_next_decision: 0.0, + macro_current: None, + macro_fatigue: NumberMap::new(), + }, + }; + let runtime = store::RuntimeState { + generation, + wall_ms: 1_700_000_000_000, + rom_sha256: "ab".repeat(32), + emulator_frame: 12_345, + compatibility: "kernel/pokered-unique8-v6/fingerprint".to_string(), + speed: 1.0, + buttons: 0, + rank_since_ms: 4_242.0, + last_event_id: 77, + reward: serde_json::json!({ "version": 4, "total": 1.25 }), + ratchet, + emulator: vec![7; 64], + framebuffer: vec![9; 32], + ratchet_game: vec![1, 2, 3], + ratchet_frame: vec![4, 5, 6], + }; + store::encode(&agent, &runtime).unwrap() + } + + /// A store dir holding a milestone archive for each rung in `rungs`, their generations, a + /// manifest and an event log, all written through the store's own commit path. + fn state_dir(root: &Path, rungs: &[u32], ratchet: flybrain_gb::RatchetState) -> Store { + let store = Store::new(root, KEEP_GENERATIONS); + store.create().unwrap(); + for (index, rung) in rungs.iter().enumerate() { + let generation = index as u64 + 1; + store.commit(generation, &envelope(generation, ratchet), Some(*rung)).unwrap(); + } + std::fs::write(root.join("events.jsonl"), b"{}\n").unwrap(); + std::fs::write(root.join("events-20260921.jsonl"), b"{}\n").unwrap(); + store + } + + #[test] + fn a_missing_rung_is_refused_and_nothing_is_touched() { + let tmp = tempfile::tempdir().unwrap(); + let durable = tmp.path().join("state"); + let hot = tmp.path().join("hot"); + state_dir(&durable, &[3, 5], flybrain_gb::RatchetState::default()); + let before = std::fs::read_dir(&durable).unwrap().flatten().count(); + + let error = reset_to_milestone(&durable, &hot, 9, &tmp.path().join("archive")) + .unwrap_err() + .to_string(); + assert!(error.contains("no milestone archive for rung 9"), "{error}"); + assert_eq!(std::fs::read_dir(&durable).unwrap().flatten().count(), before); + assert!(!tmp.path().join("archive").exists(), "nothing was archived"); + } + + #[test] + fn the_rung_becomes_both_stores_latest_with_the_recovery_budget_back() { + let tmp = tempfile::tempdir().unwrap(); + let durable = tmp.path().join("state"); + let hot = tmp.path().join("hot"); + let spent = flybrain_gb::RatchetState { + best: 5, + attempts: 3, + recoveries: 11, + ..flybrain_gb::RatchetState::default() + }; + state_dir(&durable, &[3, 5, 9, 11], spent); + state_dir(&hot, &[11], spent); + let archive = tmp.path().join("archive-20260922"); + + let report = reset_to_milestone(&durable, &hot, 5, &archive).unwrap(); + assert!(report.iter().any(|line| line.contains("attempts 3 -> 0")), "{report:?}"); + + // Everything that was there is in the archive. + assert!(archive.join("durable/milestone-11.checkpoint").is_file()); + assert!(archive.join("durable/events.jsonl").is_file()); + assert!(archive.join("hot/manifest.json").is_file()); + + // The rungs above 5 are gone; the ones below it stay as deeper fallbacks. + assert!(!durable.join("milestone-9.checkpoint").exists()); + assert!(!durable.join("milestone-11.checkpoint").exists()); + assert!(durable.join("milestone-3.checkpoint").is_file()); + assert!(durable.join("milestone-5.checkpoint").is_file()); + assert!(!durable.join("events.jsonl").exists()); + assert!(!durable.join("events-20260921.jsonl").exists()); + assert!(!hot.join("milestone-11.checkpoint").exists()); + + // Both stores restore the rung, and the counters are back. + for store in [Store::new(&hot, KEEP_GENERATIONS), Store::new(&durable, KEEP_GENERATIONS)] { + let candidates = store.candidates("x"); + let first = store::load(&candidates[0].path).unwrap(); + assert_eq!(first.runtime.ratchet.best, 5); + assert_eq!((first.runtime.ratchet.attempts, first.runtime.ratchet.recoveries), (0, 0)); + } + // ... including through the promoted milestone archive itself. + let archived = store::load(&durable.join("milestone-5.checkpoint")).unwrap(); + assert_eq!((archived.runtime.ratchet.attempts, archived.runtime.ratchet.recoveries), (0, 0)); + + // The rungs below it are offered, and only as their own archive files. + let manifest = Store::new(&durable, KEEP_GENERATIONS).manifest().unwrap(); + assert_eq!(manifest.archives.get(&3), Some(&NO_GENERATION)); + assert_eq!(manifest.latest, manifest.archives.get(&5).copied()); + assert!( + Store::new(&durable, KEEP_GENERATIONS) + .candidates("durable") + .iter() + .any(|candidate| candidate.path.ends_with("milestone-3.checkpoint")) + ); + } + + #[test] + fn a_second_reset_refuses_to_write_over_an_existing_archive() { + let tmp = tempfile::tempdir().unwrap(); + let durable = tmp.path().join("state"); + let hot = tmp.path().join("hot"); + state_dir(&durable, &[4], flybrain_gb::RatchetState::default()); + let archive = tmp.path().join("archive"); + reset_to_milestone(&durable, &hot, 4, &archive).unwrap(); + let error = reset_to_milestone(&durable, &hot, 4, &archive).unwrap_err().to_string(); + assert!(error.contains("already exists"), "{error}"); + } + + #[test] + fn the_stamp_is_the_event_logs_calendar_plus_a_time_of_day() { + assert_eq!(utc_stamp(0), "19700101T000000Z"); + // 2026-09-22T16:15:00Z + assert_eq!(utc_stamp(1_790_093_700_000), "20260922T161500Z"); + } + + #[test] + fn the_default_archive_is_a_dated_sibling_of_the_store() { + assert_eq!( + default_archive_dir(Path::new("/srv/fly/state"), "20260922T161500Z"), + PathBuf::from("/srv/fly/state.reset-20260922T161500Z") + ); + } +} diff --git a/services/flysim/crates/flysim/src/simloop.rs b/services/flysim/crates/flysim/src/simloop.rs index ecf3440..8677f45 100644 --- a/services/flysim/crates/flysim/src/simloop.rs +++ b/services/flysim/crates/flysim/src/simloop.rs @@ -708,12 +708,36 @@ impl Sim { if runtime.rom_sha256 != self.rom_sha256 { bail!("checkpoint is for another cartridge ({})", runtime.rom_sha256); } - if runtime.compatibility != self.compatibility { - bail!( - "compatibility mismatch\n checkpoint: {}\n this build: {}", + // Byte-identical, or the one documented migration the operator asked for. + // + // `FLY_ACCEPT_ADAPTERS` is read here rather than carried in `Config` because it is a + // property of a *deploy*, not of a run: `infra/05-deploy.sh` writes it into + // `/etc/fly/fly.env` only for the deploy that needs it, and an operator who wants the + // migration off again deletes one line. An empty or unset variable is no migration at + // all, which is what every deploy before this one did. + let accepted = flybrain_gb::compatibility::accepted_adapters( + std::env::var(flybrain_gb::compatibility::ACCEPT_ADAPTERS_ENV).ok().as_deref(), + ); + match flybrain_gb::compatibility::decide( + &runtime.compatibility, + &self.compatibility, + self.adapter.migrates_from(), + &accepted, + ) { + flybrain_gb::compatibility::RestoreDecision::Exact => {} + flybrain_gb::compatibility::RestoreDecision::MigrateAdapter { from } => { + tracing::warn!( + from = %from, + to = %self.adapter.id(), + "restoring a checkpoint from an earlier adapter, by the migration \ + FLY_ACCEPT_ADAPTERS opted this deploy into" + ); + } + flybrain_gb::compatibility::RestoreDecision::Refuse(reason) => bail!( + "compatibility mismatch: {reason}\n checkpoint: {}\n this build: {}", runtime.compatibility, self.compatibility - ); + ), } if runtime.framebuffer.len() != FRAMEBUFFER_LEN { bail!("checkpoint framebuffer is {} bytes", runtime.framebuffer.len()); diff --git a/services/flysim/crates/flysim/src/snapshot.rs b/services/flysim/crates/flysim/src/snapshot.rs index f1c450d..dbcced1 100644 --- a/services/flysim/crates/flysim/src/snapshot.rs +++ b/services/flysim/crates/flysim/src/snapshot.rs @@ -241,8 +241,8 @@ pub struct FeedMacroOutcome { } /// Reward categories the feed reports counts for. The adapter's own interned kinds -/// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`) map -/// onto these. +/// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, +/// `catch`) map onto these. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum RewardKind { @@ -282,6 +282,15 @@ impl RewardKind { // protocol is concerned: finding a door is finding somewhere new, and the design asks // for no new feed kind. "boundary" => Self::Explore, + // `catch` is a wild battle the fly won by keeping the Pokémon, so it publishes on + // the same counter a wild KO does. The feed's kinds are a closed set + // (`docs/feed-protocol.md`) and this rule asked for no new one. + // + // Deliberately *not* `pokedex`: on a catch of a species this run has never owned, + // the cartridge sets the Pokédex bit and the adapter's existing `species` rule pays + // for it on the same frame, so the `pokedex` counter already moves. Mapping `catch` + // there as well would count one event twice. + "catch" => Self::Wildwin, // The platformer. "band" => Self::Explore, "coin" => Self::Wildwin, @@ -739,6 +748,7 @@ mod tests { } assert_eq!(RewardKind::from_adapter("nonsense"), None); assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore)); + assert_eq!(RewardKind::from_adapter("catch"), Some(RewardKind::Wildwin)); } #[test] diff --git a/services/flysim/crates/flysim/tests/compat_migration.rs b/services/flysim/crates/flysim/tests/compat_migration.rs new file mode 100644 index 0000000..13950b7 --- /dev/null +++ b/services/flysim/crates/flysim/tests/compat_migration.rs @@ -0,0 +1,224 @@ +//! A `v5` checkpoint restored under `v6`: accepted with the opt-in, refused without it. +//! +//! The unit tests in `flybrain-gb` cover the decision function and the adapter's own state +//! 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 — +//! written, encoded, decoded, and then put through exactly what `Sim::try_restore` puts a +//! candidate through. +//! +//! 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`. + +use flybrain_gb::GameAdapter; +use flybrain_gb::compatibility::{RestoreDecision, accepted_adapters, decide}; +use flybrain_gb::pokemon_red::PokemonRedReward; +use flysim::store::{self, RuntimeState}; + +/// The live string's shape, with the adapter left open. The dataset fingerprint is shortened — +/// nothing here parses it, and a seven-digest one would be 455 characters of noise. +fn compatibility(adapter: &str) -> String { + format!( + "lif-1ms-f64-v2/{adapter}/aa:bb:cc:dd:ee:ff:00/fly-kc-mbon-rstdp-v2/\ + binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/\ + pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" + ) +} + +/// A `v5` reward ledger: `STATE_VERSION` 4, every field `v5` wrote, and **no** `catchCounts`. +/// +/// Written out by hand rather than exported from an adapter, because an exported one would be a +/// `v6` state with the counter deleted — this is the shape the release box's checkpoints really +/// carry, field for field. +fn v5_reward() -> serde_json::Value { + serde_json::json!({ + "version": 4, + "seen": ["adventure", "map:0", "early:outside", "dex:3", "boundary:0:edge:n:near"], + "tiles": ["0:5:6", "0:5:7"], + "tileCounts": { "0": 2 }, + "wildWins": { "0:112:4": 2 }, + "replayBlocked": [], + "counts": { + "milestone": 2, "exploration": 0, "map": 1, "species": 1, + "trainer": 0, "battle": 2, "badge": 0, "boundary": 1 + }, + "total": 2.05, + "recent": [{ "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 }], + "last": { "species": { "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 } }, + "initialized": true, + "sawBoot": true, + "location": "0:5:7", + "stable": 9, + "progress": 3, + "badges": 0, + "battle": null, + "mode": "OVERWORLD" + }) +} + +fn v5_checkpoint() -> Vec { + use flybrain_core::decoder::DecoderState; + use flybrain_core::lif::LifState; + use flybrain_core::ordered::NumberMap; + use flybrain_core::plasticity::PlasticityState; + + let agent = flybrain_core::agent::AgentState { + version: 1, + remainder: 0.25, + warmed_up: true, + network: LifState { + membrane: vec![0.1, -0.2], + refractory: vec![0, 1], + last_spike_ms: vec![-1_000_000.0, 5.0], + visual_drive: vec![0.3], + rng: 42, + reward_remaining: 0.0, + ms: 1_234.5, + population_rate: 1.0, + rates: NumberMap::from_pairs([("forward", 1.0)]), + plasticity: PlasticityState { + version: "fly-kc-mbon-rstdp-v2".to_string(), + topology: 7, + enabled: true, + updates: 1.0, + signal: 0.0, + gains: vec![1.0], + traces: vec![0.0], + touched: vec![0.0], + }, + }, + decoder: DecoderState { + version: 4, + calibrated: true, + baseline: NumberMap::from_pairs([("forward", 1.0)]), + held_until: NumberMap::new(), + next_allowed: NumberMap::new(), + next_decision: 0.0, + current: None, + fatigue: NumberMap::new(), + macro_next_decision: 0.0, + macro_current: None, + macro_fatigue: NumberMap::new(), + }, + }; + let runtime = RuntimeState { + generation: 41, + wall_ms: 1_790_000_000_000, + rom_sha256: flybrain_gb::pokemon_red::SUPPORTED_ROM.to_string(), + emulator_frame: 1_000_000, + compatibility: compatibility("pokered-unique8-v5"), + speed: 1.0, + buttons: 0, + rank_since_ms: 1_000.0, + last_event_id: 4_242, + reward: v5_reward(), + ratchet: flybrain_gb::RatchetState { best: 3, attempts: 1, recoveries: 4, ..Default::default() }, + emulator: vec![3; 64], + framebuffer: vec![0; 32], + ratchet_game: vec![1], + ratchet_frame: vec![2], + }; + store::encode(&agent, &runtime).expect("the fixture encodes") +} + +#[test] +fn a_v5_checkpoint_is_refused_under_v6_without_the_opt_in() { + let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes"); + let adapter = PokemonRedReward::new(); + let current = compatibility(adapter.id()); + assert_ne!(checkpoint.runtime.compatibility, current, "v6 is not v5"); + + for opt_in in [None, Some(""), Some("pokered-unique8-v4"), Some("some-other-adapter")] { + assert!( + matches!( + decide( + &checkpoint.runtime.compatibility, + ¤t, + adapter.migrates_from(), + &accepted_adapters(opt_in), + ), + RestoreDecision::Refuse(_) + ), + "FLY_ACCEPT_ADAPTERS={opt_in:?} must not migrate anything" + ); + } +} + +#[test] +fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_zero() { + let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes"); + let mut adapter = PokemonRedReward::new(); + let current = compatibility(adapter.id()); + + assert_eq!( + decide( + &checkpoint.runtime.compatibility, + ¤t, + adapter.migrates_from(), + &accepted_adapters(Some("pokered-unique8-v5")), + ), + RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() } + ); + + // The migration itself: `import_state`, exactly as `Sim::try_restore` calls it. + adapter.import_state(&checkpoint.runtime.reward).expect("a v5 ledger is a valid v6 ledger"); + + let after = adapter.export_state(); + assert_eq!(after["catchCounts"], serde_json::json!({}), "the new counter starts at 0"); + assert_eq!(after["counts"]["catch"], serde_json::json!(0)); + + // And nothing else moved: every field the v5 state carried round-trips to the same value, + // and the only key v6 adds is the counter. + // + // `counts` is the one field that is not byte-identical, and it is not a change of meaning: + // it serializes every kind in the catalog, so a v6 state lists `catch` where a v5 state had + // nothing to list. Every kind the v5 state did carry keeps its number. + let before = v5_reward(); + for (key, value) in before.as_object().unwrap() { + if key == "counts" { + for (kind, count) in value.as_object().unwrap() { + assert_eq!(&after["counts"][kind], count, "counts.{kind}"); + } + let added: Vec<&String> = after["counts"] + .as_object() + .unwrap() + .keys() + .filter(|kind| !value.as_object().unwrap().contains_key(*kind)) + .collect(); + assert_eq!(added, vec!["catch"], "v6 counts one more kind and no others"); + continue; + } + assert_eq!(&after[key], value, "{key} must survive the migration byte for byte"); + } + let added: Vec<&String> = after + .as_object() + .unwrap() + .keys() + .filter(|key| !before.as_object().unwrap().contains_key(*key)) + .collect(); + assert_eq!(added, vec!["catchCounts"], "v6 adds one field and no others"); + + // The rest of what a restore reads is untouched by the migration. + assert_eq!(adapter.progress().rank, 3); + assert_eq!(checkpoint.runtime.last_event_id, 4_242); + assert_eq!(checkpoint.runtime.ratchet.best, 3); +} + +#[test] +fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() { + let adapter = PokemonRedReward::new(); + let accepted = accepted_adapters(Some("pokered-unique8-v5")); + let current = compatibility(adapter.id()); + + // A v5 string whose state format also moved: a different build, not a rule change. + let other_abi = compatibility("pokered-unique8-v5").replace("199616", "199617"); + assert!(matches!( + decide(&other_abi, ¤t, adapter.migrates_from(), &accepted), + RestoreDecision::Refuse(_) + )); + + // And an identical string needs no opt-in at all. + assert_eq!( + decide(¤t, ¤t, adapter.migrates_from(), &[]), + RestoreDecision::Exact + ); +} diff --git a/services/flysim/crates/flysim/tests/rom_catch.rs b/services/flysim/crates/flysim/tests/rom_catch.rs new file mode 100644 index 0000000..014048b --- /dev/null +++ b/services/flysim/crates/flysim/tests/rom_catch.rs @@ -0,0 +1,257 @@ +//! The catch reward against the real cartridge. +//! +//! Gated on `FLY_ROM` *and* on a checkpoint, the way every ROM test in this workspace is, and +//! skips cleanly without either — the cartridge never enters this repository and a checkpoint is +//! not a fixture, it is the state the release box was really in: +//! +//! ```sh +//! FLY_ROM="$HOME/roms/pokemon-red.gb" \ +//! FLY_CATCH_CHECKPOINT=.local/checkpoints/ \ +//! cargo test --release -p flysim --test rom_catch -- --nocapture +//! ``` +//! +//! ## What only the cartridge can answer +//! +//! The synthetic trace in `pokemon_red/tests.rs` writes `wCapturedMonSpecies`, `wBattleResult` +//! and the Pokédex bit itself, from the disassembly. It cannot say that those are the bytes +//! *this* cartridge writes when a ball keeps a Pokémon, in that order, on frames an adapter +//! sampling once a frame actually sees. That is this test, and it is the "survey" half of +//! `docs/design/macros-wram.md`'s evidence for the row: a real battle, real button presses, and +//! the byte read out of the running game rather than written into a fake one. +//! +//! ## How the catch is produced +//! +//! No steering and no scripted button sequence: the shipping macro palette, the shipping macro +//! layer and the shipping decoder, with a stub readout that leans on one macro population at a +//! time — the same driver `tests/rom_macros_mode.rs` uses and for the same reason. The one thing +//! this harness does that the rotation does not is lean on `THROW BALL`'s channel while a wild +//! battle is up, because the question here is what the adapter reads from a catch, not whether a +//! game-blind readout finds its way to one. +//! +//! The checkpoint must hold at least one ball in the bag. The macro palette can buy one +//! (`BUY BALL`, `MB·PBALL`, inside a mart), but that is a walk across a city and back and it is a +//! different test's question; this one says out loud that it skipped. + +use flybrain_core::decoder::PopulationDecoder; +use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros; +use flybrain_core::ordered::NumberMap; +use flybrain_gb::adapter::RewardEvent; +use flybrain_gb::pokemon_red::state; +use flybrain_gb::pokemon_red::symbols::ram; +use flybrain_gb::pokemon_red::{PokemonRedReward, catalog}; +use flybrain_gb::{ + AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, +}; +use flysim::config::Config; +use flysim::macros::{MacroLayer, macro_layer}; +use flysim::snapshot::MacroMode; + +const MS_PER_FRAME: f64 = 1000.0 / 59.7275; +const SEED: u32 = 20_260_922; +/// The hot population's rate against every other one's, which is also the stub's calibration +/// rate — so a channel that is not the hot one scores exactly 1.0. +const HOT: f64 = 16.0; +const REST: f64 = 10.0; +/// `THROW BALL`'s channel (`pokemon_red::macros::palette`). +const BALL: &str = "MB·BALL"; +/// Frames the stub leans on one channel before the rotation moves on, the shape of the real +/// group's hysteresis-then-fatigue rotation. +const BURST_FRAMES: u32 = 24; + +fn rates(hot: Option<&str>) -> NumberMap { + let mut rates = NumberMap::new(); + for channel in flybrain_gb::macro_channels("pokemon-red") { + rates.set(channel, REST); + } + for bucket in 0..8 { + rates.set(&format!("command_{bucket}"), REST); + } + if let Some(channel) = hot { + rates.set(channel, HOT); + } + rates +} + +fn rom() -> Option> { + let path = std::env::var_os("FLY_ROM")?; + match std::fs::read(&path) { + Ok(bytes) => Some(bytes), + Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"), + } +} + +fn checkpoint() -> Option { + let path = std::env::var_os("FLY_CATCH_CHECKPOINT")?; + Some( + flysim::store::load(std::path::Path::new(&path)) + .expect("the checkpoint should be a FLYSIM01 envelope"), + ) +} + +struct Run { + gb: Emulator, + adapter: PokemonRedReward, + layer: MacroLayer, + decoder: PopulationDecoder, + channels: Vec<&'static str>, + ms: f64, + frame: u32, + /// Every payout the adapter has made since the run started. + payouts: Vec, +} + +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"); + let mut adapter = PokemonRedReward::new(); + gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state"); + // A checkpoint written by an earlier adapter rebaselines rather than failing, which is + // exactly the `v5` -> `v6` case this rule ships with. + adapter.import_state(&checkpoint.runtime.reward).expect("the checkpoint's reward ledger"); + let channels = flybrain_gb::macro_channels("pokemon-red"); + let preset = gameboy_decoder_config_with_macros(&channels); + let hold_ms = preset.macros.as_ref().expect("the preset has a macro group").hold_ms; + let mut decoder = PopulationDecoder::new(preset).expect("the preset is well formed"); + decoder.calibrate(&rates(None)); + let mut config = Config::default(); + config.loop_.game = "pokemon-red".to_string(); + config.macros.mode = MacroMode::Macros; + config.validate().expect("pokemon-red has a palette in macros mode"); + let mut layer = macro_layer(&config, hold_ms, SEED).expect("a layer in macros mode"); + let _ = layer.observe(&mut gb, &AdapterLedger(&adapter), 0.0); + Self { + gb, + adapter, + layer, + decoder, + channels, + ms: 0.0, + frame: 0, + payouts: Vec::new(), + } + } + + fn byte(&mut self, address: u16) -> u8 { + self.gb.read_wram(address) + } + + fn in_wild_battle(&mut self) -> bool { + self.byte(ram::wIsInBattle) == 1 + } + + /// Balls in the bag, of any kind (`constants/item_constants.asm`: MASTER_BALL 1, + /// ULTRA_BALL 2, GREAT_BALL 3, POKE_BALL 4 — the same four `THROW BALL` looks for). + fn balls(&mut self) -> usize { + state::bag(&mut self.gb) + .iter() + .filter(|item| (0x01..=0x04).contains(&item.id) && item.count > 0) + .map(|item| usize::from(item.count)) + .sum() + } + + fn step(&mut self) { + // Lean on `THROW BALL` while a wild battle is up; otherwise rotate, which is what gets + // the fly into the grass in the first place. + let hot = if self.in_wild_battle() { + Some(BALL) + } else { + let slot = (self.frame / BURST_FRAMES) as usize % self.channels.len(); + Some(self.channels[slot]) + }; + let bound = self.layer.bound_channels(); + let active = self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound)); + let mask = { + let ledger = AdapterLedger(&self.adapter); + self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger).mask + }; + self.gb.set_buttons(mask as u8); + self.gb.run_frame().expect("a frame should complete"); + self.ms += MS_PER_FRAME; + self.frame += 1; + let ms = self.ms; + self.payouts.extend(self.adapter.sample(&mut self.gb, ms)); + let ledger = AdapterLedger(&self.adapter); + let _ = self.layer.observe(&mut self.gb, &ledger, ms); + } + + fn catches(&self) -> Vec<&RewardEvent> { + self.payouts.iter().filter(|event| event.kind == catalog::kind::CATCH).collect() + } +} + +#[test] +fn a_catch_on_the_cartridge_pays_the_catch_rule_once_with_the_species_in_its_label() { + let Some(rom) = rom() else { + eprintln!("skipped: FLY_ROM is not set"); + return; + }; + let Some(checkpoint) = checkpoint() else { + eprintln!("skipped: no FLY_CATCH_CHECKPOINT"); + return; + }; + let mut run = Run::resume(&rom, &checkpoint); + let balls = run.balls(); + if balls == 0 { + eprintln!( + "skipped: the checkpoint's bag holds no ball (map {:#04x}). `BUY BALL` can buy one \ + inside a mart; point FLY_CATCH_CHECKPOINT at a state that already has one.", + run.adapter.map_id().unwrap_or(u32::MAX) + ); + return; + } + eprintln!("bag holds {balls} balls; map {:#04x}", run.adapter.map_id().unwrap_or(u32::MAX)); + + // Twenty brain minutes is generous for a forest checkpoint: the live run threw 28 balls in + // its first Viridian Forest session (`pokemon_red::macros::palette`). + let budget = 20 * 60 * 60; + let mut battles = 0u32; + let mut was_in_battle = false; + for _ in 0..budget { + run.step(); + let now = run.in_wild_battle(); + if now && !was_in_battle { + battles += 1; + } + was_in_battle = now; + if !run.catches().is_empty() { + break; + } + } + + let catches = run.catches(); + assert!( + !catches.is_empty(), + "no catch in {:.1} brain minutes: {battles} wild battles, {} balls left, map {:#04x}, \ + macros {:?}", + run.ms / 60_000.0, + run.balls(), + run.adapter.map_id().unwrap_or(u32::MAX), + run.layer.counts() + ); + + let caught = catches[0]; + eprintln!( + "caught after {:.1} brain minutes and {battles} wild battles: {} for {}", + run.ms / 60_000.0, + caught.label, + caught.value + ); + assert!(caught.label.starts_with("CAUGHT #"), "{}", caught.label); + assert!( + (caught.value - 0.30).abs() < 1e-12 || caught.value == catalog::CATCH_REPEAT_VALUE, + "a catch pays one of the rule's two amounts, not {}", + caught.value + ); + // The cartridge's own flag is clear again by the time the payout lands, which is what makes + // the payout a battle-exit event rather than a per-frame one. + assert_eq!(run.byte(ram::wCapturedMonSpecies), 0); + assert_eq!( + run.adapter.progress().counts[catalog::kind::CATCH], + 1, + "one battle, one payout" + ); + // And a species the run is paid for catching is a species the Pokédex knows: the same event + // sets the bit the `species` rule reads, whether or not it was new to this run. + assert!(run.balls() < balls, "a ball was spent"); +} diff --git a/services/flysim/tools/gen_symbols.py b/services/flysim/tools/gen_symbols.py index 71ce56c..b499e43 100644 --- a/services/flysim/tools/gen_symbols.py +++ b/services/flysim/tools/gen_symbols.py @@ -200,6 +200,15 @@ EXTRA_RAM = ( 'wCurMapTileset', 'wTilesetBank', 'wTilesetBlocksPtr', + # The catch reward (`docs/rewards-learning.md`, `docs/design/macros-wram.md` section 2). + # ram/wram.asm's own comment is "0 if no mon was captured": ItemUseBall zeroes it before + # every throw and writes wEnemyMonSpecies into it only on the branch that keeps the + # Pokemon, and UseBagItem zeroes it again on the way out of the battle. It is the + # cartridge's own answer to "was 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. + # services/flysim/tools/resolve_wram.py is the second reading of it, from ram/wram.asm at + # this commit, bracketed by wFontLoaded and wForcePlayerToChooseMon. + 'wCapturedMonSpecies', ) diff --git a/services/flysim/tools/resolve_wram.py b/services/flysim/tools/resolve_wram.py index 4e67b06..c7973cb 100644 --- a/services/flysim/tools/resolve_wram.py +++ b/services/flysim/tools/resolve_wram.py @@ -49,6 +49,33 @@ WANTED = { # not bank 0, so this is the read the memory seam grew a bank for. 'wTilesetBank': 'the ROM bank the blockset lives in', 'wTilesetBlocksPtr': 'blocks to tiles, 16 bytes per block', + # The catch reward (`docs/rewards-learning.md`, `docs/design/macros-wram.md` + # section 10). ram/wram.asm's own comment is "0 if no mon was captured": + # ItemUseBall zeroes it before every throw and writes wEnemyMonSpecies into it + # only on the branch that keeps the caught Pokemon, and UseBagItem zeroes it + # again on the way out of the battle. It is the cartridge's own answer to "was + # this one caught", and the only signal that needs no second rule to tell a + # catch apart from a gift, a trade or an evolution. + 'wCapturedMonSpecies': 'the species a ball just caught, 0 for none', +} + + +#: Constants the decomp defines through its `const` enumeration rather than with a +#: plain `EQU`, so `constants()` cannot evaluate their expressions. They matter here +#: because `NUM_TMS + NUM_HMS` is the size of `wMonHLearnset`, and that one +#: declaration is what kills the cursor on its way through the battle engine's +#: scratch bytes -- the region `wCapturedMonSpecies` lives in. +#: +#: Each is *counted* from the decomp rather than written out by hand, which is the +#: same rule the rest of this tool follows. `DEF NUM_HMS EQU const_value - HM01` is +#: by construction the number of `add_hm` definitions after `HM01`, and +#: `item_constants.asm`'s own `ASSERT NUM_TMS == const_value - TM01` ties `NUM_TMS` +#: to the number of `add_tm` definitions -- so `NUM_TMS` is counted *and* compared +#: against the literal the same file declares, and a decomp that moved one without +#: the other stops the run instead of producing an address. +COUNTED = { + 'NUM_HMS': ('constants/item_constants.asm', r'^\s*add_hm\s+\w+'), + 'NUM_TMS': ('constants/item_constants.asm', r'^\s*add_tm\s+\w+'), } @@ -68,6 +95,10 @@ def constants(root: Path) -> dict[str, int]: BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left out, which kills the cursor at any declaration that uses it. """ + counted = { + name: len(re.findall(pattern, (root / path).read_text(), re.M)) + for name, (path, pattern) in COUNTED.items() + } pending: dict[str, str] = {} sources = sorted((root / 'constants').glob('*.asm')) + sorted( (root / 'constants').glob('*.inc') @@ -77,7 +108,7 @@ def constants(root: Path) -> dict[str, int]: r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M ): pending.setdefault(name, value.strip()) - out: dict[str, int] = {} + out: dict[str, int] = dict(counted) while pending: progressed = False for name in list(pending): @@ -89,6 +120,12 @@ def constants(root: Path) -> dict[str, int]: progressed = True if not progressed: break + for name, value in counted.items(): + if out.get(name, value) != value: + raise SystemExit( + f'{name}: the decomp declares {out[name]} and defines {value} of them' + ) + out[name] = value return out @@ -367,11 +404,17 @@ def main() -> None: raise SystemExit('the walk disagrees with symbols.rs; nothing emitted') print(f'{checked} of {len(table)} pinned addresses re-derived from wram.asm, no disagreement') - missing = [name for name in WANTED if name not in resolved] + # A name this tool has already emitted is pinned, so the walk meets it as an + # anchor rather than resolving it: it was re-derived all the same, and the + # comparison above is what says so. + missing = [name for name in WANTED if name not in resolved and name not in table] if missing: raise SystemExit(f'unanchored, so not resolved: {", ".join(missing)}') for name in WANTED: - print(f'{name} = ${resolved[name]:04x} ({WANTED[name]})') + if name in resolved: + print(f'{name} = ${resolved[name]:04x} ({WANTED[name]})') + else: + print(f'{name} = ${table[name]:04x} (already pinned; {WANTED[name]})') if not args.emit: return