Merge main into feat/sf-state-01

This commit is contained in:
dev 2026-09-22 18:45:56 +00:00
commit 388e112ad5
29 changed files with 2112 additions and 74 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
import and the segment records the shared tag; if not, milestone saves must be re-earned and that
is a stated M3 finding.
- **Restoring across an adapter version** (2026-09-22). The compatibility string is compared
whole, so bumping the reward adapter refuses every checkpoint the previous one wrote -- which is
the right default and was, until now, the only behaviour. It is the wrong default for a change
that only *adds* a rule: `pokered-unique8-v6` adds the catch reward and one counter,
`catchCounts`, and means the same thing as `v5` for every other field, so a `v5` run is
resumable and throwing it away would be a choice nobody made deliberately.
So there is one narrow, opt-in migration, `flybrain_gb::compatibility::decide`, and it requires
**all three** of:
1. the two compatibility strings differ in the adapter segment (segment 1) and **nowhere else**.
A dataset, kernel, plasticity, emulator-revision, symbol-provenance or state-format
difference is still a refusal: none of those has a migration, and a fly restored across one
is a different fly;
2. the running adapter's `migrates_from()` lists the checkpoint's adapter, so the code that will
read that state says out loud that it can. Pokémon Red's list is `["pokered-unique8-v5"]` and
nothing else -- `v4` is excluded because its ledger holds no `boundary:` keys and resuming it
would pay a second time for every exit already found, and `v3` because its stored rank is a
rung on a different ladder;
3. the deploy names the same adapter id in **`FLY_ACCEPT_ADAPTERS`** (comma- or
space-separated). Unset or empty migrates nothing, which is what every deploy before this one
did.
Condition 2 without 3 would make the migration silent; condition 3 without 2 would let an
operator wave through a pair nobody wrote a migration for. `infra/05-deploy.sh`'s compatibility
gate applies the same rule before it flips the `current` symlink, and writes the variable into
`/etc/fly/fly.env` so flysim applies it at restore -- the two must agree, or a deploy would pass
a gate that flysim then fails, which is the black stream the gate exists to prevent. The
migration itself is `PokemonRedReward::import_state` doing what it already did: `catchCounts` is
absent from a `v5` state and restores empty, which is the truth about a run that was never paid
for a catch. `STATE_VERSION` does not move, because the schema did not.
- **Restarting a run from an earlier rung** (2026-09-22). `FLY_RESET_STATE=1` throws the run away;
`infra/bin/fly-reset-to-milestone <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
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

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 |
| the enemy | `wEnemyMonSpecies`, `wEnemyMonHP`, `wEnemyMonLevel`, `wEnemyMonMaxHP` | `$cfe5`, `$cfe6`, `$cff3`, `$cff4` | HP big-endian. Not written on the frame a battle starts — the reward adapter's own comment says the same — so the enemy is `None` for the first few hundred frames of a battle. | ROM (the rival's Squirtle, level 5, 20/20, and `None` on the first frame), trace |
| how many moves | `wNumMovesMinusOne` | `$cd6c` | the move count minus one, valid in a battle | trace |
| **a ball kept this one** | `wCapturedMonSpecies` | `$d11c` | **new 2026-09-22** (the catch reward, `docs/rewards-learning.md`). `ram/wram.asm`'s own comment is "0 if no mon was captured". `ItemUseBall` zeroes it before every throw (`.canUseBall`) and writes `wEnemyMonSpecies` into it only on the branch that keeps the Pokémon; `UseBagItem`'s `.returnAfterCapturingMon` zeroes it again and sets `wBattleResult` to 2 on the way out of the battle. It is therefore non-zero for the hundreds of frames the catch's text and Pokédex screen take, and zero everywhere else. The value is the **internal** species index, like `wEnemyMonSpecies` and unlike `wPokedexOwned`'s bit index. Address resolved by `services/flysim/tools/resolve_wram.py`, bracketed by `wFontLoaded` and `wForcePlayerToChooseMon`. | survey (`tests/rom_catch.rs`: a real wild battle from a rung-9 checkpoint, balls thrown by the `THROW BALL` macro, the byte read out of the running game), trace (`pokemon_red/tests.rs`) |
`wBattleResult` (`$cf0b`) is the second half of that row and is worth its own sentence: it is 0
for a win, 1 for a loss, and 2 on exactly two paths in the whole game -- `.returnAfterCapturingMon`
and a *link* battle whose opponent ran (`engine/battle/core.asm`), which this cartridge never has.
So "the captured-species byte was non-zero during the battle **and** the result is 2" is a catch
and nothing else. `InitBattleVariables`, `ResetStatusAndHalveMoneyOnBlackout` and
`HandleFlyWarpOrDungeonWarp` all clear it, so a stale 2 cannot survive into the next battle.
Not used for the catch, and why: `wPartyCount` (`$d163`) rises on a catch **only** when the party
has room -- a full party sends the Pokémon to `wBoxCount` instead -- and it also rises for a gift,
a trade and a Pokémon withdrawn from the PC. Reading a catch off it would need a second rule to
tell those apart. The cartridge's own flag needs none, which is why the row above is the one the
adapter reads.
### Battle menu and cursor, own turn against forced switch

View file

@ -153,15 +153,15 @@ and once over a Unix socket, so `tests/rpc.rs::request_reply_roundtrip` means
| `latest`: one queued value, replacing only an undelivered one; replacement releases that entry's roots; delivered or in-use messages are never reclaimed early; maxQueued is exactly 1 | conforms | `router/state.rs::{op_publish (latest branch), op_subscribe}` | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both), `tests/conformance_artifacts.rs::latest_mode_holds_at_most_two_roots_delivered_plus_queued` (both) |
| `bounded`: FIFO, no coalescing or silent loss; when capacity is unavailable, reject with `BACKPRESSURE` before admitting any delivery | conforms | `router/state.rs::op_publish` (pre-checks every subscriber) | `tests/pubsub.rs::bounded_fifo_and_atomic_backpressure` (both), `tests/conformance_routing.rs::bounded_overflow_rolls_back_all_artifact_roots` (both) |
| maxInFlight credits return only on `delivery.consumed`, not on socket write completion | conforms | `router/state.rs::release_owner` (credit returned when the owner is released) | `tests/pubsub.rs::credits_return_only_on_consume` (both), `tests/conformance_routing.rs::bounded_credit_waits_for_every_extracted_artifact` (both) |
| A latest subscriber with all credits in use still has one replaceable queued value | conforms | `router/state.rs::{Sub::queue, dispatch_topic}` (queue and credits are separate) | `tests/bus_acceptance.rs::both_transports_produce_equivalent_behaviour_traces` (events 21 and 24: `publish replaced=1`, then `latest seq=3 replaced=1`) |
| A latest subscriber with all credits in use still has one replaceable queued value | conforms | `router/state.rs::{Sub::queue, dispatch_topic}` (queue and credits are separate) | `tests/bus_acceptance.rs::both_transports_produce_equivalent_behaviour_traces` (events 21 and 24: `publish replaced=1`, then `latest seq=3 replaced=1`), `tests/integration.rs::session_over_one_router` (both: a renderer held for the whole run keeps one delivery in flight and one replaceable value, and receives snapshots 1 and 20 of 20) |
| Atomic subscriber/retention snapshot at admission; validate and reserve every queue entry and owner budget before accepting | conforms: one mutex, validate-then-mutate | `router/state.rs::op_publish` | `tests/artifacts.rs::failed_admission_is_atomic` (both) |
| A bounded overflow rejects the whole publish: no partial fan-out, no retained-latest update | conforms | `router/state.rs::op_publish` | `tests/conformance_routing.rs::bounded_overflow_rolls_back_all_artifact_roots` (both) |
| On acceptance, one `topicSequence` and roots for every delivery and the optional retained value | conforms; a refused publication spends no sequence number | `router/state.rs::op_publish` (`t.sequence += 1` after the checks) | `tests/pubsub.rs::bounded_fifo_and_atomic_backpressure` (both) |
| Different topics have no total ordering; multiple publishers follow router acceptance order | conforms: per-topic sequence only | `router/state.rs::Topic::sequence` | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| The publication reply counts accepted subscriptions and replaced queue entries, not consumers that processed data | conforms | `router/state.rs::op_publish` reply | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both) |
| `replaced` on a delivery reports how many undelivered messages were coalesced since that subscription's preceding delivery | conforms | `router/state.rs::{Sub::replaced, dispatch_topic}` (taken at dispatch) | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both) |
| `replaced` on a delivery reports how many undelivered messages were coalesced since that subscription's preceding delivery | conforms | `router/state.rs::{Sub::replaced, dispatch_topic}` (taken at dispatch) | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both), `tests/integration.rs::session_over_one_router` (both: the 18 replacements the publisher was told about at admission are the same 18 the renderer is told about on delivery, and are exactly the snapshots it did not receive) |
| Optional `retained:latest` holds one last message and its artifacts independent of subscribers | conforms | `router/state.rs::op_publish` (retain branch) | `tests/conformance_artifacts.rs::retained_topic_value_holds_a_root_independent_of_subscribers` (both) |
| `replayLatest` enqueues the retained value before subsequent accepted publications; bounded preserves the order, latest may coalesce it | conforms | `router/state.rs::op_subscribe` (replay is enqueued under the subscribe lock) | `tests/conformance_routing.rs::latest_replay_is_ordered_ahead_of_a_racing_publish` (both), `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| `replayLatest` enqueues the retained value before subsequent accepted publications; bounded preserves the order, latest may coalesce it | conforms | `router/state.rs::op_subscribe` (replay is enqueued under the subscribe lock) | `tests/conformance_routing.rs::latest_replay_is_ordered_ahead_of_a_racing_publish` (both: one racing publication to a bounded and a latest subscription at once; bounded must deliver replay then publication, and the latest branch is chosen by that publication's own `replaced` count, never by which side of the race the dispatcher won), `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| Replay uses the original topicSequence, a fresh deliveryId and explicit roots | conforms | `router/state.rs::op_subscribe` (`add_roots`, the same `Arc<TopicMsg>`) | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| Without retention, a zero-subscriber publication retains no ownership after admission | conforms | `router/state.rs::op_publish` | `tests/pubsub.rs::zero_subscriber_publish_retains_nothing` (both) |
| Clearing a topic releases only its retained root, not active consumers | conforms | `router/state.rs::op_clear` | `tests/conformance_routing.rs::cleared_topic_gives_no_replay_until_a_fresh_publish` (both) |
@ -260,7 +260,7 @@ and once over a Unix socket, so `tests/rpc.rs::request_reply_roundtrip` means
| The thirteen transport error codes exist with those names | conforms | `error.rs::ErrorCode` | `tests/wire.rs::body_errors_keep_the_connection` (both) |
| Three more codes: `CONFLICT`, `NO_TOPIC`, `ARTIFACT_MISMATCH` | deviates-allowed: "Transport errors **include** ..." is not an exhaustive list, and each names a refusal the draft requires but leaves unnamed. Recorded as an amendment in bus-v1 section 12 | `error.rs::ErrorCode` | `tests/pubsub.rs::subscription_and_topic_validation` (both), `tests/artifacts.rs::seal_checks_length_and_digest` (both) |
| Before admission report `not-dispatched`; once dispatch might have occurred report `dispatched` or `unknown` conservatively | conforms | `error.rs::BusError::new` (not-dispatched by default), `router/state.rs` dispatched notices, `client/reactor.rs::fail_all` (unknown) | `tests/rpc.rs::{cancellation_states, service_disconnect_fails_calls}` (both), `tests/sol_review_races.rs::writer_failure_terminates_reader_and_pending_work` |
| Bounded subscriptions can reject a publication; latest spectators cannot hold a session transaction indefinitely | conforms: a latest subscriber never causes `BACKPRESSURE` | `router/state.rs::op_publish` (the latest branch skips every capacity check) | `tests/bus_acceptance.rs::a_latest_subscriber_never_refuses_a_publication` (both: 100 publications of 60 KB into one unconsumed slot, six times the bounded pool, none refused, 98 coalesced), with `tests/pubsub.rs::{bounded_fifo_and_atomic_backpressure, saturated_subscriber_does_not_block_control}` (both) for the bounded half |
| Bounded subscriptions can reject a publication; latest spectators cannot hold a session transaction indefinitely | conforms: a latest subscriber never causes `BACKPRESSURE` | `router/state.rs::op_publish` (the latest branch skips every capacity check) | `tests/bus_acceptance.rs::a_latest_subscriber_never_refuses_a_publication` (both: 100 publications of 60 KB into one unconsumed slot, six times the bounded pool, none refused, 98 coalesced), with `tests/pubsub.rs::{bounded_fifo_and_atomic_backpressure, saturated_subscriber_does_not_block_control}` (both) for the bounded half, and `tests/integration.rs::session_over_one_router` (both: 20 snapshot publications accepted by both subscriptions while the presentation consumer reads nothing) |
| Sustained pinned-artifact quota exhaustion is surfaced as pressure, not solved by freeing live data | conforms: `QUOTA_EXCEEDED`, never eviction | `router/state.rs::{op_allocate, op_seal}` | `tests/artifacts.rs::quotas_are_enforced` (both) |
| Session and application policies choose disconnect, pause or fail; the router does not know which | conforms by absence | `router/state.rs` | `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both) |
@ -284,7 +284,7 @@ and once over a Unix socket, so `tests/rpc.rs::request_reply_roundtrip` means
| 3. Artifacts: allocate/seal/read; publication before seal fails; fan-out owns one object; the last consumer releases; a retained extracted frame survives a message drop | conforms | `tests/artifacts.rs` (28), `tests/conformance_artifacts.rs` (36) |
| 4. Faults: sender drops after admission, consumer dies mid-read, reply lost, queued frame replaced, subscription closes with in-use deliveries, router restarts, old release arrives; no double-free, use-after-reuse, unbounded tombstones or hidden replay | conforms | `tests/conformance_routing.rs::{caller_disconnect_detaches_dispatched_call_but_service_keeps_serving, subscriber_disconnect_releases_queued_and_delivered_artifacts_but_not_retention}`, `tests/artifacts.rs::{release_ids_are_watermarked, router_restart_invalidates_old_handles}`, `tests/bus_acceptance.rs::{a_lost_result_leaks_no_roots_and_the_endpoint_cache_still_replays, disconnect_releases_logical_ownership_without_mutating_open_bytes}`, `tests/sol_rereview_regressions.rs` (11) |
| 5. RPC cache: an endpoint retains an artifact-bearing result, the original caller consumes it, a domain retry still returns valid bytes, eviction drops the last hold | conforms | `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both), `tests/bus_acceptance.rs::a_retransmission_repeats_the_domain_request_under_a_fresh_call_id` (both) |
| 6. Integration: two parallel fake agents, a complete-batch environment RPC, committed snapshot publication and a deliberately slow presentation consumer on one router | conforms | `tests/integration.rs::session_over_one_router` (both) |
| 6. Integration: two parallel fake agents, a complete-batch environment RPC, committed snapshot publication and a deliberately slow presentation consumer on one router | conforms; the consumer is slow by construction, held until the publisher's completion is observed, so its coalescing is forced rather than raced for | `tests/integration.rs::session_over_one_router` (both) |
| 7. Performance: 640x480x60 with three consumers, one delayed; p50/p95/p99 RPC latency, router CPU, copy and readback cost separately, RSS, store live and peak bytes, outstanding roots, collection lag, queue lengths, for one, two and four agents | conforms | `tests/perf.rs::frames_at_60hz_with_three_consumers` (`--ignored`); numbers below |
| The first executable example: a counter RPC, a pub/sub observer and a frame artifact held past message consumption, in one small Rust program, no game or browser | conforms | `examples/demo.rs` (`cargo run -p flybus --example demo`), asserted by `tests/example_demo.rs::the_example_shows_a_counter_rpc_an_observer_and_a_held_frame` |
@ -438,18 +438,30 @@ poll-until-the-router-settles the rest of the file already uses for router-side
no sleep, no timing constant, and the assertion now has the precondition its contract sentence
names. **360 runs after the fix, 0 failures** (240 debug, 120 release).
Two other intermittent failures were seen in the same sweep and are **not** fixed here, since
they belong to the bus slice rather than to this one:
Two other intermittent failures were seen in the same sweep. They belonged to the bus slice
rather than to this one and were fixed there, in the same way and for the same reason:
- `tests/example_demo.rs::the_example_shows_a_counter_rpc_an_observer_and_a_held_frame`,
2 failures in 40 standalone runs plus 1 in 12 full-suite runs. It prints
2 failures in 40 standalone runs plus 1 in 12 full-suite runs. It printed
"while the frame is held: 1 artifact(s), 2 root(s)" instead of 1 root: the producer's hold
release is queued on the control lane and had not been applied when the example read the
counts. The same shape of gap, in the guide deliverable's printed output.
- `tests/integration.rs::unix_socket::session_over_one_router`, 1 failure in 12 full-suite
runs and 0 in 40 standalone runs, at the assertion that the deliberately slow consumer
skipped snapshots. Under load it kept up, so the assertion is a timing claim about the
machine.
counts. `examples/demo.rs` now waits for that release before reading the counts, the same
bounded poll it already used for collection eight lines below, so the line the guide quotes
is an observation rather than a race. The printed output is unchanged.
- `tests/integration.rs::session_over_one_router`, 1 failure in 12 full-suite runs and 0 in 40
standalone runs, at the assertion that the deliberately slow consumer skipped snapshots.
Under load it kept up, so the assertion was a timing claim about the machine: section 7
permits a latest subscriber to miss values, it does not oblige it to. The renderer is now
held until the publisher's twentieth receipt has returned -- the publisher's completion
observed, not timed -- so the coalescing is forced by construction, and the test asserts the
guarantees that do hold: each delivery carries the frame of the snapshot it announces,
deliveries arrive in publication order, the last value received is the latest published, the
renderer receives snapshots 1 and 20 of 20, both subscriptions accept all twenty publications
while the spectator reads nothing, and the eighteen replacements reported to the publisher at
admission are the same eighteen reported to the renderer on delivery and are exactly the
snapshots it did not receive. 16 failures in 40 runs beside four busy loops before, 0 in 40
after; the whole crate went from 10 failed runs in 20 to 0, and the workspace suite from 2
in 5 to 0.
## Contradictions

View file

@ -1,6 +1,6 @@
# Rewards and learning
The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v5`. The code of record is
The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v6`. The code of record is
`services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the
gates and the rules); this page says what each rule pays for and why it is allowed to. The
prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules
@ -24,6 +24,7 @@ change what the fly can do.
| `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` |
| `badge` | `badge` | +3 | 400 ms | Each newly set badge bit |
| `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger |
| `catch` | `wildwin` | +0.30, +0.10 | 150 ms | A wild Pokémon kept by a ball: +0.30 for a species this run had never owned, +0.10 for a repeat; at most three payouts per species for the lifetime of the ledger |
Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")`
is `None` by test. The values in one frame sum into `R`, and the network reinforces once with
@ -33,6 +34,54 @@ The feed-kind column is `RewardKind::from_adapter` in `services/flysim/crates/fl
`docs/feed-protocol.md` publishes seven counters, and an adapter kind that has no counter of its
own shares the nearest one. It still reaches the page as an event with its own label.
Two consequences of that sharing are worth stating rather than discovering. `catch` publishes on
`wildwin` because a catch is a wild battle the fly won by keeping the Pokémon, and *not* on
`pokedex` because the `species` rule already pays for the Pokédex bit the same catch sets --
counting it twice would be the dishonest option. And the stage's ticker copy is keyed on the feed
kind, not on the catalog kind (`apps/stage/src/games/pokemon-red.ts`), so the row for a catch
currently reads "wild win". The event's own label, `CAUGHT #<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
Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other
@ -128,7 +177,19 @@ body picks the macro; the descending neurons press the buttons.**
## Honesty
The catalog now includes exits. That is worth saying plainly on the honesty panel, because paying
The catalog now includes catches. The honesty panel's copy is not data-driven from the catalog --
`apps/stage/src/lib/schedule.ts`'s rotating card is four written lines and lists no kinds -- so
there was nothing to regenerate and the copy is unchanged. The sentences below are where the
argument lives.
Paying for a catch does not move the fly: the ball is thrown by a macro the mushroom body chose
among the ones the battle scene put on the pad, and the payout is read out of WRAM after the
frame. What it does do is make one of the palette's existing macros worth choosing, which is the
same kind of pressure every other rule applies. The cap is what keeps it from becoming a farm: a
run that finds one patch of grass and throws balls at the same species all night earns 0.50 from
it and then nothing.
The catalog also includes exits. That is worth saying plainly on the honesty panel, because paying
for a door is closer to telling the fly where to go than paying for a badge is:
- **still no button path.** Nothing in the adapter chooses or biases a button. The reward is read

View file

@ -736,3 +736,11 @@ rewritten separately.
(73/73) because 82% of the fixed run is battle time; Fable shipped it on the same judgement as
v0.4.6 and started row 50 (MOVE n blocked on an unresponsive move list). The on-screen chat ring
now survives a sim restart (sidecar in the hot dir, never in the checkpoint).
- 2026-09-22 (v0.5.0, the operator's decision): the fly is paid for keeping a wild Pokémon. New
catalog kind `catch` (0.30 for a species this run never caught, 0.10 for a repeat, three payouts
per species), read from the captured-species byte and the battle result together; the existing
species rule still pays on top. Adapter `pokered-unique8-v6`; the compatibility string differs
in the adapter segment only, and a deploy with `FLY_ACCEPT_ADAPTERS=pokered-unique8-v5` migrates
a v5 checkpoint instead of refusing it. `fly-reset-to-milestone <N>` restarts the run from a
ladder rung (archives both stores first). The live run restarts from rung 7 with this release, so
the ladder is climbed again with the catch reward and the row-54 walks in place.

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)"
fi
# Whether the only difference between two compatibility strings is the adapter
# segment, and FLY_ACCEPT_ADAPTERS names the adapter the live checkpoints carry.
#
# The bash half of flybrain_gb::compatibility::decide, which is what flysim
# itself applies at restore. Both have to agree: a gate that let a deploy
# through and a flysim that then refused every checkpoint would be the black
# stream this whole section exists to prevent. The string is
# {kernel}/{adapter}/{fingerprint}/{plasticity}/binjgb:{rev}/pokered:{commit}/statefmt:{id},
# so the adapter is segment 1 and nothing else may move.
adapter_migration_accepted() {
local live="$1" new="$2" accepted="$3"
local -a live_parts new_parts
IFS='/' read -r -a live_parts <<< "$live"
IFS='/' read -r -a new_parts <<< "$new"
[ "${#live_parts[@]}" -eq "${#new_parts[@]}" ] || return 1
local i differing=0 index=-1
for ((i = 0; i < ${#live_parts[@]}; i++)); do
if [ "${live_parts[$i]}" != "${new_parts[$i]}" ]; then
differing=$((differing + 1))
index=$i
fi
done
[ "$differing" -eq 1 ] && [ "$index" -eq 1 ] || return 1
local entry
for entry in ${accepted//,/ }; do
[ "$entry" = "${live_parts[1]}" ] && return 0
done
return 1
}
# cpu_pin CMD [ARGS...] — run CMD inside the container on the page cpus.
# Falls through to a plain ct_exec when no partition is configured, so this is
# a no-op on an unpartitioned container rather than a new failure mode (a
@ -262,6 +292,16 @@ if [ -n "$RELEASE_TARBALL" ]; then
# 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
# the default.
#
# FLY_ACCEPT_ADAPTERS is the *other* override, and the opposite one: it keeps
# the run. It names adapter version strings whose checkpoints the new build
# may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 for the deploy
# that adds the catch reward. It only applies when the adapter segment is the
# ONLY difference between the two strings and the new build's adapter says it
# can read that one; a dataset, kernel, emulator or state-format change is
# still a refusal, because none of those has a migration. The same variable is
# written into /etc/fly/fly.env below, so flysim applies the same rule at
# restore that this gate applied at deploy.
# -----------------------------------------------------------------------
state_dir="${FLY_STATE_DIR:-/srv/fly/state}"
hot_dir="${FLY_STATE_HOT_DIR:-/run/fly/state}"
@ -287,6 +327,11 @@ if [ -n "$RELEASE_TARBALL" ]; then
log "05-deploy: no decodable checkpoint in ${state_dir} — nothing to compare, continuing"
elif [ "$new_compat" = "$live_compat" ]; then
log "05-deploy: checkpoint compatibility matches the live state, the new build will restore it"
elif [ -n "${FLY_ACCEPT_ADAPTERS:-}" ] \
&& adapter_migration_accepted "$live_compat" "$new_compat" "$FLY_ACCEPT_ADAPTERS"; then
log "05-deploy: FLY_ACCEPT_ADAPTERS=${FLY_ACCEPT_ADAPTERS} — the adapter version is the only difference, and it is named; the run is KEPT and migrated"
log "05-deploy: live: $live_compat"
log "05-deploy: new: $new_compat"
elif [ "${FLY_RESET_STATE:-0}" = 1 ]; then
archive="${state_dir}.$(date -u +%Y%m%d%H%M%S)"
log "05-deploy: FLY_RESET_STATE=1 — compatibility CHANGED, archiving the durable state to ${archive} and clearing the hot ring"
@ -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.
live state: ${live_compat}
new build: ${new_compat}
The difference is usually an adapter/ladder or dataset version bump. Two ways forward:
The difference is usually an adapter/ladder or dataset version bump. Three ways forward:
* deploy a build whose string matches (check out the commit the running release was built from), or
* if the ADAPTER VERSION is the only segment that differs and the new build documents a
migration from the old one, re-run with FLY_ACCEPT_ADAPTERS set to the adapter id in the live
string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5). The run is kept; flysim applies the same
rule at restore. See docs/design/flysim.md, \"Restoring across an adapter version\", or
* accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which
archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and
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
echo "FLY_MACRO_BLOCKED_MINUTES=${FLY_MACRO_BLOCKED_MINUTES}"
fi
# Adapter versions whose checkpoints this build may migrate
# (flybrain_gb::compatibility, docs/design/flysim.md "Restoring across an
# adapter version"). Only written when it is set, because the safe state is
# absent: an empty or missing variable migrates nothing, which is what every
# deploy before 2026-09-22 did. It stays in fly.env for as long as the
# operator leaves it on the deploy command line, so removing the opt-in is
# one deploy without it.
if [[ -n "${FLY_ACCEPT_ADAPTERS:-}" ]]; then
echo "FLY_ACCEPT_ADAPTERS=${FLY_ACCEPT_ADAPTERS}"
fi
# flybridge (services/bridge/src/config.ts). Nothing wrote these before, so
# flybridge.service had no EnvironmentFile= at all and the service refused to
# start with "CHANNEL is required / BOT_USER is required / GAME_TITLE is
@ -607,7 +666,7 @@ fi
# ---------------------------------------------------------------------------
log "05-deploy: converging bin/ helpers to /opt/fly/bin"
ct_exec "$CTID" -- mkdir -p /opt/fly/bin
for name in fly-watchdog fly-recap fly-retention flypush flystage-launch flycast-launch wait-for-x wait-for-stage wait-for-health; do
for name in fly-watchdog fly-recap fly-retention fly-reset-to-milestone flypush flystage-launch flycast-launch wait-for-x wait-for-stage wait-for-health; do
converge_file "$CTID" "$INFRA_DIR/bin/$name" "/opt/fly/bin/$name" 0755 root:root >/dev/null
done

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.
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
```

14
infra/env/example.env vendored
View file

@ -317,6 +317,20 @@ FLY_MACRO_MODE=raw
# target once more. Unset means the default, 10.
# FLY_MACRO_BLOCKED_MINUTES=10
# --- restoring across an adapter version ------------------------------------
# Adapter version strings whose checkpoints this build may migrate, comma- or
# space-separated (docs/design/flysim.md, "Restoring across an adapter
# version"). Unset -- the default, and what every deploy before 2026-09-22 did
# -- migrates nothing: a build whose compatibility string differs from the live
# state's is refused by 05-deploy's gate and by flysim at restore.
#
# It applies only when the ADAPTER segment is the only difference between the
# two strings AND the new build's adapter declares a migration from that one. A
# dataset, kernel, plasticity, emulator or state-format difference is still a
# refusal. Set it for the one deploy that needs it and leave it out afterwards;
# 05-deploy writes it into /etc/fly/fly.env only while it is set.
# FLY_ACCEPT_ADAPTERS=pokered-unique8-v5
# --- push mode --------------------------------------------------------------
# local: flypush.service stays disabled, everything else identical to prod.
# twitch: flypush.service is enabled by 07-enable.sh.

View file

@ -56,7 +56,7 @@ impl MemoryReader for &mut dyn MemoryReader {
/// One reward payout in one frame.
///
/// `kind` is an adapter-owned interned name (Pokémon: `milestone`,
/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`); it is the
/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`); it is the
/// key the statistics counters and the on-screen ticker group by. Field names
/// serialize exactly as the prototype's `RewardEvent` did, so a checkpoint
/// written by either implementation reads in the other.
@ -241,9 +241,21 @@ impl std::error::Error for AdapterError {}
/// A game, as the sim loop sees it.
pub trait GameAdapter: Send {
/// Adapter version string, pinned into the checkpoint compatibility string.
/// Pokémon: `pokered-unique8-v5`.
/// Pokémon: `pokered-unique8-v6`.
fn id(&self) -> &'static str;
/// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration
/// this adapter has written down and tested.
///
/// The default is empty: an adapter migrates from nothing unless it says otherwise, which
/// is the behaviour every adapter had before this existed. It is only half of the gate --
/// [`crate::compatibility::decide`] also requires the operator to have named the same id in
/// `FLY_ACCEPT_ADAPTERS` for that deploy -- so listing an id here never migrates a live run
/// on its own.
fn migrates_from(&self) -> &'static [&'static str] {
&[]
}
/// Whether semantic rewards are enabled for this cartridge. An adapter that
/// says no must still sample without paying anything, so the stream keeps
/// running with a visible "rewards off" mode.
@ -470,6 +482,10 @@ mod tests {
let platformer =
adapter_for_with_rom_pin("platformer", Some(&"a".repeat(64))).unwrap();
assert_ne!(pokemon.id(), platformer.id());
assert!(
!platformer.migrates_from().contains(&pokemon.id()),
"a migration never crosses games"
);
assert_ne!(pokemon.symbol_provenance(), platformer.symbol_provenance());
// And two ROM revisions of the same game cannot either.
let other = adapter_for_with_rom_pin("platformer", Some(&"b".repeat(64))).unwrap();

View file

@ -30,7 +30,7 @@ pub const PROTOTYPE_PLASTICITY_VERSION: &str = "fly-kc-mbon-rstdp-v2";
pub struct Compatibility<'a> {
/// `kernelVersion(config)` from the neural library.
pub neural_kernel_version: &'a str,
/// The adapter's version string, e.g. `pokered-unique8-v5`.
/// The adapter's version string, e.g. `pokered-unique8-v6`.
pub adapter: &'a str,
/// The dataset's seven SHA-256 digests joined with `:`.
pub dataset_fingerprint: &'a str,
@ -67,6 +67,92 @@ impl Compatibility<'_> {
}
}
/// Position of the adapter's version string in [`Compatibility::string`].
///
/// `{kernel}/{adapter}/{fingerprint}/{plasticity}/binjgb:{rev}/pokered:{commit}/statefmt:{id}`,
/// so the adapter is segment one. Nothing else in the string may move for a migration to be
/// considered: a different kernel, dataset, plasticity, emulator revision, symbol provenance or
/// state format is a different *fly*, not a different reward rule.
const ADAPTER_SEGMENT: usize = 1;
/// The environment variable that opts a deploy into the adapter migration.
///
/// Read by flysim at restore and by `infra/05-deploy.sh`'s compatibility gate. Comma- or
/// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v5`.
pub const ACCEPT_ADAPTERS_ENV: &str = "FLY_ACCEPT_ADAPTERS";
/// What a build may do with a checkpoint whose compatibility string is not its own.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RestoreDecision {
/// Byte-identical. Restore it, as every build always has.
Exact,
/// Every segment but the adapter's is identical, this build's adapter says it can migrate
/// from that one, and the operator named it in [`ACCEPT_ADAPTERS_ENV`]. Restore it.
MigrateAdapter { from: String },
/// Refuse, and say which of the three conditions failed.
Refuse(&'static str),
}
/// Decide whether `checkpoint`'s compatibility string may be restored under `current`.
///
/// Three conditions, all required, in the order they are cheapest to explain:
///
/// 1. the two strings differ in the adapter segment and **nowhere else**;
/// 2. `migrates_from` -- the running adapter's own list -- contains the checkpoint's adapter, so
/// the code that will read that state says out loud that it can;
/// 3. `accepted` -- [`ACCEPT_ADAPTERS_ENV`] as the operator set it for this deploy -- contains it
/// too, so no build ever migrates a run by itself.
///
/// Condition 2 without condition 3 would make the migration silent; condition 3 without condition
/// 2 would let an operator wave through a pair nobody wrote a migration for. Neither alone is
/// enough, which is why both are here.
pub fn decide(
checkpoint: &str,
current: &str,
migrates_from: &[&str],
accepted: &[String],
) -> RestoreDecision {
if checkpoint == current {
return RestoreDecision::Exact;
}
let old: Vec<&str> = checkpoint.split('/').collect();
let new: Vec<&str> = current.split('/').collect();
if old.len() != new.len() {
return RestoreDecision::Refuse("the two compatibility strings do not have the same shape");
}
let differing: Vec<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
/// binjgb save state written elsewhere can be memcpy'd back in here.
pub fn state_format_id() -> String {
@ -94,7 +180,7 @@ mod tests {
assert_eq!(
fixture().prototype_string(),
concat!(
"lif-1ms-f64-v2/pokered-unique8-v5/aa:bb:cc:dd:ee:ff:00/",
"lif-1ms-f64-v2/pokered-unique8-v6/aa:bb:cc:dd:ee:ff:00/",
"fly-kc-mbon-rstdp-v2/",
"binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/",
"pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
@ -102,6 +188,73 @@ mod tests {
);
}
fn with_adapter(adapter: &'static str) -> String {
Compatibility { adapter, ..fixture() }.string()
}
#[test]
fn an_identical_string_restores_without_any_opt_in() {
let current = with_adapter("pokered-unique8-v6");
assert_eq!(decide(&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]
fn the_state_format_segment_is_appended_not_interleaved() {
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
//! declaration order, which is the order `counts` and `last` serialize in.
@ -20,8 +20,18 @@ pub mod kind {
pub const BATTLE: &str = "battle";
pub const BADGE: &str = "badge";
pub const BOUNDARY: &str = "boundary";
pub const CATCH: &str = "catch";
}
/// What a `catch` of a species this run has already caught pays.
///
/// Not a multiple of the rule's catalog value, because no binary float scales 0.30 into
/// exactly 0.10: `0.3 * (1.0 / 3.0)` is `0.09999999999999999`, and that number would reach
/// the ticker, the checkpoint and `docs/rewards-learning.md`'s table as itself. `boundary`'s
/// two payouts are 0.05 and 0.10, which a scale of two does express exactly, so that rule
/// still goes through the scaling path.
pub const CATCH_REPEAT_VALUE: f64 = 0.10;
#[derive(Debug, Clone, Copy)]
pub struct RewardRule {
pub kind: &'static str,
@ -34,7 +44,7 @@ pub struct RewardRule {
pub stimulation_ms: u32,
}
pub const REWARDS: [RewardRule; 8] = [
pub const REWARDS: [RewardRule; 9] = [
RewardRule {
kind: kind::MILESTONE,
label: "Story",
@ -99,6 +109,22 @@ pub const REWARDS: [RewardRule; 8] = [
value: 0.05,
stimulation_ms: 100,
},
// The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not
// only for knocking one out. Appended rather than slotted next to `species` for the same
// reason `boundary` was appended -- the declaration order is the key order `counts`
// serializes in, and every checkpoint already written carries the first eight in this order.
//
// One rule, two payouts, like `boundary`: this value is what a species this run has never
// caught pays, and [`CATCH_REPEAT_VALUE`] is what a repeat pays. The existing `species`
// rule is untouched and still pays 0.50 the first time a species is owned by any means, so
// a first catch of a new species pays 0.50 + 0.30 across two kinds.
RewardRule {
kind: kind::CATCH,
label: "Catch",
trigger: "Wild Pokémon caught; 0.10 for a species already caught; max 3 per species",
value: 0.30,
stimulation_ms: 150,
},
];
/// Position of `kind` in [`REWARDS`], or `None` for an unknown kind. This is
@ -199,10 +225,34 @@ mod tests {
// separate kinds.
assert_eq!(rule(kind::BOUNDARY).unwrap().value, 0.05);
assert_eq!(rule(kind::BOUNDARY).unwrap().stimulation_ms, 100);
// Nor the prototype's: the operator's catch rule, `pokered-unique8-v6`.
assert_eq!(rule(kind::CATCH).unwrap().value, 0.30);
assert_eq!(CATCH_REPEAT_VALUE, 0.10);
assert_eq!(rule(kind::CATCH).unwrap().stimulation_ms, 150);
assert!(rule("blackout").is_none(), "the catalog has no penalties");
assert!(REWARDS.iter().all(|rule| rule.value > 0.0));
}
#[test]
fn the_catch_rule_is_last_so_the_older_key_order_does_not_move() {
let order: Vec<&str> = REWARDS.iter().map(|rule| rule.kind).collect();
assert_eq!(
order,
vec![
kind::MILESTONE,
kind::EXPLORATION,
kind::MAP,
kind::SPECIES,
kind::TRAINER,
kind::BATTLE,
kind::BADGE,
kind::BOUNDARY,
kind::CATCH,
]
);
assert_eq!(index(kind::CATCH), Some(REWARDS.len() - 1));
}
#[test]
fn empty_counts_lists_every_kind_at_zero() {
let counts = Counts::default();

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
//! are unchanged; `docs/rewards-learning.md` holds the live rule table and the
//! source evidence behind each gate. v4 replaced the 16-rung boot-to-badges
//! ladder with the 38 rungs of `docs/design/ladder.md`; v5 adds one reward rule,
//! `boundary` (`docs/design/room-escape.md` section 2), which pays the first step
//! next to and the first step onto each of a map's exits.
//! next to and the first step onto each of a map's exits; v6 adds `catch`, the
//! operator's decision of 2026-09-22, which pays for keeping a wild Pokémon.
pub mod catalog;
#[cfg(test)]
@ -32,13 +33,33 @@ use symbols::ram;
/// Adapter version, pinned into the checkpoint compatibility string.
///
/// `v5` is the `boundary` rule. Bumping it is what rejects every checkpoint written
/// by `v4`: the string is compared whole before a restore is attempted, so a ledger
/// that has never recorded a single `boundary:` key can never be resumed as though
/// its exits were already collected. (`v4` was the 38-rung ladder, and rejected
/// `v3` for the same reason: a stored rank that meant "4 badges" on the old ladder
/// could not be read as a rung on the new one.) Pre-launch, so no run is lost.
pub const REWARD_ADAPTER: &str = "pokered-unique8-v5";
/// `v6` is the `catch` rule. Bumping it is what makes a `v5` checkpoint a decision
/// rather than an accident: the compatibility string is compared whole before a
/// restore is attempted, so a `v5` run is refused by default and resumed only when
/// the operator names it in `FLY_ACCEPT_ADAPTERS`
/// ([`crate::compatibility::RestoreDecision`], `docs/design/flysim.md`). That
/// migration is safe in one direction only, and only for this pair: `v5`'s ledger is
/// a `v6` ledger with the catch counter absent, and an absent counter reads as zero.
///
/// (`v5` was the `boundary` rule, and rejected `v4` because a ledger that had never
/// recorded a `boundary:` key could not be resumed as though its exits were already
/// collected. `v4` was the 38-rung ladder, and rejected `v3` because a stored rank
/// that meant "4 badges" on the old ladder is not a rung on the new one. Neither of
/// those is a migration: this one is, because nothing a `v5` ledger holds means
/// something different under `v6`.)
pub const REWARD_ADAPTER: &str = "pokered-unique8-v6";
/// Adapter ids whose checkpoints `v6` can read.
///
/// Exactly one, and it is one because the `catch` rule adds a counter and changes nothing else:
/// a `v5` ledger restores as a `v6` ledger with `catchCounts` empty, and every other byte of the
/// state means what it meant. `v4` is not here -- its `seen` ledger holds no `boundary:` keys, so
/// resuming it would pay a second time for every exit the run had already found -- and neither is
/// `v3`, whose stored rank is a rung on a different ladder.
///
/// Listing an id here is necessary but not sufficient: `FLY_ACCEPT_ADAPTERS` must name it too
/// (`crate::compatibility::decide`, `docs/design/flysim.md`).
pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v5"];
/// The only cartridge semantic rewards are enabled for. Even the canonical
/// pret build stays disabled until reviewed; see `docs/rewards-learning.md`.
@ -60,8 +81,22 @@ pub const SUPPORTED_ROM: &str =
/// [`REWARD_ADAPTER`] is the gate that refuses such a checkpoint anyway, and it is
/// the right gate, because the objection to loading one is about semantics rather
/// than shape.
///
/// *Not* bumped for the `catch` rule either, and this time the answer matters,
/// because `v5` checkpoints are meant to be restorable under `v6`. The rule adds one
/// counter, `catchCounts`, and nothing else: every other field keeps its name, its
/// shape and its meaning, and a state written without the counter restores with it
/// empty, which is the truth about a run that was never paid for a catch. That is the
/// whole of the documented `v5` -> `v6` migration; see
/// [`crate::compatibility::RestoreDecision`].
pub const STATE_VERSION: u64 = 4;
/// Catch payouts one species may earn in the lifetime of a run's ledger.
///
/// The same cap and the same reason as the wild-KO rule's three: a species the fly can
/// find over and over is a farm, and three is enough for the behaviour to be learned.
const MAX_CATCH_PAYOUTS: u64 = 3;
const BADGE_NAMES: [&str; 8] = [
"BOULDER", "CASCADE", "THUNDER", "RAINBOW", "SOUL", "MARSH", "VOLCANO", "EARTH",
];
@ -325,6 +360,26 @@ struct Battle {
wild: bool,
saw_living: bool,
ko: bool,
/// Lifetime `species` payouts when this battle started.
///
/// The "never owned this run" test for the catch rule, and an exact one: the only
/// thing that can set a `wPokedexOwned` bit during a wild battle is the catch
/// itself, so a `species` payout between the battle starting and the ball keeping
/// the Pokémon *is* that Pokémon being new. It is read this way rather than from
/// `wCapturedMonSpecies` directly because that byte is the cartridge's **internal**
/// species index and the owned bitset is by **Pokédex number**; the two numberings
/// differ and nothing in WRAM converts between them
/// (`docs/design/macros-wram.md` section 2, "species numbering").
///
/// `None` for a battle restored from a checkpoint written before this existed,
/// which reads as "cannot tell" and pays the repeat amount rather than guessing
/// generously.
species_at_start: Option<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
@ -370,6 +425,10 @@ pub struct PokemonRedReward {
tiles: OrderedSet,
tile_counts: BTreeMap<u8, 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,
counts: Counts,
total: f64,
@ -420,6 +479,7 @@ impl PokemonRedReward {
tiles: OrderedSet::new(),
tile_counts: BTreeMap::new(),
wild_wins: BTreeMap::new(),
catch_counts: BTreeMap::new(),
replay_blocked: OrderedSet::new(),
counts: Counts::default(),
total: 0.0,
@ -567,8 +627,9 @@ impl PokemonRedReward {
}
/// Forget observations a rollback invalidates. Lifetime novelty survives,
/// and every wild-KO key paid so far is blocked from paying again, because
/// after a rollback the same battle could otherwise be replayed for reward.
/// and every wild-KO key and every caught species paid so far is blocked from
/// paying again, because after a rollback the same battle -- or the same catch --
/// could otherwise be replayed for reward.
pub fn clear_transient(&mut self) {
self.location.clear();
self.stable = 0;
@ -578,6 +639,10 @@ impl PokemonRedReward {
for key in keys {
self.replay_blocked.insert(&key);
}
let caught: Vec<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.
@ -690,14 +755,29 @@ impl PokemonRedReward {
if in_battle == 1 || in_battle == 2 || in_battle == 255 {
self.mode = "BATTLE".to_string();
self.stable = 0;
let species_paid = self.counts.get(kind::SPECIES);
if self.battle.is_none() && in_battle != 255 {
self.battle = Some(Battle {
key: battle_key(memory, map),
wild: in_battle == 1,
saw_living: false,
ko: false,
species_at_start: Some(species_paid),
captured: None,
captured_new: false,
});
}
// The cartridge's own answer to "was one caught": `ram/wram.asm`'s comment on
// this byte is "0 if no mon was captured". `ItemUseBall` zeroes it before every
// throw and writes `wEnemyMonSpecies` into it only on the branch that keeps the
// Pokémon, and `UseBagItem`'s `.returnAfterCapturingMon` zeroes it again on the
// way out of the battle -- so it is non-zero for the hundreds of frames the
// catch's own text and Pokédex screen take, and zero everywhere else.
//
// Read rather than derived from `wPartyCount`, because a catch with a full party
// raises `wBoxCount` instead, and because `wPartyCount` also rises for a gift, a
// trade and a revive out of the PC.
let captured = memory.read8(ram::wCapturedMonSpecies);
if let Some(battle) = &mut self.battle {
let hp = word(memory, ram::wEnemyMonHP);
let max = word(memory, ram::wEnemyMonMaxHP);
@ -710,6 +790,11 @@ impl PokemonRedReward {
if battle.saw_living && hp == 0 {
battle.ko = true;
}
if battle.wild && captured != 0 && battle.captured.is_none() {
battle.captured = Some(captured);
battle.captured_new =
battle.species_at_start.is_some_and(|before| species_paid > before);
}
}
} else if in_battle == 0 {
self.mode = "OVERWORLD".to_string();
@ -728,6 +813,41 @@ impl PokemonRedReward {
}
self.wild_wins.insert(battle.key.clone(), (count + 1).min(3));
}
// The catch rule (`docs/rewards-learning.md`, the operator 2026-09-22).
//
// Paid on the way out of the battle rather than on the capture frame, so that
// it lands in the same place the wild-KO payout does and cannot fire twice for
// one battle. `wBattleResult` is 2 on exactly two paths in the game:
// `UseBagItem`'s `.returnAfterCapturingMon`, which is this one, and a link
// battle whose opponent ran (`engine/battle/core.asm`), which this cartridge
// never has. Requiring it as well as the captured species means a byte read
// out of a half-initialised battle cannot pay.
if let Some(species) = battle.captured
&& battle.wild
&& result == 2
{
let key = species.to_string();
let paid = self.catch_counts.get(&key).copied().unwrap_or(0);
if paid < MAX_CATCH_PAYOUTS
&& !self.replay_blocked.contains(&format!("catch:{key}"))
{
let value = if battle.captured_new {
catalog::rule(kind::CATCH)
.expect("the catch rule is in the catalog")
.value
} else {
catalog::CATCH_REPEAT_VALUE
};
self.emit_amount(
&mut emitted,
kind::CATCH,
format!("CAUGHT #{species}"),
value,
brain_ms,
);
}
self.catch_counts.insert(key, (paid + 1).min(MAX_CATCH_PAYOUTS));
}
}
let location = format!("{map}:{x}:{y}");
self.stable = if self.location == location { self.stable + 1 } else { 1 };
@ -825,13 +945,33 @@ impl PokemonRedReward {
label: String,
scale: f64,
brain_ms: f64,
) {
let rule = catalog::rule(kind).expect("emit is only called with catalog kinds");
self.emit_amount(emitted, kind, label, rule.value * scale, brain_ms);
}
/// [`PokemonRedReward::emit`] with the payout stated outright instead of as a multiple
/// of the catalog value.
///
/// One rule needs it. `catch` pays 0.30 for a species this run has not caught and 0.10
/// for one it has, and no binary float scales the first into exactly the second:
/// `0.3 * (1.0 / 3.0)` is `0.09999999999999999`, and that is the number that would reach
/// the ticker and the checkpoint. `boundary`'s pair, 0.05 and 0.10, *is* an exact scale
/// of two, so that rule still goes through [`PokemonRedReward::emit`].
fn emit_amount(
&mut self,
emitted: &mut Vec<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 event = RewardEvent {
kind,
label,
brain_ms,
value: rule.value * scale,
value,
stimulation_ms: rule.stimulation_ms,
};
emitted.push(event.clone());
@ -991,6 +1131,10 @@ impl PokemonRedReward {
"tiles": self.tiles.as_slice(),
"tileCounts": self.tile_counts,
"wildWins": self.wild_wins,
// The one field v6 adds. A v5 state does not carry it and restores with it
// empty, which is the documented v5 -> v6 migration and the truth about a run
// that was never paid for a catch.
"catchCounts": self.catch_counts,
"replayBlocked": self.replay_blocked.as_slice(),
"counts": self.counts,
"total": self.total,
@ -1011,6 +1155,9 @@ impl PokemonRedReward {
"wild": battle.wild,
"sawLiving": battle.saw_living,
"ko": battle.ko,
"speciesAtStart": battle.species_at_start,
"captured": battle.captured,
"capturedNew": battle.captured_new,
})),
"mode": self.mode,
})
@ -1056,6 +1203,13 @@ impl PokemonRedReward {
let counts_raw = counted_record(input.get("counts")).ok_or(BAD_CHECKPOINT)?;
let tile_counts_raw = counted_record(input.get("tileCounts")).ok_or(BAD_CHECKPOINT)?;
let wild_wins_raw = counted_record(input.get("wildWins")).ok_or(BAD_CHECKPOINT)?;
// Absent in every v5 state, and that absence is the migration: no species has been
// paid for a catch, because the rule did not exist. Present but malformed is still
// an error, the same as every other counter here.
let catch_counts = match input.get("catchCounts") {
None | Some(Value::Null) => BTreeMap::new(),
Some(value) => counted_record(Some(value)).ok_or(BAD_CHECKPOINT)?,
};
let recent = recent_raw
.iter()
@ -1078,6 +1232,19 @@ impl PokemonRedReward {
wild: value.get("wild").and_then(Value::as_bool).ok_or(BAD_HISTORY)?,
saw_living: value.get("sawLiving").and_then(Value::as_bool).ok_or(BAD_HISTORY)?,
ko: value.get("ko").and_then(Value::as_bool).ok_or(BAD_HISTORY)?,
// All three are v6's, and all three are optional for the same reason
// `catchCounts` is. A v5 battle carries no `speciesAtStart`, which reads as
// "cannot tell whether the caught species was new" and pays the repeat
// amount: the conservative half of the rule, and at most 0.20 once.
species_at_start: value.get("speciesAtStart").and_then(Value::as_u64),
captured: value
.get("captured")
.and_then(Value::as_u64)
.and_then(|species| u8::try_from(species).ok()),
captured_new: value
.get("capturedNew")
.and_then(Value::as_bool)
.unwrap_or(false),
}),
};
let replay_blocked = match input.get("replayBlocked") {
@ -1103,6 +1270,7 @@ impl PokemonRedReward {
}
self.tile_counts = tile_counts;
self.wild_wins = wild_wins_raw;
self.catch_counts = catch_counts;
self.replay_blocked = replay_blocked.iter().map(String::as_str).collect();
self.counts = Counts::default();
for (key, count) in &counts_raw {
@ -1174,6 +1342,10 @@ impl GameAdapter for PokemonRedReward {
REWARD_ADAPTER
}
fn migrates_from(&self) -> &'static [&'static str] {
MIGRATES_FROM
}
fn rom_allowed(&self, sha256: &str) -> bool {
sha256 == SUPPORTED_ROM
}

View file

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

View file

@ -83,6 +83,34 @@ impl Fixture {
self.visit(x, 0)
}
/// One wild battle that ends in a ball keeping the Pokémon, byte for byte as the
/// cartridge writes it at the pinned commit.
///
/// `InitBattleVariables` clears `wBattleResult`; `ItemUseBall`'s capture branch sets the
/// Pokédex bit (for a species the player did not already own) and writes
/// `wEnemyMonSpecies` into `wCapturedMonSpecies`; `UseBagItem`'s
/// `.returnAfterCapturingMon` then zeroes that byte, sets `wBattleResult` to 2 and leaves
/// the battle. `dex` is the Pokédex *number* minus one, i.e. the bit index, and `None` is a
/// species this run already owns.
fn catch(&mut self, species: u8, dex: Option<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
/// `(x, y)`, the layout `ram/wram.asm` documents at the pinned commit.
fn warps(&mut self, warps: &[(u8, u8)]) {
@ -320,6 +348,136 @@ fn a_wild_run_capture_or_single_faint_never_pays_a_ko_while_a_verified_ko_does()
assert_eq!(f.reward.statistics().counts[kind::BATTLE], 1);
}
#[test]
fn a_catch_pays_the_new_species_amount_once_the_repeat_amount_after_and_stops_at_three() {
let mut f = Fixture::new();
f.sample();
// A species this run has never owned: the cartridge sets the Pokédex bit on the way
// through, so the existing `species` rule pays 0.50 and the new rule pays 0.30.
let first = f.catch(0xb0, Some(3));
assert_eq!(kinds(&first), ["species", "catch"]);
assert_eq!(labels(&first), ["OWNED #4", "CAUGHT #176"]);
assert!((first[0].value - 0.5).abs() < 1e-12, "the species rule is untouched");
assert!((first[1].value - 0.30).abs() < 1e-12);
// The same species again: a repeat, twice, and then the cap.
for _ in 0..2 {
let again = f.catch(0xb0, None);
assert_eq!(kinds(&again), ["catch"]);
assert_eq!(again[0].value, 0.10, "the repeat amount is exactly 0.10, not 0.3/3");
}
assert!(f.catch(0xb0, None).is_empty(), "three payouts per species is the cap");
assert_eq!(f.reward.statistics().counts[kind::CATCH], 3);
// Another species starts its own count, and its own 0.30.
let other = f.catch(0x99, Some(0));
assert_eq!(kinds(&other), ["species", "catch"]);
assert!((other[1].value - 0.30).abs() < 1e-12);
}
#[test]
fn a_catch_of_a_species_this_run_already_owns_pays_the_repeat_amount() {
let mut f = Fixture::new();
f.sample();
// Owned before the battle -- a gift, a trade, an evolution -- so no Pokédex bit is set
// during it and the catch is not a new species.
f.memory.or(ram::wPokedexOwned, 1);
assert_eq!(kinds(&f.sample()), ["species"]);
let events = f.catch(0x99, None);
assert_eq!(kinds(&events), ["catch"]);
assert_eq!(events[0].value, 0.10);
}
#[test]
fn nothing_but_a_wild_catch_pays_the_catch_rule() {
// A trainer battle: balls cannot be thrown, and `wIsInBattle` is 2.
let mut f = Fixture::new();
f.sample();
f.memory.set(ram::wIsInBattle, 2);
f.memory.set(ram::wEnemyMonHP + 1, 10);
f.memory.set(ram::wEnemyMonMaxHP + 1, 10);
f.sample();
f.memory.set(ram::wCapturedMonSpecies, 0xb0);
f.sample();
f.memory.set(ram::wCapturedMonSpecies, 0);
f.memory.set(ram::wBattleResult, 2);
f.memory.set(ram::wIsInBattle, 0);
assert!(f.sample().is_empty(), "a trainer battle never pays the catch rule");
// The Safari Zone and the old man's tutorial are excluded a step earlier: the whole
// sample is dropped with a visible mode, so no battle is ever opened.
for battle_type in [1u8, 2] {
let mut f = Fixture::new();
f.sample();
f.memory.set(ram::wBattleType, battle_type);
f.memory.set(ram::wIsInBattle, 1);
assert!(f.sample().is_empty());
f.memory.set(ram::wCapturedMonSpecies, 0xb0);
assert!(f.sample().is_empty());
f.memory.set(ram::wCapturedMonSpecies, 0);
f.memory.set(ram::wBattleResult, 2);
f.memory.set(ram::wIsInBattle, 0);
f.memory.set(ram::wBattleType, 0);
assert!(f.sample().is_empty());
assert_eq!(f.reward.statistics().counts[kind::CATCH], 0);
}
// A ball that missed: `wCapturedMonSpecies` never leaves zero and the battle ends as a
// run or a loss.
let mut f = Fixture::new();
f.sample();
f.memory.set(ram::wIsInBattle, 1);
f.memory.set(ram::wEnemyMonHP + 1, 10);
f.memory.set(ram::wEnemyMonMaxHP + 1, 10);
f.sample();
f.memory.set(ram::wIsInBattle, 0);
assert!(f.sample().is_empty());
}
#[test]
fn a_rollback_cannot_replay_a_catch() {
let mut f = Fixture::new();
f.sample();
assert_eq!(kinds(&f.catch(0xb0, Some(3))), ["species", "catch"]);
f.reward.clear_transient();
let state = f.reward.export_state();
f.reward.import_state(&state).unwrap();
assert!(f.catch(0xb0, None).is_empty(), "an already-paid species cannot pay after rollback");
assert_eq!(f.reward.statistics().counts[kind::CATCH], 1);
}
#[test]
fn a_v5_state_restores_under_v6_with_the_catch_counter_at_zero() {
let mut f = Fixture::new();
f.sample();
f.catch(0xb0, Some(3));
let v6 = f.reward.export_state();
assert_eq!(v6["catchCounts"], json!({ "176": 1 }));
// The v5 shape is this one without the counter the rule added: same `version`, same field
// names, same meanings. That is the whole of the documented migration.
let mut v5 = v6.clone();
v5.as_object_mut().unwrap().remove("catchCounts");
assert_eq!(v5["version"], json!(STATE_VERSION), "v5 and v6 states share a schema version");
let mut restored = PokemonRedReward::new();
restored.import_state(&v5).unwrap();
let mut expected = v6.clone();
expected["catchCounts"] = json!({});
assert_eq!(restored.export_state(), expected, "the counter starts at 0, nothing else moves");
// A genuine v5 `counts` object carries eight kinds and no `catch`, which reads as zero.
let mut older = v5.clone();
older["counts"].as_object_mut().unwrap().remove("catch");
let mut restored = PokemonRedReward::new();
restored.import_state(&older).unwrap();
assert_eq!(restored.statistics().counts[kind::CATCH], 0);
assert_eq!(restored.statistics().counts[kind::SPECIES], 1);
}
#[test]
fn a_repeated_wild_ko_decays_then_stops() {
let mut f = Fixture::new();
@ -378,6 +536,7 @@ fn malformed_checkpoint_fields_are_named_in_the_error() {
("counts", json!([])),
("tileCounts", json!("not a record")),
("wildWins", json!(3)),
("catchCounts", json!(3)),
] {
let mut broken = good.clone();
broken[field] = wrong;
@ -706,7 +865,8 @@ fn the_recent_ticker_keeps_the_newest_eight_events_newest_first() {
#[test]
fn the_adapter_reports_its_identity_and_pinned_rom() {
let reward = PokemonRedReward::new();
assert_eq!(reward.id(), "pokered-unique8-v5");
assert_eq!(reward.id(), "pokered-unique8-v6");
assert_eq!(reward.migrates_from(), ["pokered-unique8-v5"]);
assert!(reward.rom_allowed(SUPPORTED_ROM));
assert!(!reward.rom_allowed(
"5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b"
@ -716,6 +876,9 @@ fn the_adapter_reports_its_identity_and_pinned_rom() {
assert_eq!(symbols::ram::wNumberOfWarps, 0xd3ae);
assert_eq!(symbols::ram::wWarpEntries, 0xd3af);
assert_eq!(symbols::ram::wCurMapConnections, 0xd370);
// Resolved from ram/wram.asm by services/flysim/tools/resolve_wram.py, bracketed by
// wFontLoaded and wForcePlayerToChooseMon; never written out by hand.
assert_eq!(symbols::ram::wCapturedMonSpecies, 0xd11c);
assert_eq!(symbols::MILESTONES.len(), 17);
}

View file

@ -99,8 +99,17 @@ pub async fn run() -> Result<Vec<String>, Box<dyn std::error::Error>> {
"published sequence {} to {} subscriber(s)",
receipt.topic_sequence, receipt.subscribers
));
// The producer lets go of its own hold; the delivery keeps the bytes alive.
// The producer lets go of its own hold; the delivery keeps the bytes alive. The release
// travels the control lane like any other operation, so the count below waits for it
// instead of reading a number that may still include it.
drop(frame);
let released = Instant::now() + Duration::from_secs(10);
while router.stats().artifact_roots > 1 {
if Instant::now() > released {
return Err("the producer's own hold was never released".into());
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
let message = frames.next().await.ok_or("the subscription closed")?;
let image = message.artifact("frame")?;

View file

@ -179,7 +179,9 @@ async fn collection_waits_for_every_retained_owner(via: Via) {
s.owners == 0 && s.sealed_artifacts == 0 && s.store_bytes == 0
})
.await;
assert_eq!(e.files("sealed"), 0);
// The unlink follows the registry update, outside the router lock: wait for the file to
// go rather than assume the two happen together.
e.settle_files("sealed", 0).await;
}
// -------------------------------------------------------------------------------------------
@ -521,7 +523,7 @@ async fn disconnect_abandons_an_unsealed_writer(via: Via) {
s.artifacts == 0 && s.store_bytes == 0 && s.owners == 0
})
.await;
assert_eq!(e.files("staging"), 0);
e.settle_files("staging", 0).await;
}
/// An abrupt disconnect must release an explicit hold too, when it was the object's only root.
@ -538,7 +540,7 @@ async fn disconnect_releases_an_explicit_hold(via: Via) {
s.sealed_artifacts == 0 && s.owners == 0 && s.store_bytes == 0
})
.await;
assert_eq!(e.files("sealed"), 0);
e.settle_files("sealed", 0).await;
}
/// A vanished subscriber must give up both a delivery it already holds and one still queued

View file

@ -420,14 +420,20 @@ async fn bounded_overflow_rolls_back_all_artifact_roots(via: Via) {
}
/// bus-v1 section 7: "New subscriptions with replayLatest enqueue it before subsequent accepted
/// publications." A fresh `latest` subscription's replay claims its first in-flight credit
/// immediately (there is nothing else competing for it yet), so a publish accepted right after
/// subscribing must still be observed strictly after the replay, never ahead of or merged with
/// it: each keeps its own delivery.
/// publications ... bounded mode preserves that order, while latest mode may coalesce it before
/// delivery under the ordinary latest rule." The replay is enqueued under the subscribe lock, so
/// a publication admitted after `subscribe` returned is always behind it; what the two modes do
/// with that order is what differs, and one racing publication is put to both at once.
///
/// The bounded subscription must deliver both values, replay first. The latest subscription
/// either does the same or replaces the still-queued replay, and the router says which in the
/// racing publication's own `replaced` count rather than the test guessing from how fast the
/// dispatcher ran: what it may never do is reorder the two or lose the newer value.
async fn latest_replay_is_ordered_ahead_of_a_racing_publish(via: Via) {
let e = env(via).await;
let admin = e.client("admin").await;
let reader = e.client("reader").await;
let viewer = e.client("viewer").await;
admin
.declare_topic("t.replay-race", Retained::Latest)
.await
@ -436,26 +442,66 @@ async fn latest_replay_is_ordered_ahead_of_a_racing_publish(via: Via) {
.publish("t.replay-race", obj(json!({"v": "old"})), &[])
.await
.unwrap();
let mut sub = reader
let mut fifo = reader
.subscribe(
"t.replay-race",
SubscriptionConfig::bounded().in_flight(1).replay(true),
)
.await
.unwrap();
let mut coalescing = viewer
.subscribe(
"t.replay-race",
SubscriptionConfig::latest().in_flight(1).replay(true),
)
.await
.unwrap();
admin
let racing = admin
.publish("t.replay-race", obj(json!({"v": "new"})), &[])
.await
.unwrap();
let first = within("the replay arrives first", sub.next())
assert_eq!(
racing.subscribers, 2,
"one publication, admitted behind both replays"
);
let first = within("the replay arrives first", fifo.next())
.await
.unwrap();
assert_eq!(first.payload()["v"], "old");
drop(first); // the sole in-flight credit must return before the queued second value moves
let second = within("the racing publish follows, not coalesced away", sub.next())
let second = within("the racing publish follows, not coalesced away", fifo.next())
.await
.unwrap();
assert_eq!(second.payload()["v"], "new");
assert_eq!(
(second.payload()["v"].as_str(), second.replaced()),
(Some("new"), 0),
"bounded preserves the order and coalesces nothing"
);
let m = within("the latest subscription's first delivery", coalescing.next())
.await
.unwrap();
if racing.replaced == 1 {
assert_eq!(
(m.payload()["v"].as_str(), m.replaced()),
(Some("new"), 1),
"a replay still queued is replaced by the newer value, and the delivery says so"
);
drop(m);
quiet("nothing behind a coalesced replay", coalescing.next()).await;
} else {
assert_eq!(
(racing.replaced, m.payload()["v"].as_str(), m.replaced()),
(0, Some("old"), 0),
"a replay already in flight keeps its own delivery"
);
drop(m);
let after = within("the racing publish follows it", coalescing.next())
.await
.unwrap();
assert_eq!(after.payload()["v"], "new");
}
}
/// bus-v1 section 7: clearing releases only the retained root; a later `replayLatest`

View file

@ -1,6 +1,11 @@
//! bus-v1 section 11 item 6: two parallel fake agents, complete-batch environment RPC,
//! committed snapshot publication and a deliberately slow presentation consumer, all over one
//! router. Generic services only; nothing here knows what a brain or a game is.
//!
//! The presentation consumer is held until the publisher's own completion is observed, so the
//! latest subscription has to coalesce instead of happening to: section 7 lets a latest
//! subscriber miss values, it does not oblige it to, and a test that demands a miss it cannot
//! force is asserting how fast the machine is.
mod common;
@ -148,20 +153,31 @@ async fn session_over_one_router(via: Via) {
)
.await
.unwrap();
// A renderer that does not read a single snapshot until the publisher has finished every
// one of them. The hold ends on the publisher's observed completion, never on a timer.
let (release, held) = tokio::sync::oneshot::channel::<()>();
let presenting = tokio::spawn(async move {
held.await.unwrap();
let mut seen = Vec::new();
let mut coalesced = 0;
while let Some(m) = slow.next().await {
let (sequence, replaced) = (m.topic_sequence(), m.replaced());
let frame = m.artifact("frame").unwrap();
drop(m);
tokio::time::sleep(Duration::from_millis(25)).await; // a slow renderer
let bytes = frame.read_all().await.unwrap();
let step = bytes[0] as u64;
assert_eq!(
sequence, step,
"a delivery carries the frame of the snapshot it announces"
);
coalesced += replaced;
seen.push((step, frame.reference().artifact_id.clone()));
if step == STEPS {
break;
}
}
seen
(seen, coalesced)
});
let recorder = e.client("recorder").await;
let mut all = recorder
@ -179,6 +195,7 @@ async fn session_over_one_router(via: Via) {
seq
});
let mut replaced_at_admission = 0;
for step in 1..=STEPS {
let advanced = coordinator
.call_and_wait(
@ -226,8 +243,16 @@ async fn session_over_one_router(via: Via) {
)
.await
.unwrap();
assert_eq!(receipt.topic_sequence, step);
assert_eq!(
(receipt.topic_sequence, receipt.subscribers),
(step, 2),
"both subscriptions accept every publication: the stalled latest spectator neither \
refuses one nor drops out of the fan-out"
);
replaced_at_admission += receipt.replaced;
}
// The publisher is finished, observably, so the renderer may start.
release.send(()).unwrap();
let recorded = within("recorder", recording).await.unwrap();
assert_eq!(
@ -235,17 +260,30 @@ async fn session_over_one_router(via: Via) {
(1..=STEPS).map(|s| (s, s)).collect::<Vec<_>>(),
"the bounded recorder misses nothing"
);
let presented = within("presenter", presenting).await.unwrap();
assert_eq!(
presented.last().unwrap().0,
STEPS,
"the slow consumer ends on the latest snapshot"
);
let (presented, coalesced) = within("presenter", presenting).await.unwrap();
let steps: Vec<u64> = presented.iter().map(|(s, _)| *s).collect();
assert!(
presented.len() < STEPS as usize,
"the slow consumer skipped snapshots: {presented:?}"
steps.windows(2).all(|w| w[0] < w[1]),
"what a latest subscription does deliver arrives in publication order: {presented:?}"
);
assert_eq!(
steps.last().copied(),
Some(STEPS),
"the slow consumer ends on the latest snapshot: {presented:?}"
);
assert_eq!(
steps,
vec![1, STEPS],
"held for the whole run, the subscription keeps the one delivery already in flight and \
one replaceable queued value, so the renderer sees the first snapshot and the last, \
and the eighteen between them were coalesced: {presented:?}"
);
assert_eq!(
(coalesced, replaced_at_admission),
(STEPS - 2, STEPS - 2),
"every snapshot the renderer missed is counted as a replacement, to the publisher at \
admission and to the renderer on its next delivery: none is lost silently"
);
assert!(presented.windows(2).all(|w| w[0].0 < w[1].0));
for t in agents {
t.abort();

View file

@ -266,13 +266,14 @@ async fn pending_connections_are_bounded_and_hello_expires() {
let router = Router::new(config).unwrap();
let pending = router.connect_in_memory_as("first");
tokio::time::timeout(Duration::from_millis(20), async {
// Registration happens on the router's own task. How long that takes is this box's
// business; that it happens is the router's.
within("the pending connection occupies the only slot", async {
while router.stats().connections != 1 {
tokio::task::yield_now().await;
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await
.unwrap();
.await;
assert_eq!(router.stats().connections, 1);
let refused = Client::connect(
router.connect_in_memory_as("second"),
@ -280,7 +281,14 @@ async fn pending_connections_are_bounded_and_hello_expires() {
)
.await;
assert_eq!(refused.unwrap_err().code, ErrorCode::RouterLost);
tokio::time::sleep(Duration::from_millis(80)).await;
// The 40 ms hello timeout expires on the router's clock: wait for the expiry to be
// observed rather than sleep past it and read the count once.
within("the pending Hello expires", async {
while router.stats().connections != 0 {
tokio::time::sleep(Duration::from_millis(1)).await;
}
})
.await;
assert_eq!(router.stats().connections, 0, "pending Hello timed out");
drop(pending);

View file

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

View file

@ -42,6 +42,15 @@ struct Args {
/// the "nothing to compare" case for a fresh container.
#[arg(long, value_name = "DIR")]
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<()> {
@ -66,6 +75,17 @@ fn main() -> Result<()> {
return Ok(());
}
if let Some(rank) = args.reset_to_milestone {
let stamp = flysim::reset::utc_stamp(flysim::eventlog::now_wall_ms());
let durable = config.paths.save_dir.clone();
let hot = config.paths.hot_dir.clone();
let archive = flysim::reset::default_archive_dir(&durable, &stamp);
for line in flysim::reset::reset_to_milestone(&durable, &hot, rank, &archive)? {
println!("{line}");
}
return Ok(());
}
flysim::run(config)
}

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 {
bail!("checkpoint is for another cartridge ({})", runtime.rom_sha256);
}
if runtime.compatibility != self.compatibility {
bail!(
"compatibility mismatch\n checkpoint: {}\n this build: {}",
// Byte-identical, or the one documented migration the operator asked for.
//
// `FLY_ACCEPT_ADAPTERS` is read here rather than carried in `Config` because it is a
// property of a *deploy*, not of a run: `infra/05-deploy.sh` writes it into
// `/etc/fly/fly.env` only for the deploy that needs it, and an operator who wants the
// migration off again deletes one line. An empty or unset variable is no migration at
// all, which is what every deploy before this one did.
let accepted = flybrain_gb::compatibility::accepted_adapters(
std::env::var(flybrain_gb::compatibility::ACCEPT_ADAPTERS_ENV).ok().as_deref(),
);
match flybrain_gb::compatibility::decide(
&runtime.compatibility,
&self.compatibility,
self.adapter.migrates_from(),
&accepted,
) {
flybrain_gb::compatibility::RestoreDecision::Exact => {}
flybrain_gb::compatibility::RestoreDecision::MigrateAdapter { from } => {
tracing::warn!(
from = %from,
to = %self.adapter.id(),
"restoring a checkpoint from an earlier adapter, by the migration \
FLY_ACCEPT_ADAPTERS opted this deploy into"
);
}
flybrain_gb::compatibility::RestoreDecision::Refuse(reason) => bail!(
"compatibility mismatch: {reason}\n checkpoint: {}\n this build: {}",
runtime.compatibility,
self.compatibility
);
),
}
if runtime.framebuffer.len() != FRAMEBUFFER_LEN {
bail!("checkpoint framebuffer is {} bytes", runtime.framebuffer.len());

View file

@ -241,8 +241,8 @@ pub struct FeedMacroOutcome {
}
/// Reward categories the feed reports counts for. The adapter's own interned kinds
/// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`) map
/// onto these.
/// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`,
/// `catch`) map onto these.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RewardKind {
@ -282,6 +282,15 @@ impl RewardKind {
// protocol is concerned: finding a door is finding somewhere new, and the design asks
// for no new feed kind.
"boundary" => Self::Explore,
// `catch` is a wild battle the fly won by keeping the Pokémon, so it publishes on
// the same counter a wild KO does. The feed's kinds are a closed set
// (`docs/feed-protocol.md`) and this rule asked for no new one.
//
// Deliberately *not* `pokedex`: on a catch of a species this run has never owned,
// the cartridge sets the Pokédex bit and the adapter's existing `species` rule pays
// for it on the same frame, so the `pokedex` counter already moves. Mapping `catch`
// there as well would count one event twice.
"catch" => Self::Wildwin,
// The platformer.
"band" => Self::Explore,
"coin" => Self::Wildwin,
@ -739,6 +748,7 @@ mod tests {
}
assert_eq!(RewardKind::from_adapter("nonsense"), None);
assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore));
assert_eq!(RewardKind::from_adapter("catch"), Some(RewardKind::Wildwin));
}
#[test]

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',
'wTilesetBank',
'wTilesetBlocksPtr',
# The catch reward (`docs/rewards-learning.md`, `docs/design/macros-wram.md` section 2).
# ram/wram.asm's own comment is "0 if no mon was captured": ItemUseBall zeroes it before
# every throw and writes wEnemyMonSpecies into it only on the branch that keeps the
# Pokemon, and UseBagItem zeroes it again on the way out of the battle. It is the
# cartridge's own answer to "was this one caught", and the only signal that needs no
# second rule to tell a catch apart from a gift, a trade or an evolution.
# services/flysim/tools/resolve_wram.py is the second reading of it, from ram/wram.asm at
# this commit, bracketed by wFontLoaded and wForcePlayerToChooseMon.
'wCapturedMonSpecies',
)

View file

@ -49,6 +49,33 @@ WANTED = {
# not bank 0, so this is the read the memory seam grew a bank for.
'wTilesetBank': 'the ROM bank the blockset lives in',
'wTilesetBlocksPtr': 'blocks to tiles, 16 bytes per block',
# The catch reward (`docs/rewards-learning.md`, `docs/design/macros-wram.md`
# section 10). ram/wram.asm's own comment is "0 if no mon was captured":
# ItemUseBall zeroes it before every throw and writes wEnemyMonSpecies into it
# only on the branch that keeps the caught Pokemon, and UseBagItem zeroes it
# again on the way out of the battle. It is the cartridge's own answer to "was
# this one caught", and the only signal that needs no second rule to tell a
# catch apart from a gift, a trade or an evolution.
'wCapturedMonSpecies': 'the species a ball just caught, 0 for none',
}
#: Constants the decomp defines through its `const` enumeration rather than with a
#: plain `EQU`, so `constants()` cannot evaluate their expressions. They matter here
#: because `NUM_TMS + NUM_HMS` is the size of `wMonHLearnset`, and that one
#: declaration is what kills the cursor on its way through the battle engine's
#: scratch bytes -- the region `wCapturedMonSpecies` lives in.
#:
#: Each is *counted* from the decomp rather than written out by hand, which is the
#: same rule the rest of this tool follows. `DEF NUM_HMS EQU const_value - HM01` is
#: by construction the number of `add_hm` definitions after `HM01`, and
#: `item_constants.asm`'s own `ASSERT NUM_TMS == const_value - TM01` ties `NUM_TMS`
#: to the number of `add_tm` definitions -- so `NUM_TMS` is counted *and* compared
#: against the literal the same file declares, and a decomp that moved one without
#: the other stops the run instead of producing an address.
COUNTED = {
'NUM_HMS': ('constants/item_constants.asm', r'^\s*add_hm\s+\w+'),
'NUM_TMS': ('constants/item_constants.asm', r'^\s*add_tm\s+\w+'),
}
@ -68,6 +95,10 @@ def constants(root: Path) -> dict[str, int]:
BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left
out, which kills the cursor at any declaration that uses it.
"""
counted = {
name: len(re.findall(pattern, (root / path).read_text(), re.M))
for name, (path, pattern) in COUNTED.items()
}
pending: dict[str, str] = {}
sources = sorted((root / 'constants').glob('*.asm')) + sorted(
(root / 'constants').glob('*.inc')
@ -77,7 +108,7 @@ def constants(root: Path) -> dict[str, int]:
r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M
):
pending.setdefault(name, value.strip())
out: dict[str, int] = {}
out: dict[str, int] = dict(counted)
while pending:
progressed = False
for name in list(pending):
@ -89,6 +120,12 @@ def constants(root: Path) -> dict[str, int]:
progressed = True
if not progressed:
break
for name, value in counted.items():
if out.get(name, value) != value:
raise SystemExit(
f'{name}: the decomp declares {out[name]} and defines {value} of them'
)
out[name] = value
return out
@ -367,11 +404,17 @@ def main() -> None:
raise SystemExit('the walk disagrees with symbols.rs; nothing emitted')
print(f'{checked} of {len(table)} pinned addresses re-derived from wram.asm, no disagreement')
missing = [name for name in WANTED if name not in resolved]
# A name this tool has already emitted is pinned, so the walk meets it as an
# anchor rather than resolving it: it was re-derived all the same, and the
# comparison above is what says so.
missing = [name for name in WANTED if name not in resolved and name not in table]
if missing:
raise SystemExit(f'unanchored, so not resolved: {", ".join(missing)}')
for name in WANTED:
if name in resolved:
print(f'{name} = ${resolved[name]:04x} ({WANTED[name]})')
else:
print(f'{name} = ${table[name]:04x} (already pinned; {WANTED[name]})')
if not args.emit:
return