Merge feat/catch-reward: a catch reward, adapter v6 with a v5 migration, and a rung restart

This commit is contained in:
acamilo 2026-09-22 18:41:15 +00:00
commit a8afd8f448
22 changed files with 1947 additions and 32 deletions

View file

@ -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 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 import and the segment records the shared tag; if not, milestone saves must be re-earned and that
is a stated M3 finding. 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 <N>` keeps it and rewinds it. It archives both stores to a
dated directory, rewrites `milestone-<N>.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 - **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, 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 like the readout's blocked-direction cooldown and for the same reason: a restore that resumed a

View file

@ -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 | | 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 | | 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 | | 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 ### Battle menu and cursor, own turn against forced switch

View file

@ -1,6 +1,6 @@
# Rewards and learning # 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 `services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the
gates and the rules); this page says what each rule pays for and why it is allowed to. The gates and the rules); this page says what each rule pays for and why it is allowed to. The
prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules
@ -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)` | | `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` |
| `badge` | `badge` | +3 | 400 ms | Each newly set badge bit | | `badge` | `badge` | +3 | 400 ms | Each newly set badge bit |
| `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger | | `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger |
| `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")` Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")`
is `None` by test. The values in one frame sum into `R`, and the network reinforces once with is `None` by test. The values in one frame sum into `R`, and the network reinforces once with
@ -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 `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. 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 #<species>`, 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 ## Gates
Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other
@ -128,7 +177,19 @@ body picks the macro; the descending neurons press the buttons.**
## Honesty ## 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: 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 - **still no button path.** Nothing in the adapter chooses or biases a button. The reward is read

63
infra/05-deploy.sh Executable file → Normal file
View file

@ -186,6 +186,36 @@ else
log "05-deploy: CPUSET unset — heavy in-container steps run unpinned (no partition configured)" log "05-deploy: CPUSET unset — heavy in-container steps run unpinned (no partition configured)"
fi 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. # 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 # 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 # a no-op on an unpartitioned container rather than a new failure mode (a
@ -262,6 +292,16 @@ if [ -n "$RELEASE_TARBALL" ]; then
# checkpoints and the on-screen chat ring's sidecar — so the new build warms # checkpoints and the on-screen chat ring's sidecar — so the new build warms
# up fresh. Everything learned so far is thrown away, which is why it is not # up fresh. Everything learned so far is thrown away, which is why it is not
# the default. # 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}" state_dir="${FLY_STATE_DIR:-/srv/fly/state}"
hot_dir="${FLY_STATE_HOT_DIR:-/run/fly/state}" hot_dir="${FLY_STATE_HOT_DIR:-/run/fly/state}"
@ -287,6 +327,11 @@ if [ -n "$RELEASE_TARBALL" ]; then
log "05-deploy: no decodable checkpoint in ${state_dir} — nothing to compare, continuing" log "05-deploy: no decodable checkpoint in ${state_dir} — nothing to compare, continuing"
elif [ "$new_compat" = "$live_compat" ]; then elif [ "$new_compat" = "$live_compat" ]; then
log "05-deploy: checkpoint compatibility matches the live state, the new build will restore it" 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 elif [ "${FLY_RESET_STATE:-0}" = 1 ]; then
archive="${state_dir}.$(date -u +%Y%m%d%H%M%S)" 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" log "05-deploy: FLY_RESET_STATE=1 — compatibility CHANGED, archiving the durable state to ${archive} and clearing the hot ring"
@ -300,8 +345,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. 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} live state: ${live_compat}
new build: ${new_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 * 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 * accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which
archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and
clears ${hot_dir} so the new build warms up fresh. clears ${hot_dir} so the new build warms up fresh.
@ -457,6 +506,16 @@ trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT
if [[ -n "${FLY_MACRO_BLOCKED_MINUTES:-}" ]]; then if [[ -n "${FLY_MACRO_BLOCKED_MINUTES:-}" ]]; then
echo "FLY_MACRO_BLOCKED_MINUTES=${FLY_MACRO_BLOCKED_MINUTES}" echo "FLY_MACRO_BLOCKED_MINUTES=${FLY_MACRO_BLOCKED_MINUTES}"
fi 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 (services/bridge/src/config.ts). Nothing wrote these before, so
# flybridge.service had no EnvironmentFile= at all and the service refused to # flybridge.service had no EnvironmentFile= at all and the service refused to
# start with "CHANNEL is required / BOT_USER is required / GAME_TITLE is # start with "CHANNEL is required / BOT_USER is required / GAME_TITLE is
@ -607,7 +666,7 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "05-deploy: converging bin/ helpers to /opt/fly/bin" log "05-deploy: converging bin/ helpers to /opt/fly/bin"
ct_exec "$CTID" -- mkdir -p /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 converge_file "$CTID" "$INFRA_DIR/bin/$name" "/opt/fly/bin/$name" 0755 root:root >/dev/null
done done

View file

@ -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-<N>.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 <N>
# 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 <rung> (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]*'"

View file

@ -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. is deliberate — a silent reset would be indistinguishable from real progress on stream.
A deliberate reset means moving `/srv/fly/state` aside by hand. 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 <N>` promotes `milestone-<N>.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-<UTC>` 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=<release-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 <release-env> <release-tarball>
# The gate logs "the adapter version is the only difference, and it is named; the run is KEPT
# and migrated", and writes FLY_ACCEPT_ADAPTERS into /etc/fly/fly.env so flysim applies the
# same rule at restore. Anything else about the string differing is still a refusal.
# 5. 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-<UTC>/durable` back into
`/srv/fly/state`, delete the generation the tool wrote, and start again.
## Restore from the backup host ## Restore from the backup host
``` ```

14
infra/env/example.env vendored
View file

@ -317,6 +317,20 @@ FLY_MACRO_MODE=raw
# target once more. Unset means the default, 10. # target once more. Unset means the default, 10.
# FLY_MACRO_BLOCKED_MINUTES=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 -------------------------------------------------------------- # --- push mode --------------------------------------------------------------
# local: flypush.service stays disabled, everything else identical to prod. # local: flypush.service stays disabled, everything else identical to prod.
# twitch: flypush.service is enabled by 07-enable.sh. # twitch: flypush.service is enabled by 07-enable.sh.

View file

@ -56,7 +56,7 @@ impl MemoryReader for &mut dyn MemoryReader {
/// One reward payout in one frame. /// One reward payout in one frame.
/// ///
/// `kind` is an adapter-owned interned name (Pokémon: `milestone`, /// `kind` is an adapter-owned interned name (Pokémon: `milestone`,
/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`); 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 /// key the statistics counters and the on-screen ticker group by. Field names
/// serialize exactly as the prototype's `RewardEvent` did, so a checkpoint /// serialize exactly as the prototype's `RewardEvent` did, so a checkpoint
/// written by either implementation reads in the other. /// 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. /// A game, as the sim loop sees it.
pub trait GameAdapter: Send { pub trait GameAdapter: Send {
/// Adapter version string, pinned into the checkpoint compatibility string. /// Adapter version string, pinned into the checkpoint compatibility string.
/// Pokémon: `pokered-unique8-v5`. /// Pokémon: `pokered-unique8-v6`.
fn id(&self) -> &'static str; 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 /// Whether semantic rewards are enabled for this cartridge. An adapter that
/// says no must still sample without paying anything, so the stream keeps /// says no must still sample without paying anything, so the stream keeps
/// running with a visible "rewards off" mode. /// running with a visible "rewards off" mode.
@ -470,6 +482,10 @@ mod tests {
let platformer = let platformer =
adapter_for_with_rom_pin("platformer", Some(&"a".repeat(64))).unwrap(); adapter_for_with_rom_pin("platformer", Some(&"a".repeat(64))).unwrap();
assert_ne!(pokemon.id(), platformer.id()); 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()); assert_ne!(pokemon.symbol_provenance(), platformer.symbol_provenance());
// And two ROM revisions of the same game cannot either. // And two ROM revisions of the same game cannot either.
let other = adapter_for_with_rom_pin("platformer", Some(&"b".repeat(64))).unwrap(); let other = adapter_for_with_rom_pin("platformer", Some(&"b".repeat(64))).unwrap();

View file

@ -30,7 +30,7 @@ pub const PROTOTYPE_PLASTICITY_VERSION: &str = "fly-kc-mbon-rstdp-v2";
pub struct Compatibility<'a> { pub struct Compatibility<'a> {
/// `kernelVersion(config)` from the neural library. /// `kernelVersion(config)` from the neural library.
pub neural_kernel_version: &'a str, pub neural_kernel_version: &'a str,
/// The adapter's version string, e.g. `pokered-unique8-v5`. /// The adapter's version string, e.g. `pokered-unique8-v6`.
pub adapter: &'a str, pub adapter: &'a str,
/// The dataset's seven SHA-256 digests joined with `:`. /// The dataset's seven SHA-256 digests joined with `:`.
pub dataset_fingerprint: &'a str, pub dataset_fingerprint: &'a str,
@ -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<usize> = (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<String> {
value
.unwrap_or_default()
.split([',', ' ', '\t', '\n'])
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(str::to_string)
.collect()
}
/// `<state size>-<target triple>`: the two things that decide whether a /// `<state size>-<target triple>`: the two things that decide whether a
/// binjgb save state written elsewhere can be memcpy'd back in here. /// binjgb save state written elsewhere can be memcpy'd back in here.
pub fn state_format_id() -> String { pub fn state_format_id() -> String {
@ -94,7 +180,7 @@ mod tests {
assert_eq!( assert_eq!(
fixture().prototype_string(), fixture().prototype_string(),
concat!( 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/", "fly-kc-mbon-rstdp-v2/",
"binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/", "binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/",
"pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "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(&current, &current, &[], &[]), 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] #[test]
fn the_state_format_segment_is_appended_not_interleaved() { fn the_state_format_segment_is_appended_not_interleaved() {
let full = fixture().string(); let full = fixture().string();

View file

@ -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 //! A direct port of the prototype's `src/reward/catalog.ts`, including the
//! declaration order, which is the order `counts` and `last` serialize in. //! declaration order, which is the order `counts` and `last` serialize in.
@ -20,8 +20,18 @@ pub mod kind {
pub const BATTLE: &str = "battle"; pub const BATTLE: &str = "battle";
pub const BADGE: &str = "badge"; pub const BADGE: &str = "badge";
pub const BOUNDARY: &str = "boundary"; pub const BOUNDARY: &str = "boundary";
pub const CATCH: &str = "catch";
} }
/// 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)] #[derive(Debug, Clone, Copy)]
pub struct RewardRule { pub struct RewardRule {
pub kind: &'static str, pub kind: &'static str,
@ -34,7 +44,7 @@ pub struct RewardRule {
pub stimulation_ms: u32, pub stimulation_ms: u32,
} }
pub const REWARDS: [RewardRule; 8] = [ pub const REWARDS: [RewardRule; 9] = [
RewardRule { RewardRule {
kind: kind::MILESTONE, kind: kind::MILESTONE,
label: "Story", label: "Story",
@ -99,6 +109,22 @@ pub const REWARDS: [RewardRule; 8] = [
value: 0.05, value: 0.05,
stimulation_ms: 100, 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 /// Position of `kind` in [`REWARDS`], or `None` for an unknown kind. This is
@ -199,10 +225,34 @@ mod tests {
// separate kinds. // separate kinds.
assert_eq!(rule(kind::BOUNDARY).unwrap().value, 0.05); assert_eq!(rule(kind::BOUNDARY).unwrap().value, 0.05);
assert_eq!(rule(kind::BOUNDARY).unwrap().stimulation_ms, 100); 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!(rule("blackout").is_none(), "the catalog has no penalties");
assert!(REWARDS.iter().all(|rule| rule.value > 0.0)); assert!(REWARDS.iter().all(|rule| rule.value > 0.0));
} }
#[test]
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] #[test]
fn empty_counts_lists_every_kind_at_zero() { fn empty_counts_lists_every_kind_at_zero() {
let counts = Counts::default(); let counts = Counts::default();

View file

@ -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 //! A port of the prototype's `src/reward/pokemon-red.ts`. The gates and budgets
//! are unchanged; `docs/rewards-learning.md` holds the live rule table and the //! are unchanged; `docs/rewards-learning.md` holds the live rule table and the
//! source evidence behind each gate. v4 replaced the 16-rung boot-to-badges //! 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, //! ladder with the 38 rungs of `docs/design/ladder.md`; v5 adds one reward rule,
//! `boundary` (`docs/design/room-escape.md` section 2), which pays the first step //! `boundary` (`docs/design/room-escape.md` section 2), which pays the first step
//! next to and the first step onto each of a map's exits. //! 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; pub mod catalog;
#[cfg(test)] #[cfg(test)]
@ -32,13 +33,33 @@ use symbols::ram;
/// Adapter version, pinned into the checkpoint compatibility string. /// Adapter version, pinned into the checkpoint compatibility string.
/// ///
/// `v5` is the `boundary` rule. Bumping it is what rejects every checkpoint written /// `v6` is the `catch` rule. Bumping it is what makes a `v5` checkpoint a decision
/// by `v4`: the string is compared whole before a restore is attempted, so a ledger /// rather than an accident: the compatibility string is compared whole before a
/// that has never recorded a single `boundary:` key can never be resumed as though /// restore is attempted, so a `v5` run is refused by default and resumed only when
/// its exits were already collected. (`v4` was the 38-rung ladder, and rejected /// the operator names it in `FLY_ACCEPT_ADAPTERS`
/// `v3` for the same reason: a stored rank that meant "4 badges" on the old ladder /// ([`crate::compatibility::RestoreDecision`], `docs/design/flysim.md`). That
/// could not be read as a rung on the new one.) Pre-launch, so no run is lost. /// migration is safe in one direction only, and only for this pair: `v5`'s ledger is
pub const REWARD_ADAPTER: &str = "pokered-unique8-v5"; /// 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 /// The only cartridge semantic rewards are enabled for. Even the canonical
/// pret build stays disabled until reviewed; see `docs/rewards-learning.md`. /// pret build stays disabled until reviewed; see `docs/rewards-learning.md`.
@ -60,8 +81,22 @@ pub const SUPPORTED_ROM: &str =
/// [`REWARD_ADAPTER`] is the gate that refuses such a checkpoint anyway, and it is /// [`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 /// the right gate, because the objection to loading one is about semantics rather
/// than shape. /// 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; 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] = [ const BADGE_NAMES: [&str; 8] = [
"BOULDER", "CASCADE", "THUNDER", "RAINBOW", "SOUL", "MARSH", "VOLCANO", "EARTH", "BOULDER", "CASCADE", "THUNDER", "RAINBOW", "SOUL", "MARSH", "VOLCANO", "EARTH",
]; ];
@ -325,6 +360,26 @@ struct Battle {
wild: bool, wild: bool,
saw_living: bool, saw_living: bool,
ko: 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<u64>,
/// The internal species index `wCapturedMonSpecies` named, once a ball has kept one.
captured: Option<u8>,
/// 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 /// Immutable per-sample byte cache. Each address requested during one sample
@ -370,6 +425,10 @@ pub struct PokemonRedReward {
tiles: OrderedSet, tiles: OrderedSet,
tile_counts: BTreeMap<u8, u64>, tile_counts: BTreeMap<u8, u64>,
wild_wins: BTreeMap<String, u64>, wild_wins: BTreeMap<String, u64>,
/// 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<String, u64>,
replay_blocked: OrderedSet, replay_blocked: OrderedSet,
counts: Counts, counts: Counts,
total: f64, total: f64,
@ -420,6 +479,7 @@ impl PokemonRedReward {
tiles: OrderedSet::new(), tiles: OrderedSet::new(),
tile_counts: BTreeMap::new(), tile_counts: BTreeMap::new(),
wild_wins: BTreeMap::new(), wild_wins: BTreeMap::new(),
catch_counts: BTreeMap::new(),
replay_blocked: OrderedSet::new(), replay_blocked: OrderedSet::new(),
counts: Counts::default(), counts: Counts::default(),
total: 0.0, total: 0.0,
@ -567,8 +627,9 @@ impl PokemonRedReward {
} }
/// Forget observations a rollback invalidates. Lifetime novelty survives, /// Forget observations a rollback invalidates. Lifetime novelty survives,
/// and every wild-KO key paid so far is blocked from paying again, because /// and every wild-KO key and every caught species paid so far is blocked from
/// after a rollback the same battle could otherwise be replayed for reward. /// 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) { pub fn clear_transient(&mut self) {
self.location.clear(); self.location.clear();
self.stable = 0; self.stable = 0;
@ -578,6 +639,10 @@ impl PokemonRedReward {
for key in keys { for key in keys {
self.replay_blocked.insert(&key); self.replay_blocked.insert(&key);
} }
let caught: Vec<String> = 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. /// 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 { if in_battle == 1 || in_battle == 2 || in_battle == 255 {
self.mode = "BATTLE".to_string(); self.mode = "BATTLE".to_string();
self.stable = 0; self.stable = 0;
let species_paid = self.counts.get(kind::SPECIES);
if self.battle.is_none() && in_battle != 255 { if self.battle.is_none() && in_battle != 255 {
self.battle = Some(Battle { self.battle = Some(Battle {
key: battle_key(memory, map), key: battle_key(memory, map),
wild: in_battle == 1, wild: in_battle == 1,
saw_living: false, saw_living: false,
ko: 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 { if let Some(battle) = &mut self.battle {
let hp = word(memory, ram::wEnemyMonHP); let hp = word(memory, ram::wEnemyMonHP);
let max = word(memory, ram::wEnemyMonMaxHP); let max = word(memory, ram::wEnemyMonMaxHP);
@ -710,6 +790,11 @@ impl PokemonRedReward {
if battle.saw_living && hp == 0 { if battle.saw_living && hp == 0 {
battle.ko = true; 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 { } else if in_battle == 0 {
self.mode = "OVERWORLD".to_string(); self.mode = "OVERWORLD".to_string();
@ -728,6 +813,41 @@ impl PokemonRedReward {
} }
self.wild_wins.insert(battle.key.clone(), (count + 1).min(3)); 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}"); let location = format!("{map}:{x}:{y}");
self.stable = if self.location == location { self.stable + 1 } else { 1 }; self.stable = if self.location == location { self.stable + 1 } else { 1 };
@ -825,13 +945,33 @@ impl PokemonRedReward {
label: String, label: String,
scale: f64, scale: f64,
brain_ms: 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<RewardEvent>,
kind: &'static str,
label: String,
value: f64,
brain_ms: f64,
) { ) {
let rule = catalog::rule(kind).expect("emit is only called with catalog kinds"); let rule = catalog::rule(kind).expect("emit is only called with catalog kinds");
let event = RewardEvent { let event = RewardEvent {
kind, kind,
label, label,
brain_ms, brain_ms,
value: rule.value * scale, value,
stimulation_ms: rule.stimulation_ms, stimulation_ms: rule.stimulation_ms,
}; };
emitted.push(event.clone()); emitted.push(event.clone());
@ -991,6 +1131,10 @@ impl PokemonRedReward {
"tiles": self.tiles.as_slice(), "tiles": self.tiles.as_slice(),
"tileCounts": self.tile_counts, "tileCounts": self.tile_counts,
"wildWins": self.wild_wins, "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(), "replayBlocked": self.replay_blocked.as_slice(),
"counts": self.counts, "counts": self.counts,
"total": self.total, "total": self.total,
@ -1011,6 +1155,9 @@ impl PokemonRedReward {
"wild": battle.wild, "wild": battle.wild,
"sawLiving": battle.saw_living, "sawLiving": battle.saw_living,
"ko": battle.ko, "ko": battle.ko,
"speciesAtStart": battle.species_at_start,
"captured": battle.captured,
"capturedNew": battle.captured_new,
})), })),
"mode": self.mode, "mode": self.mode,
}) })
@ -1056,6 +1203,13 @@ impl PokemonRedReward {
let counts_raw = counted_record(input.get("counts")).ok_or(BAD_CHECKPOINT)?; 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 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)?; 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 let recent = recent_raw
.iter() .iter()
@ -1078,6 +1232,19 @@ impl PokemonRedReward {
wild: value.get("wild").and_then(Value::as_bool).ok_or(BAD_HISTORY)?, 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)?, 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)?, 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") { let replay_blocked = match input.get("replayBlocked") {
@ -1103,6 +1270,7 @@ impl PokemonRedReward {
} }
self.tile_counts = tile_counts; self.tile_counts = tile_counts;
self.wild_wins = wild_wins_raw; self.wild_wins = wild_wins_raw;
self.catch_counts = catch_counts;
self.replay_blocked = replay_blocked.iter().map(String::as_str).collect(); self.replay_blocked = replay_blocked.iter().map(String::as_str).collect();
self.counts = Counts::default(); self.counts = Counts::default();
for (key, count) in &counts_raw { for (key, count) in &counts_raw {
@ -1174,6 +1342,10 @@ impl GameAdapter for PokemonRedReward {
REWARD_ADAPTER REWARD_ADAPTER
} }
fn migrates_from(&self) -> &'static [&'static str] {
MIGRATES_FROM
}
fn rom_allowed(&self, sha256: &str) -> bool { fn rom_allowed(&self, sha256: &str) -> bool {
sha256 == SUPPORTED_ROM sha256 == SUPPORTED_ROM
} }

View file

@ -47,6 +47,7 @@ pub mod ram {
pub const wBattleType: u16 = 0xd05a; // 53338 pub const wBattleType: u16 = 0xd05a; // 53338
pub const wTrainerNo: u16 = 0xd05d; // 53341 pub const wTrainerNo: u16 = 0xd05d; // 53341
pub const wPartyMenuTypeOrMessageID: u16 = 0xd07d; // 53373 pub const wPartyMenuTypeOrMessageID: u16 = 0xd07d; // 53373
pub const wCapturedMonSpecies: u16 = 0xd11c; // 53532
pub const wForcePlayerToChooseMon: u16 = 0xd11f; // 53535 pub const wForcePlayerToChooseMon: u16 = 0xd11f; // 53535
pub const wTextBoxID: u16 = 0xd125; // 53541 pub const wTextBoxID: u16 = 0xd125; // 53541
pub const wPartyCount: u16 = 0xd163; // 53603 pub const wPartyCount: u16 = 0xd163; // 53603

View file

@ -83,6 +83,34 @@ impl Fixture {
self.visit(x, 0) 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<u16>) -> Vec<RewardEvent> {
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 /// 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. /// `(x, y)`, the layout `ram/wram.asm` documents at the pinned commit.
fn warps(&mut self, warps: &[(u8, u8)]) { 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); 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] #[test]
fn a_repeated_wild_ko_decays_then_stops() { fn a_repeated_wild_ko_decays_then_stops() {
let mut f = Fixture::new(); let mut f = Fixture::new();
@ -378,6 +536,7 @@ fn malformed_checkpoint_fields_are_named_in_the_error() {
("counts", json!([])), ("counts", json!([])),
("tileCounts", json!("not a record")), ("tileCounts", json!("not a record")),
("wildWins", json!(3)), ("wildWins", json!(3)),
("catchCounts", json!(3)),
] { ] {
let mut broken = good.clone(); let mut broken = good.clone();
broken[field] = wrong; broken[field] = wrong;
@ -706,7 +865,8 @@ fn the_recent_ticker_keeps_the_newest_eight_events_newest_first() {
#[test] #[test]
fn the_adapter_reports_its_identity_and_pinned_rom() { fn the_adapter_reports_its_identity_and_pinned_rom() {
let reward = PokemonRedReward::new(); let reward = PokemonRedReward::new();
assert_eq!(reward.id(), "pokered-unique8-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(SUPPORTED_ROM));
assert!(!reward.rom_allowed( assert!(!reward.rom_allowed(
"5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b" "5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b"
@ -716,6 +876,9 @@ fn the_adapter_reports_its_identity_and_pinned_rom() {
assert_eq!(symbols::ram::wNumberOfWarps, 0xd3ae); assert_eq!(symbols::ram::wNumberOfWarps, 0xd3ae);
assert_eq!(symbols::ram::wWarpEntries, 0xd3af); assert_eq!(symbols::ram::wWarpEntries, 0xd3af);
assert_eq!(symbols::ram::wCurMapConnections, 0xd370); 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); assert_eq!(symbols::MILESTONES.len(), 17);
} }

View file

@ -30,6 +30,7 @@ pub mod metrics;
pub mod pacing; pub mod pacing;
pub mod profile; pub mod profile;
pub mod ratelimit; pub mod ratelimit;
pub mod reset;
pub mod sdnotify; pub mod sdnotify;
pub mod simloop; pub mod simloop;
pub mod snapshot; pub mod snapshot;

View file

@ -42,6 +42,15 @@ struct Args {
/// the "nothing to compare" case for a fresh container. /// the "nothing to compare" case for a fresh container.
#[arg(long, value_name = "DIR")] #[arg(long, value_name = "DIR")]
print_state_compatibility: Option<std::path::PathBuf>, print_state_compatibility: Option<std::path::PathBuf>,
/// 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<u32>,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@ -66,6 +75,17 @@ fn main() -> Result<()> {
return Ok(()); 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) flysim::run(config)
} }

View file

@ -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-<N>.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-<rank>.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<String>;
/// 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<Report> {
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<u32, u64> =
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<usize> {
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-<r>.checkpoint`
/// for `r <= keep_up_to`. Returns the rungs kept, ascending.
fn clear_store(dir: &Path, keep_up_to: Option<u32>) -> Result<Vec<u32>> {
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::<u32>().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<usize> {
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<u8> {
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")
);
}
}

View file

@ -718,12 +718,36 @@ impl Sim {
if runtime.rom_sha256 != self.rom_sha256 { if runtime.rom_sha256 != self.rom_sha256 {
bail!("checkpoint is for another cartridge ({})", runtime.rom_sha256); bail!("checkpoint is for another cartridge ({})", runtime.rom_sha256);
} }
if runtime.compatibility != self.compatibility { // Byte-identical, or the one documented migration the operator asked for.
bail!( //
"compatibility mismatch\n checkpoint: {}\n this build: {}", // `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, runtime.compatibility,
self.compatibility self.compatibility
); ),
} }
if runtime.framebuffer.len() != FRAMEBUFFER_LEN { if runtime.framebuffer.len() != FRAMEBUFFER_LEN {
bail!("checkpoint framebuffer is {} bytes", runtime.framebuffer.len()); bail!("checkpoint framebuffer is {} bytes", runtime.framebuffer.len());

View file

@ -241,8 +241,8 @@ pub struct FeedMacroOutcome {
} }
/// Reward categories the feed reports counts for. The adapter's own interned kinds /// Reward categories the feed reports counts for. The adapter's own interned kinds
/// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`) map /// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`,
/// onto these. /// `catch`) map onto these.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum RewardKind { pub enum RewardKind {
@ -282,6 +282,15 @@ impl RewardKind {
// protocol is concerned: finding a door is finding somewhere new, and the design asks // protocol is concerned: finding a door is finding somewhere new, and the design asks
// for no new feed kind. // for no new feed kind.
"boundary" => Self::Explore, "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. // The platformer.
"band" => Self::Explore, "band" => Self::Explore,
"coin" => Self::Wildwin, "coin" => Self::Wildwin,
@ -739,6 +748,7 @@ mod tests {
} }
assert_eq!(RewardKind::from_adapter("nonsense"), None); assert_eq!(RewardKind::from_adapter("nonsense"), None);
assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore)); assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore));
assert_eq!(RewardKind::from_adapter("catch"), Some(RewardKind::Wildwin));
} }
#[test] #[test]

View file

@ -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<u8> {
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,
&current,
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,
&current,
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, &current, adapter.migrates_from(), &accepted),
RestoreDecision::Refuse(_)
));
// And an identical string needs no opt-in at all.
assert_eq!(
decide(&current, &current, adapter.migrates_from(), &[]),
RestoreDecision::Exact
);
}

View file

@ -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/<a rung-9 forest checkpoint> \
//! 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<Vec<u8>> {
let path = std::env::var_os("FLY_ROM")?;
match std::fs::read(&path) {
Ok(bytes) => Some(bytes),
Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"),
}
}
fn checkpoint() -> Option<flysim::store::Checkpoint> {
let path = std::env::var_os("FLY_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<RewardEvent>,
}
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");
}

View file

@ -200,6 +200,15 @@ EXTRA_RAM = (
'wCurMapTileset', 'wCurMapTileset',
'wTilesetBank', 'wTilesetBank',
'wTilesetBlocksPtr', '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',
) )

View file

@ -49,6 +49,33 @@ WANTED = {
# not bank 0, so this is the read the memory seam grew a bank for. # not bank 0, so this is the read the memory seam grew a bank for.
'wTilesetBank': 'the ROM bank the blockset lives in', 'wTilesetBank': 'the ROM bank the blockset lives in',
'wTilesetBlocksPtr': 'blocks to tiles, 16 bytes per block', '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 BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left
out, which kills the cursor at any declaration that uses it. out, which kills the cursor at any declaration that uses it.
""" """
counted = {
name: len(re.findall(pattern, (root / path).read_text(), re.M))
for name, (path, pattern) in COUNTED.items()
}
pending: dict[str, str] = {} pending: dict[str, str] = {}
sources = sorted((root / 'constants').glob('*.asm')) + sorted( sources = sorted((root / 'constants').glob('*.asm')) + sorted(
(root / 'constants').glob('*.inc') (root / 'constants').glob('*.inc')
@ -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 r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M
): ):
pending.setdefault(name, value.strip()) pending.setdefault(name, value.strip())
out: dict[str, int] = {} out: dict[str, int] = dict(counted)
while pending: while pending:
progressed = False progressed = False
for name in list(pending): for name in list(pending):
@ -89,6 +120,12 @@ def constants(root: Path) -> dict[str, int]:
progressed = True progressed = True
if not progressed: if not progressed:
break 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 return out
@ -367,11 +404,17 @@ def main() -> None:
raise SystemExit('the walk disagrees with symbols.rs; nothing emitted') 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') 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: if missing:
raise SystemExit(f'unanchored, so not resolved: {", ".join(missing)}') raise SystemExit(f'unanchored, so not resolved: {", ".join(missing)}')
for name in WANTED: 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: if not args.emit:
return return