Merge main into feat/sf-state-01
This commit is contained in:
commit
388e112ad5
29 changed files with 2112 additions and 74 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
63
infra/05-deploy.sh
Executable file → Normal 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
|
||||
|
||||
|
|
|
|||
77
infra/bin/fly-reset-to-milestone
Executable file
77
infra/bin/fly-reset-to-milestone
Executable 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]*'"
|
||||
|
|
@ -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
14
infra/env/example.env
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(¤t, ¤t, &[], &[]), RestoreDecision::Exact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v5_checkpoint_restores_under_v6_only_with_the_opt_in() {
|
||||
let old = with_adapter("pokered-unique8-v5");
|
||||
let new = with_adapter("pokered-unique8-v6");
|
||||
let migrates = ["pokered-unique8-v5"];
|
||||
|
||||
assert!(matches!(decide(&old, &new, &migrates, &[]), RestoreDecision::Refuse(_)));
|
||||
assert_eq!(
|
||||
decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v5"))),
|
||||
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() }
|
||||
);
|
||||
// And only for a pair the running adapter says it can migrate.
|
||||
assert!(matches!(
|
||||
decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v5"))),
|
||||
RestoreDecision::Refuse(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_but_the_adapter_segment_may_move() {
|
||||
let migrates = ["pokered-unique8-v5"];
|
||||
let accepted = accepted_adapters(Some("pokered-unique8-v5"));
|
||||
let new = with_adapter("pokered-unique8-v6");
|
||||
|
||||
// A different dataset, with the same adapter bump, is not a migration.
|
||||
let other_dataset = Compatibility {
|
||||
adapter: "pokered-unique8-v5",
|
||||
dataset_fingerprint: "00:11:22:33:44:55:66",
|
||||
..fixture()
|
||||
}
|
||||
.string();
|
||||
assert!(matches!(
|
||||
decide(&other_dataset, &new, &migrates, &accepted),
|
||||
RestoreDecision::Refuse(_)
|
||||
));
|
||||
|
||||
// Neither is a different kernel, and neither is a string of another shape.
|
||||
let other_kernel =
|
||||
Compatibility { adapter: "pokered-unique8-v5", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() }
|
||||
.string();
|
||||
assert!(matches!(
|
||||
decide(&other_kernel, &new, &migrates, &accepted),
|
||||
RestoreDecision::Refuse(_)
|
||||
));
|
||||
assert!(matches!(decide("a/b", &new, &migrates, &accepted), RestoreDecision::Refuse(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_opt_in_list_is_separated_by_commas_or_spaces() {
|
||||
assert!(accepted_adapters(None).is_empty());
|
||||
assert!(accepted_adapters(Some(" ")).is_empty());
|
||||
assert_eq!(
|
||||
accepted_adapters(Some("pokered-unique8-v5, pokered-unique8-v4")),
|
||||
vec!["pokered-unique8-v5".to_string(), "pokered-unique8-v4".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_state_format_segment_is_appended_not_interleaved() {
|
||||
let full = fixture().string();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
450
services/flysim/crates/flysim/src/reset.rs
Normal file
450
services/flysim/crates/flysim/src/reset.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
224
services/flysim/crates/flysim/tests/compat_migration.rs
Normal file
224
services/flysim/crates/flysim/tests/compat_migration.rs
Normal 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,
|
||||
¤t,
|
||||
adapter.migrates_from(),
|
||||
&accepted_adapters(opt_in),
|
||||
),
|
||||
RestoreDecision::Refuse(_)
|
||||
),
|
||||
"FLY_ACCEPT_ADAPTERS={opt_in:?} must not migrate anything"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_zero() {
|
||||
let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes");
|
||||
let mut adapter = PokemonRedReward::new();
|
||||
let current = compatibility(adapter.id());
|
||||
|
||||
assert_eq!(
|
||||
decide(
|
||||
&checkpoint.runtime.compatibility,
|
||||
¤t,
|
||||
adapter.migrates_from(),
|
||||
&accepted_adapters(Some("pokered-unique8-v5")),
|
||||
),
|
||||
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() }
|
||||
);
|
||||
|
||||
// The migration itself: `import_state`, exactly as `Sim::try_restore` calls it.
|
||||
adapter.import_state(&checkpoint.runtime.reward).expect("a v5 ledger is a valid v6 ledger");
|
||||
|
||||
let after = adapter.export_state();
|
||||
assert_eq!(after["catchCounts"], serde_json::json!({}), "the new counter starts at 0");
|
||||
assert_eq!(after["counts"]["catch"], serde_json::json!(0));
|
||||
|
||||
// And nothing else moved: every field the v5 state carried round-trips to the same value,
|
||||
// and the only key v6 adds is the counter.
|
||||
//
|
||||
// `counts` is the one field that is not byte-identical, and it is not a change of meaning:
|
||||
// it serializes every kind in the catalog, so a v6 state lists `catch` where a v5 state had
|
||||
// nothing to list. Every kind the v5 state did carry keeps its number.
|
||||
let before = v5_reward();
|
||||
for (key, value) in before.as_object().unwrap() {
|
||||
if key == "counts" {
|
||||
for (kind, count) in value.as_object().unwrap() {
|
||||
assert_eq!(&after["counts"][kind], count, "counts.{kind}");
|
||||
}
|
||||
let added: Vec<&String> = after["counts"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|kind| !value.as_object().unwrap().contains_key(*kind))
|
||||
.collect();
|
||||
assert_eq!(added, vec!["catch"], "v6 counts one more kind and no others");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(&after[key], value, "{key} must survive the migration byte for byte");
|
||||
}
|
||||
let added: Vec<&String> = after
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|key| !before.as_object().unwrap().contains_key(*key))
|
||||
.collect();
|
||||
assert_eq!(added, vec!["catchCounts"], "v6 adds one field and no others");
|
||||
|
||||
// The rest of what a restore reads is untouched by the migration.
|
||||
assert_eq!(adapter.progress().rank, 3);
|
||||
assert_eq!(checkpoint.runtime.last_event_id, 4_242);
|
||||
assert_eq!(checkpoint.runtime.ratchet.best, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() {
|
||||
let adapter = PokemonRedReward::new();
|
||||
let accepted = accepted_adapters(Some("pokered-unique8-v5"));
|
||||
let current = compatibility(adapter.id());
|
||||
|
||||
// A v5 string whose state format also moved: a different build, not a rule change.
|
||||
let other_abi = compatibility("pokered-unique8-v5").replace("199616", "199617");
|
||||
assert!(matches!(
|
||||
decide(&other_abi, ¤t, adapter.migrates_from(), &accepted),
|
||||
RestoreDecision::Refuse(_)
|
||||
));
|
||||
|
||||
// And an identical string needs no opt-in at all.
|
||||
assert_eq!(
|
||||
decide(¤t, ¤t, adapter.migrates_from(), &[]),
|
||||
RestoreDecision::Exact
|
||||
);
|
||||
}
|
||||
257
services/flysim/crates/flysim/tests/rom_catch.rs
Normal file
257
services/flysim/crates/flysim/tests/rom_catch.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,33 @@ WANTED = {
|
|||
# not bank 0, so this is the read the memory seam grew a bank for.
|
||||
'wTilesetBank': 'the ROM bank the blockset lives in',
|
||||
'wTilesetBlocksPtr': 'blocks to tiles, 16 bytes per block',
|
||||
# The catch reward (`docs/rewards-learning.md`, `docs/design/macros-wram.md`
|
||||
# section 10). ram/wram.asm's own comment is "0 if no mon was captured":
|
||||
# ItemUseBall zeroes it before every throw and writes wEnemyMonSpecies into it
|
||||
# only on the branch that keeps the caught Pokemon, and UseBagItem zeroes it
|
||||
# again on the way out of the battle. It is the cartridge's own answer to "was
|
||||
# this one caught", and the only signal that needs no second rule to tell a
|
||||
# catch apart from a gift, a trade or an evolution.
|
||||
'wCapturedMonSpecies': 'the species a ball just caught, 0 for none',
|
||||
}
|
||||
|
||||
|
||||
#: Constants the decomp defines through its `const` enumeration rather than with a
|
||||
#: plain `EQU`, so `constants()` cannot evaluate their expressions. They matter here
|
||||
#: because `NUM_TMS + NUM_HMS` is the size of `wMonHLearnset`, and that one
|
||||
#: declaration is what kills the cursor on its way through the battle engine's
|
||||
#: scratch bytes -- the region `wCapturedMonSpecies` lives in.
|
||||
#:
|
||||
#: Each is *counted* from the decomp rather than written out by hand, which is the
|
||||
#: same rule the rest of this tool follows. `DEF NUM_HMS EQU const_value - HM01` is
|
||||
#: by construction the number of `add_hm` definitions after `HM01`, and
|
||||
#: `item_constants.asm`'s own `ASSERT NUM_TMS == const_value - TM01` ties `NUM_TMS`
|
||||
#: to the number of `add_tm` definitions -- so `NUM_TMS` is counted *and* compared
|
||||
#: against the literal the same file declares, and a decomp that moved one without
|
||||
#: the other stops the run instead of producing an address.
|
||||
COUNTED = {
|
||||
'NUM_HMS': ('constants/item_constants.asm', r'^\s*add_hm\s+\w+'),
|
||||
'NUM_TMS': ('constants/item_constants.asm', r'^\s*add_tm\s+\w+'),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -68,6 +95,10 @@ def constants(root: Path) -> dict[str, int]:
|
|||
BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left
|
||||
out, which kills the cursor at any declaration that uses it.
|
||||
"""
|
||||
counted = {
|
||||
name: len(re.findall(pattern, (root / path).read_text(), re.M))
|
||||
for name, (path, pattern) in COUNTED.items()
|
||||
}
|
||||
pending: dict[str, str] = {}
|
||||
sources = sorted((root / 'constants').glob('*.asm')) + sorted(
|
||||
(root / 'constants').glob('*.inc')
|
||||
|
|
@ -77,7 +108,7 @@ def constants(root: Path) -> dict[str, int]:
|
|||
r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M
|
||||
):
|
||||
pending.setdefault(name, value.strip())
|
||||
out: dict[str, int] = {}
|
||||
out: dict[str, int] = dict(counted)
|
||||
while pending:
|
||||
progressed = False
|
||||
for name in list(pending):
|
||||
|
|
@ -89,6 +120,12 @@ def constants(root: Path) -> dict[str, int]:
|
|||
progressed = True
|
||||
if not progressed:
|
||||
break
|
||||
for name, value in counted.items():
|
||||
if out.get(name, value) != value:
|
||||
raise SystemExit(
|
||||
f'{name}: the decomp declares {out[name]} and defines {value} of them'
|
||||
)
|
||||
out[name] = value
|
||||
return out
|
||||
|
||||
|
||||
|
|
@ -367,11 +404,17 @@ def main() -> None:
|
|||
raise SystemExit('the walk disagrees with symbols.rs; nothing emitted')
|
||||
print(f'{checked} of {len(table)} pinned addresses re-derived from wram.asm, no disagreement')
|
||||
|
||||
missing = [name for name in WANTED if name not in resolved]
|
||||
# A name this tool has already emitted is pinned, so the walk meets it as an
|
||||
# anchor rather than resolving it: it was re-derived all the same, and the
|
||||
# comparison above is what says so.
|
||||
missing = [name for name in WANTED if name not in resolved and name not in table]
|
||||
if missing:
|
||||
raise SystemExit(f'unanchored, so not resolved: {", ".join(missing)}')
|
||||
for name in WANTED:
|
||||
print(f'{name} = ${resolved[name]:04x} ({WANTED[name]})')
|
||||
if name in resolved:
|
||||
print(f'{name} = ${resolved[name]:04x} ({WANTED[name]})')
|
||||
else:
|
||||
print(f'{name} = ${table[name]:04x} (already pinned; {WANTED[name]})')
|
||||
|
||||
if not args.emit:
|
||||
return
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue