Compare commits

..

85 commits
v0.5.4 ... main

Author SHA1 Message Date
acamilo
eecd5744e0 docs: v0.6.4 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-28 21:38:07 +00:00
acamilo
dc64ffb4ea Merge feat/recovery-splash: an on-stream splash while recovery acts 2026-09-28 21:37:56 +00:00
acamilo
3922b82762 Merge feat/loop-recover-ladder: automatic loop recovery climbs restart, rung reset, rung below 2026-09-28 21:37:50 +00:00
acamilo
26892d8530 loop recovery: wait on the watchdog's ActiveState, and only a root flysim that is the running build
review-ladder r2: a oneshot probe mid-run is 'activating', which
is-active does not count, so the wait never waited. The root copy must
also be byte-identical to /opt/fly/current/flysim, so a manual rollback
cannot approve an archive the running build refuses. Docs: the hold
after an unrestorable rung, the post-deploy --list check, and never
stopping the unit mid-step.
2026-09-28 21:37:40 +00:00
acamilo
865d6f6b55 stage mockups: recovery splash, four phases by two actions 2026-09-28 21:34:37 +00:00
acamilo
c574e7b973 docs: recovery splash and the stream notice contract 2026-09-28 21:34:37 +00:00
acamilo
15f2e6dbae stage: auto-recovery splash over the game, polled from flystage-web
The loop-recovery helper's notice (/run/fly/wd/recovery-notice.json,
FLY_RECOVERY_NOTICE) is served by serve.mjs at /recovery-notice.json and
polled once a second by the page, independent of flysim. Countdown shows a
Game Boy text box over the bottom of the game; acting covers the game while
flysim is down; done/failed show briefly. Stale, malformed or oversized
notices are ignored and helper strings are clipped to a closed charset.
Adds ?recovery=, __stage.recovery(), the recovery-notice dev tool, unit
tests (model, poller, serve.mjs route) and an e2e spec.
2026-09-28 21:34:37 +00:00
acamilo
f205d95e5e loop recovery: review-ladder fixes, root never runs a fly-writable binary
B1: fly-loop-reset runs only /opt/fly/sbin/flysim, a root-owned copy
05-deploy installs from the release tarball after checking it against
the tarball's MANIFEST; fixed paths, fly.env parsed as data, env -i.
B2: the wrapper pauses fly-watchdog.timer (and waits out a running
probe) for the reset, and starts flysim and the timer on every exit.
H1: a step that raises or times out is a failed step; the ladder state
is saved before the step runs. H2: one --list call computes the build's
compatibility once; TimeoutStartSec 25 min. H3: level 2+ resets to the
rung below the best or restarts, never lower.
Tests run the wrapper against a fake flysim, systemctl and archives.
2026-09-28 21:34:32 +00:00
acamilo
9c9cec49a9 loop recovery: reset only to an archive the running build can restore
fly-reset-to-milestone does not check compatibility and a flysim that
refuses every checkpoint does not start. fly-loop-reset --check applies
05-deploy's rule (identical, or an adapter-only difference named in
FLY_ACCEPT_ADAPTERS); the ladder picks the highest restorable rung and
falls back to a restart when there is none.
2026-09-28 21:25:58 +00:00
acamilo
63ecc32b2f loop recovery: an escalation ladder that unsticks a trap on its own
Restart flysim, then reset to the current rung's milestone, then to the
archive below the best rung (never lower), one step per confirmed trap
that outlives the previous one. Two resets a day, three-hour restarts
once they are spent; the ladder starts over at a new best rung or after
six quiet hours. State and history live in the unit's StateDirectory so
a reboot does not forget where the ladder stood.

A router model list confirms each step; a 'not stuck' answer delays it
at most three probes and no answer leaves the watchdog to decide alone.
Each step is announced 60 s ahead in /run/fly/wd/recovery-notice.json
for the stage's recovery splash.

fly-loop-reset is the one new root surface (a sudoers line); 05-deploy
now converges config/fly-sudoers so a release can add it.
2026-09-28 21:23:33 +00:00
acamilo
1bd2223431 Require complete router configuration before veto 2026-09-26 01:23:31 +00:00
acamilo
dccc52ae1c Document recovery and run its regressions in infra gate 2026-09-26 01:15:44 +00:00
acamilo
432991cbc5 Add conservative automated loop recovery timer 2026-09-26 01:12:46 +00:00
acamilo
862e343e1c docs: v0.6.2 status
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
2026-09-23 23:48:20 +00:00
acamilo
d0ce10e3c9 Merge fix/loop-row61: a trainer's challenge is the cartridge's until its battle is over 2026-09-23 23:48:20 +00:00
acamilo
9b117dc4b2 docs: 12.25 and the row 61 audit on top of row 59, the bubble, game.scene in macros mode 2026-09-23 23:47:36 +00:00
acamilo
cff15804fb state, tests: the challenge covers the "!" bubble too, the doc block back above its impl, and the ROM test asserts no pad inside a challenge 2026-09-23 23:47:36 +00:00
acamilo
d6597b3e98 docs: the row 61 audit, a trainer's challenge text walled the forest's corridor 2026-09-23 23:28:17 +00:00
acamilo
0117a85354 docs: macros.md 12.24, a trainer's challenge is the cartridge's until its battle is over 2026-09-23 23:28:12 +00:00
acamilo
c78ca352f6 tests: the ROM proof from the forest's south gate checkpoint 2026-09-23 23:28:05 +00:00
acamilo
0e366cd4e4 state: a trainer's challenge is the cartridge's until its battle is over, in the macros' own scene 2026-09-23 23:27:54 +00:00
acamilo
16bc65b5a8 survey: FLY_PROBE_CATCH_FRAME reads the first free frame from a given one, FLY_PROBE_WHOLE prints a big map whole 2026-09-23 23:27:54 +00:00
acamilo
510727ccc0 docs: v0.6.1 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-23 23:08:10 +00:00
acamilo
f32352bd5e Merge fix/loop-row59: the map graph as the cartridge's headers and warps draw it, pieces and all 2026-09-23 23:08:09 +00:00
acamilo
9ccc0592b1 docs: macros.md 12.24 and the row 59 audit 2026-09-23 22:33:47 +00:00
acamilo
437dfa84a9 tests: from the live Route 4 checkpoint, the fly goes into Mt. Moon and Route 4's west doors are not a ring 2026-09-23 22:33:47 +00:00
acamilo
d181f952a7 tests: from the badge, the road to Mt. Moon is not a ring at the Pewter end, and Route 4's doors and sides are the cartridge's 2026-09-23 22:23:27 +00:00
acamilo
5147ab1a87 geography: a header connection no step on foot crosses is named and is not a way out; Pallet's shore, the sea routes, the League fence 2026-09-23 22:23:01 +00:00
acamilo
97a97a501d geography: a split map is pieces with their own doors and neighbours; Route 4 and Mt. Moon's lower floors, the hop matched on where an exit lands 2026-09-23 22:23:01 +00:00
acamilo
4108fec5ce macros: a push-back is a refusal once the overworld has stayed the fly's for half a second, not on the first frame of it 2026-09-23 22:23:01 +00:00
acamilo
d85ee5026f geography: Route 3/4, 14/15 and 24/25 as the headers connect them, and Mt. Moon's doors on Route 4 2026-09-23 22:23:01 +00:00
acamilo
288e958d19 survey: FLY_PROBE_SAVE_RANK writes the first safe overworld frame at a rung as a checkpoint 2026-09-23 22:23:01 +00:00
acamilo
4d82f7db74 Merge refactor/legacy-frame: FND-01, one LegacyFrame for the service and every harness, FLY_TRACE and the sugar journal 2026-09-23 21:43:45 +00:00
acamilo
2f7b7cebd0 docs: v0.6.0 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-23 21:23:57 +00:00
acamilo
ff49b56e2c docs: the explore row stays "new place" 2026-09-23 21:23:40 +00:00
acamilo
b484a077d4 stage: the explore row reads "new place" again (the operator, 2026-09-23) 2026-09-23 21:23:05 +00:00
acamilo
9301e395e8 docs: the trap hunt and the bench run the service's frame since FND-01, and hunts across the change do not compare 2026-09-23 20:15:20 +00:00
acamilo
9e2f90749f flysim: FLY_TRACE's boundary half read back with fly-session-types, the journal held to the service test, rustfmt on the new files 2026-09-23 20:15:20 +00:00
acamilo
60f09b79a3 flysim: every harness runs the legacy frame: the trap hunt, the benches, the ROM tests' and the probe's stub drivers
trap_hunt, palette_bench and room_escape ran the frame through NeuralAgent::tick, which installs
a frame and its rewards after the next ticks: one frame behind the stream, with the ratchet
observed without the objective signal and a rollback that never re-observed the scene. They now
restore the way the stream restores and run LegacyFrame::transition and ::boundary, looking in
through FrameObserver; FLY_TRACE works in each of them. The stub-readout drivers of
rom_macros_mode, rom_catch and scene_probe run LegacyFrame::execute and ::stub_advance, which are
the same calls they made, in the same order.
2026-09-23 20:15:20 +00:00
acamilo
ad1c0e3693 flysim: a frame-stamped sugar journal in the hot directory
Every admitted sugar and operator reward pulse is appended to sugar-journal.jsonl with the frame
counter it was applied at, which is the step of the next transition in FLY_TRACE, so a shadow run
can replay the audience's input. Append-only, not checkpointed, never read back into the fly.
2026-09-23 20:14:01 +00:00
acamilo
94cc91369a flysim: the legacy frame is one type, flysim::frame::LegacyFrame, and the sim loop runs it
prepare, execute, advance, evaluate, commit, then the boundary's ratchet capture and rollback,
in the order the stream runs them. The frame owns the remainder, the frame counter, the frame on
screen, the mask and the blocked-direction window; the sim loop keeps the feed, the event log,
the milestone archive between commit and boundary, and the checkpoints. Two moves between
disjoint state change no byte: the visual frame is installed at commit, and the stimulations
follow the scene's observation. The FLY_TRACE of a run from the rung-10 checkpoint is
byte-identical to the one the inline hooks wrote.
2026-09-23 20:14:00 +00:00
acamilo
ff74c07622 flysim: FLY_TRACE, a per-frame trace of the legacy loop in the step trace's field names
One JSON line per transition: ticks and the exact remainder, digests of the rates, of the
transition's spikes, of the frame and of work RAM, the decision and the mask, macro and reward
events in order, the rank, and the boundary's slot saves, rollback and captures in the shape of
TraceBehaviour.boundaryActions and TraceOperational.captures. Off unless FLY_TRACE names a file.
Recorded inline in step_frame first, so the frame's extraction can be held to it.
2026-09-23 20:14:00 +00:00
acamilo
bc117ad470 flybrain-gb: say what a move with power does not promise 2026-09-23 19:23:48 +00:00
acamilo
fa021918b4 Merge fix/loop-row60: a move the cartridge would refuse is not dealt beside one that works 2026-09-23 19:23:43 +00:00
acamilo
4591e7907a docs: macros.md 12.23, macros-wram.md section 13, the row 60 audit 2026-09-23 17:59:29 +00:00
acamilo
07f88c443a tests: the ROM proof from the Route 1 checkpoint 2026-09-23 17:23:05 +00:00
acamilo
b33af9e772 scene_probe: the route survey prints the battle bytes a MOVE n button's effect rests on 2026-09-23 17:23:05 +00:00
acamilo
a02d5f7995 trap_hunt: payouts by kind, battle lengths and wins, and MOVE n on a move without effect 2026-09-23 17:23:05 +00:00
acamilo
f3e3e9cf98 palette: a move the cartridge answers with nothing is not dealt beside one it does not
Row 60: TAIL WHIP against a Pidgey at DEFENSE -6 was MOVE 2 183 times. The
rule is PP's: a refused move leaves the pad while another move is usable, and
with none usable the moves stay as PP deals them, so an open list never
comes down to BACK alone.
2026-09-23 17:23:05 +00:00
acamilo
438b2d8540 state: the move table, and whether the battle engine will answer a move with nothing
move_data reads a row of Moves from the cartridge image ($0E:$4000, each row
checked against its own id); move_without_effect answers the refusals the
effect routines make on bytes already in WRAM: a stat stage at its limit or a
stat at 1/999, Mist or a substitute against a stat-lowering move, a status
move against a statused, Poison-type or (Electric) Ground-type target.
MacroState::move_without_effect defaults to false.
2026-09-23 17:23:05 +00:00
acamilo
c8ecf06db5 symbols: the stat stages and the enemy's status, type and stats, resolved from wram.asm
resolve_wram.py now follows the decomp's const and _RS counters and a struct
macro's field labels, so battle_struct fields resolve and 77 of 81 pinned
addresses are re-derived with no disagreement.
2026-09-23 17:23:05 +00:00
acamilo
37adeb9417 docs: a map script's text can pay talk when it names the thing in front, and where it can 2026-09-23 17:22:11 +00:00
acamilo
5d527f0910 Merge feat/engage-reward: the fly is paid for talking to people indoors and picking up items, not for leaving buildings (adapter v7) 2026-09-23 17:20:02 +00:00
acamilo
56b02ff758 rewards: a frame on which no item bit moved reads nothing more 2026-09-23 14:34:10 +00:00
acamilo
8d1d5c44f5 docs: the engagement rewards, and the v6 migration in deploy examples
rewards-learning.md carries the v7 table, the definition of indoors, the four
conditions a conversation pays under, the item and hidden-item reads, the seed,
boundary's indoor rule with the warp-transition frame it was measured on, the
feed-kind choice and the ticker word, and the honesty note on paying for a
conversation. flysim.md records the v6 -> v7 migration; macros-wram.md the six
new reads; the architecture tour and integration.md point at the live catalog.
The deploy comments, example.env and the runbook name FLY_ACCEPT_ADAPTERS=
pokered-unique8-v6 for the v0.6.0 deploy.
2026-09-23 14:34:10 +00:00
acamilo
3cf706c96a rewards: read the conversation's argument once the cartridge has written it, and a ROM test
The first cartridge run found what the synthetic trace could not. In the
Viridian Forest north gate the font bit rose on one frame and DisplayTextID's
argument reached wSpriteIndex twenty frames later: DisplayTextIDInit loads the
font's tiles into VRAM first. Until then the byte still names the previous
text's subject, which may be the person in front of the fly from a conversation
that did not pay. So the watch now remembers the byte from the last ready
frame and reads it once, when it changes or after 45 samples, and only while
the bottom dialogue box is drawn (the start menu draws its own elsewhere); a
box that closes first is read on the closing sample if the dialogue box was up.

tests/rom_engage.rs, gated on FLY_ROM and FLY_ENGAGE_CHECKPOINT: from the rung-10
Pewter checkpoint, with the checkpoint's own v6 ledger restored (the migration,
on real game state), a scripted walker goes down Route 2 into the forest's north
gate, talks to the old man three times (one payout, 0.10) and picks up the
Antidote ball at (25, 11) (one payout, 0.15, "FOUND ITEM #11"); pressing A at
the empty tile pays nothing, and after rolling the emulator back to before the
pickup, taking it again pays nothing. No boundary payout lands while an indoor
header is loaded.

It also measured the warp transition: for about thirty frames of
PlayMapChangeSound wCurMap already names the destination while the tileset and
warp table are still the map being left, so the exit the fly stands on is
classified by the map it belongs to -- a town door still pays its on-exit half,
keyed under the building's id as before, and a building's door does not. A unit
test pins both directions.
2026-09-23 14:34:10 +00:00
acamilo
2d288b0146 stage: the explore row reads "new find"
Since pokered-unique8-v7 the explore feed kind carries four adapter rules --
new ground, an exit, a conversation indoors, an item picked up -- and "new
place" was true of the first two only. The per-game copy now names what all
four are; the feed protocol and the kind set are unchanged.
2026-09-23 14:34:10 +00:00
acamilo
52811bf512 session types: the legacy composition names pokered-unique8-v7
The example composition's adapter and FLYSIM01 string move to v7 with the live
adapter, and the fixtures are regenerated by update_fixtures; valid.json and
invalid.json's hand-written cases follow, with the one deliberately mismatched
string moving from v5 to v6 so it is still the previous adapter.
2026-09-23 14:34:10 +00:00
acamilo
2e7eed0a78 rewards: talk and item, boundary indoors pays nothing, adapter v7 with a v6 migration
The operator's decision of 2026-09-23: pay the fly for engaging with what is
inside a building, and stop paying it for walking back out of one. Chosen over
a pad rule and over weighting the choice, and like v0.5.0's catch reward it is
a catalog change, not a loop-review fix.

Indoors. engage::indoor is two of the cartridge's own tables: not outside by
CheckIfInOutsideMap (tileset OVERWORLD or PLATEAU; WarpFound2 labels the other
branch .indoorMaps) and not a place BikeRidingTilesets lets the bike be ridden
(OVERWORLD, FOREST, UNDERGROUND, SHIP_PORT, CAVERN). That is every house, mart,
center, gym, gate, lab, museum, ship, tower, mansion and hideout, and not
Viridian Forest, a cave, the Underground Path or the dock.

`talk`, +0.10, 100 ms, the catalog's tenth kind. Paid when the text box closes
on a conversation that (1) opened on the sample after one where the fly had the
joypad (state::controllable) and was standing still (wWalkCounter zero, the only
state the overworld reads A in) on the same tile; (2) is with the thing in
front: DisplayTextID copies its argument into wSpriteIndex, a sprite slot up to
wNumSprites whose sprite stands on the tile the player faces (or one further
across a counter, on a tileset with counter tiles), or a text id matching the
sign on that tile; an item ball is not a person; (3) opened indoors; (4) closed
on the same map. Keyed talk:<map>:sprite:<slot> / talk:<map>:sign:<id> in the
lifetime `seen` ledger, which is checkpointed and survives a rollback -- not the
macros' session `talked` ledger.

`item`, +0.15, 120 ms, the eleventh. An item ball is a toggleable sprite of this
map (wToggleableObjectList) whose wMapSpriteExtraData is (item id, 0), the shape
LoadMapHeader writes for an ITEM object_event and nothing else; PickUpItem sets
its global bit in wToggleableObjectFlags after GiveItem succeeds. A hidden item
is a bit of wObtainedHiddenItemsFlags, set by FoundHiddenItemText after GiveItem
and by nothing else. Either pays when its bit rises between two playable
samples, once per item (item:<global> / hidden:<index>) for the ledger's life.

`boundary` still writes every key indoors, so exit_visited answers what it did
and the macros see no change, but emits nothing on an indoor map. Outdoor,
forest and cave exits pay as before.

v6 -> v7. STATE_VERSION stays 4 and no field is added: the new ledgers are keys
in `seen`. A v6 state restores with no talk: keys, and the first playable sample
that finds `items:seeded` absent writes one key per item the game already shows
as taken, pays for none, and marks the seed -- so a rollback that un-takes a v6
pickup cannot pay for it. The two item balls a script reveals (the Rocket
Hideout's Silph Scope and Lift Key, toggles $87 and $88, the only ITEM entries
toggleable_objects.asm starts OFF) are left out of the seed. MIGRATES_FROM is
["pokered-unique8-v6"]; v5 is no longer migrated.

Feed kinds: both publish on `explore`, the family of new ground and a door found,
at the same quiet scale; not `area` (maps, notable), `story` or `wildwin`. No
feed-protocol change.

The compatibility string differs from main's in exactly one segment:
pokered-unique8-v6 -> pokered-unique8-v7.

Tests: catalog values and order; indoor over all 24 tilesets; a talk pays once
per (map, object), not while the box is open, not re-talked, not outdoors/in the
forest/in a cave, not for text opened with the joypad taken, simulated, scripted,
mid-step, about someone not in front, or the start menu; not across a warp or a
rollback; counter reach only with counter tiles; item balls, people, trainers,
hidden items, the seed and the script-shown balls; boundary indoors records and
pays nothing; a v6 state and a v6 FLYSIM01 envelope migrate with the new ledgers
empty and the items seeded. rom.rs's bedroom walk now proves the stairs are
recorded and unpaid on the cartridge.
2026-09-23 14:34:10 +00:00
acamilo
ed0080ab2c symbols: the six WRAM names the engagement rewards read
wSpriteIndex, wWalkCounter, wMapSpriteExtraData, wToggleableObjectFlags,
wToggleableObjectList and wObtainedHiddenItemsFlags, resolved by
tools/resolve_wram.py from ram/wram.asm at the pinned commit and bracketed by
addresses symbols.rs already carries. Two of the brackets needed the tool to
count NUM_STATS and NUM_CITY_MAPS, which the decomp defines as const_value over
an enumeration; both are now counted up to the line that defines them.
2026-09-23 14:34:10 +00:00
acamilo
7784a9d172 docs: v0.5.5 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-23 13:43:21 +00:00
acamilo
6fd6d840dc Merge fix/loop-row58: the gym's people are there when the screen does not draw them, and the game's own moves write no ledger 2026-09-23 13:43:18 +00:00
acamilo
0f510c93aa infra(05-deploy): refuse a bad FLY_FEED_VIA before the release is flipped, not half way through 2026-09-23 13:13:59 +00:00
acamilo
0ca5f000d6 Merge feat/fly-edge-feed: EDGE-01, the feed over flybus behind FLY_FEED_VIA, served by fly-edge 2026-09-23 13:13:18 +00:00
acamilo
21701579a3 docs: the row 58 audit 2026-09-23 13:08:40 +00:00
acamilo
fc7fdffa6c docs: flybus.md, the reachable worst case and the known limits of the feed on the bus
Review round 1. The sizing amendment's worst case is one socket client with
all four subscriptions (15 snapshots, about 1.8 MB, 472,061 bytes measured
because fan-out shares artifacts), not seven stuck subscribers. The lifecycle
amendment gains deploy-time validation, the flyedge cpuset and the absolute
bus dir; a known-limits list records what review round 1 left as notes: feed
counters off the container, store quota per router, rollback while in bus
mode, and the old fixtures.
2026-09-23 11:59:40 +00:00
acamilo
4b1559b45a fly-edge, flysim: a taken feed port is named as such; FLY_BUS_DIR must be absolute; worst case restated
Review round 1. A bind failure after subscribing was logged once as
"waiting for the feed bus". session() now ends as Unreachable, BindFailed or
BusLost, each logged as what it is (once per streak), and
fly_edge_bind_failures_total counts the second; a new parity test holds the
port, sees the edge fail to bind without claiming to serve, frees it and gets
served.

feed.bus_dir (FLY_BUS_DIR) must be absolute and non-empty, checked in either
mode, since flysim and the edge each resolve it and a relative path would
let them disagree.

The sizing worst case was 7 stuck subscribers; that seat does not exist,
since edge.sock admits one client. It is that client with all 4
subscriptions it may open: 4*3+1+2 = 15 snapshots, about 1.8 MB. feedbus's
comment and unit test say so, and stall.rs's hoarding scenario now takes all
four subscriptions, checks a fifth and a second connection are refused, and
bounds the store at 15 snapshots.
2026-09-23 11:59:26 +00:00
acamilo
2da7f688a8 infra: FLY_FEED_VIA is validated at deploy and read case-blind by the watchdog; flyedge gets the page's CPUs
Review round 1. flysim reads FLY_FEED_VIA case-insensitively, so check 2 must
too: with Bus in fly.env it read flysim's zeroed counters and would have
escalated to restarting flystage and flycast every pass. It now lowercases.
05-deploy.sh runs the value through feed_via_normalize (lib/common.sh) and
dies on anything but direct|bus, writing the lowercased word, so a typo is a
deploy refusal instead of a flysim boot loop.

lint's fly.target check read only the first physical Wants=/Requires= line;
target_pulls joins backslash continuations and drops comments, with a
fixture that names a unit only on a continuation line. The cpuset loop writes
a flyedge drop-in on the page CPUs, and lint holds it.
2026-09-23 11:50:11 +00:00
acamilo
ae3b15ab70 tests: the ROM proof from the gym-door checkpoint
From the row-58 checkpoint, 30 brain minutes on the stub rotation: at
most three gym arrivals end in the fly walking straight back out inside
ten seconds, and the fly goes up the room to row 6 or above, where the
Jr. Trainer stands. Base: one arrival, back out in 309 frames, highest
row 11 -- fails. Branch: 4 arrivals, 1 back out, 30,879 frames in the
gym, highest row 2, beside the leader. Rung 11 is printed, not asserted.
2026-09-23 11:48:57 +00:00
acamilo
04657d3324 docs: macros.md 12.22, macros-wram.md section 12 2026-09-23 11:48:57 +00:00
acamilo
2b1a6c0dea watchdog: check 10 reads the reward events, and flags a window busy going nowhere
Row 58's pad was GO OBJECTIVE into the Pewter Gym and GO OUT straight
back out for 25 minutes, diluted by eight other names, every macro done:
ten distinct names, so the four-name sequence rule could not fire, no
macro near 95%, nothing refused or blocked, and the exploration count
flat. What the window did not have was a reward event.

The stream carries the reward events beside the macros, and one more
rule reads them behind the same no-new-ground gate: WD_LOOP_BUSY_MIN
(100) decisions and no reward in the window, on two probes running, is
'unrewarded'. fly_loop_rewards is exported and loop.json carries
window.rewards. Run against the live row-58 log it flags (211 decisions,
0 rewards, 10 names) where the rules before it did not. Still never
acts: the fixture's two new cases restart nothing.
2026-09-23 11:48:57 +00:00
acamilo
28bd980e65 survey: the route probe prints the room whole, its people, and the rung at the end
The catch dump prints a small map whole with its people drawn and off
the screen, each person's ledger entries and whether a route reaches
them; outcomes are keyed by the map they finished on, the trace line
carries the seam's bytes (wCurOpponent included), and the drive ends
with the rank. How row 58's mechanism was read.
2026-09-23 11:48:57 +00:00
acamilo
ddf0743395 a trainer's challenge is not the cartridge refusing a step
Section 12.4 and row 37 read a macro the cartridge ended by taking the
joypad as a refusal and wrote the target blocked and the tile pushed on
the spot. A trainer who sees the fly takes the joypad the same way. In
the Pewter Gym the walk toward the leader crossed the Jr. Trainer's
sight line, and BROCK went into the blocked ledger for ten brain minutes
while the fly lost, blacked out and walked back to a room whose way out
was the pad again.

The entries now wait for the cartridge to give the joypad back: back in
the overworld is a refusal, written as before; a battle is a battle and
teaches the ledgers nothing. Tests for the rung's people off the screen,
facing one of them, and the challenge; the two push-back tests now hand
the joypad back before they read the ledgers.
2026-09-23 11:48:57 +00:00
acamilo
1ba5c80053 tests: stall.rs gates only the pacing claim; the rates move to an ignored perf test
Review round 1, B1. The p99 sleep-overshoot bound measured the OS scheduler
and the published*2 bound failed whenever a starved debug publisher
coalesced, which it is designed to do, so the workspace gate went red on a
loaded box. The three gated tests keep what the slice claims: pacer lag 0,
no watch send held by a consumer (50 ms bound), no refused publication,
something reaches the bus, a bounded store, and a healthy client that reaches
the newest snapshot. The overshoot and throughput bounds are in
the_three_scenarios_keep_their_rates, #[ignore]d.
2026-09-23 11:46:45 +00:00
acamilo
3c614c87f4 docs: flybus.md amendments for the feed on the bus, its sizing and its store lifecycle
The two pending decisions, taken for the feed with EDGE-01 and dated:
sizing from the measured 122,367-byte snapshot (the frame is 92,160 bytes,
not the 1.2 MB the list assumed) with the worst case of seven stuck latest
subscribers at about 3 MB inside a 32 MiB tmpfs store; and the lifecycle,
flysim owning the router under /run/fly/bus and starting first, the edge
After= and Requires= it and reconnecting by itself across a crash. Plus the
design as built, the tour's pointer, the example config and the flybus
README's no-longer-true line.
2026-09-23 11:41:28 +00:00
acamilo
d3f98ae4f1 flysim: stop the bus runtime under the publisher at shutdown, not the router first
Shutting the router down first raced the last publish and logged a refusal
on every clean stop. The publisher ends on its own when the watch sender goes;
the edge sees the socket close either way.
2026-09-23 11:41:28 +00:00
acamilo
3d9a08d0be infra: flyedge.service, off by default, and the feed bus through build, deploy and watchdog
flyedge.service runs /opt/fly/current/fly-edge After= and Requires=
flysim.service, with its metrics on loopback :9102 and a
ConditionPathExists so a release without the binary leaves it inactive. It is
in no target and 07-enable.sh does not enable it; the header has the switch
and the way back.

build-flysim.sh also builds fly-edge beside the flysim binary and
package-release.sh ships it when present. 05-deploy.sh writes
FLY_FEED_VIA (default direct) into fly.env, flysim.service names
FLY_BUS_DIR=/run/fly/bus and tmpfiles creates it. Watchdog check 2 reads the
feed counters from whoever serves the feed: flyedge when fly.env says bus.
lint.sh holds all of that, and drives check 2's choice against a fixture.
2026-09-23 11:41:28 +00:00
acamilo
34c7a56b25 tests: the edge writes the direct feed's bytes, and a stalled consumer never lags the loop
parity.rs replays the four committed stage fixtures whose headers the Rust
producer can read (macros, shop, center, bigpad; 400 snapshots each, all of
them with FLY_EDGE_PARITY_ALL=1) into one watch slot served both ways at once,
recorded by a stage, a bridge and a frame-only client per path: headers equal
without wall times, attachments byte-equal and equal to the fixture's, and
the whole messages byte-equal. FLY_EDGE_PARITY_OUT writes the recordings as
.flyfeed files. Also: a header past the envelope limit, and the edge dropping
its clients, unbinding and coming back across a router restart.

stall.rs runs flysim's Pacer at 60 Hz publishing full-size snapshots at 30 Hz
against three stages that stopped reading, a bus subscriber that hoards every
delivery, and no subscriber at all: pacer lag 0, no slow watch send, no
refused publication, a bounded store, and a healthy client that stays current.
2026-09-23 11:41:28 +00:00
acamilo
d324ec825a fly-edge: serve the feed WebSocket from the feed bus
A new workspace binary. It reads flysim's own configuration (same env file,
same FLY_FEED_BIND, FLY_BUS_DIR and idle cadence), subscribes to
fly.feed.snapshots as fly-edge with one latest slot and one delivery in
flight, turns each publication back into a Snapshot with feedbus::receive and
serves it through flysim's feed::router, so hello, wants, drop-oldest and the
2 Hz idle header are flysim's code and the bytes are flysim's bytes.

The port is bound only once the first snapshot has arrived, and when the bus
goes away every client is dropped and the port unbound, which is what a
stopped flysim looks like to the stage; then it reconnects every 500 ms.
FLY_EDGE_METRICS_ADDR serves /metrics (fly_frames_sent_total and
fly_feed_clients under their flysim names, plus fly_edge_*) and /healthz.
2026-09-23 11:41:28 +00:00
acamilo
d3fa7908ec flysim: FLY_FEED_VIA=bus publishes every snapshot on an embedded flybus router
feed.via (FLY_FEED_VIA, default direct) and feed.bus_dir (FLY_BUS_DIR,
default /run/fly/bus). In bus mode flysim does not bind the feed port: it
starts a router on its own two-thread runtime with a closed policy (flysim
may publish fly.feed.snapshots, fly-edge may only subscribe to it), listens
for the edge on <bus_dir>/edge.sock, and a publisher task copies each snapshot
out of the watch slot onto the latest-retained topic: frame, audio and spikes
as sealed artifacts, the header as the envelope payload, or as a header
artifact past 48 KiB. The sim thread still only writes its watch slot, so a
slow bus costs snapshots on the bus and never a frame of the loop.

feedbus holds both halves of the encoding, publish and receive, and the
limits sized for the real 122,367-byte snapshot. Two counters are new:
fly_bus_published_total and fly_bus_publish_failures_total.
2026-09-23 11:41:28 +00:00
acamilo
acf7c2ebb8 flysim: the feed server takes a FeedState, so another process can run it
feed::router needed the whole AppState for three things: the watch slot, the
feed counters and the idle cadence. It now takes exactly those, and Shared's
Metrics sits behind an Arc so the counters can be handed over. Nothing about
what the feed writes changes.
2026-09-23 11:41:28 +00:00
acamilo
3eb82d7144 a warp's tear deals no pad
wCurMap names the new map thirty-two frames before the header, the
coordinates and the warp table follow it, while the screen fades, and
nothing sets the joypad bits until the fade ends. The seam read
"map 54 at (16, 17)" -- Pewter City's doormat under the gym's id -- as
an overworld, dealt a pad, and a walk started there planned over the
wrong map; what it aimed at and the tile it left went into the ledgers
under the new map's id. Live, GO OUT started and finished in 0.05 s.

The driver reads a tear as the map byte having changed while the fly
still stands on a warp of the loaded table that leads to the map the
byte names (a doormat's LAST_MAP under a town's id included), deals it
as Unknown with an empty pad, and records no ground from it. Teleport
pads do not change the map byte, so they are never a tear; a tear is
bounded at TEAR_FRAMES all the same.
2026-09-23 11:26:49 +00:00
acamilo
5fd16536db a battle decided is the cartridge's
Between a trainer's challenge closing and the battle screen the
transition runs 219 frames with every joypad and script bit clear, so
the scene read overworld and a pad was dealt: a walk toward the leader
pressed into the animation, gave up after three refused steps and put
him in the blocked ledger, and the trainer's conversation read as over.

wCurOpponent is set when a battle is decided and cleared by EndOfBattle
with wIsInBattle. It is not in the generated table; it is the byte
between wIsInBattle's flag byte and wBattleType, both neighbours
checked against the table, and controllable() reads it.
2026-09-23 11:26:49 +00:00
acamilo
484cc075cc the rung's people are in the room when the screen does not show them
CheckSpriteAvailability writes $ff into the image index of a sprite
outside its window, and state::npcs reports what is drawn. From the
Pewter Gym's doormat that is the guide alone, already talked to, so the
rung's list was empty: GO OBJECTIVE had nothing to aim at and GO OUT,
withheld only while the rung's person is in the room, was the pad.
Outside, GO OBJECTIVE walked back in. BROCK was twelve rows up.

state::offscreen_npcs reports the sprites the cartridge hides only for
being outside the window, read from bytes the seam already has, and
objective_targets reads them for a person. Nothing else does: a sprite
out of the window may be a toggleable object switched off, and GO NPC,
TALK and objects keep what is drawn.

Facing any of the rung's people is the arrival: with three in a gym,
leaving out only the one ahead walked GO OBJECTIVE between the leader
and the trainer.
2026-09-23 11:26:49 +00:00
acamilo
552428a7be survey: the route probe reads the room on the fly's Nth arrival on a map
FLY_PROBE_CATCH_MAP and FLY_PROBE_CATCH_ENTRIES stop the route survey
forty frames after the fly's Nth arrival on a map (the first frames on a
new map byte still carry the old map's warps), and the dump lists every
person the macros can see with its talked, blocked and reached entries.
Row 58's pad was one door in and one door out, and what it needed read
was the room on the far side of the door.
2026-09-23 11:26:49 +00:00
114 changed files with 12498 additions and 1445 deletions

View file

@ -47,12 +47,15 @@ are a `chromium --kiosk` line in a systemd unit and a Playwright test.
| `feed` | ws URL | Feed override for `mode=live`. Default `ws://127.0.0.1:7400/feed`. | | `feed` | ws URL | Feed override for `mode=live`. Default `ws://127.0.0.1:7400/feed`. |
| `gain`, `gamegain`, `sfxgain` | 0..1 | Master / game / SFX gain. Defaults 0.9 / 0.8 / 0.5. | | `gain`, `gamegain`, `sfxgain` | 0..1 | Master / game / SFX gain. Defaults 0.9 / 0.8 / 0.5. |
| `audio` | `0` | Do not create an AudioContext at all. | | `audio` | `0` | Do not create an AudioContext at all. |
| `recovery` | `1`, `0` | Poll `/recovery-notice.json` for the auto-recovery splash. Defaults to on in `live` mode and off in `player` mode, so a fixture screenshot never picks up a stray notice file. |
`window.__stage` exposes the operator surface: `metrics()` (per-stage paint timings), `audio()` `window.__stage` exposes the operator surface: `metrics()` (per-stage paint timings), `audio()`
(context state, ring fill, underruns, drops), `health()` (accepted snapshots, feed gaps, decode (context state, ring fill, underruns, drops), `health()` (accepted snapshots, feed gaps, decode
errors), `manifest()`, `seek(seconds)`, `stopFeed()`, `gameScale()`, `fly()` (renderer mode, gait errors), `manifest()`, `seek(seconds)`, `stopFeed()`, `gameScale()`, `fly()` (renderer mode, gait
phase, leg tips, proboscis extension), `motion()` (which tab and why, the moment on stage and its phase, leg tips, proboscis extension), `motion()` (which tab and why, the moment on stage and its
phase, the queue depth, live particles), `pam()` (the PAM centroid the flare spreads from), and phase, the queue depth, live particles), `pam()` (the PAM centroid the flare spreads from), and
`recovery(notice, nowS?)` (put a recovery notice on the splash by hand, validated like a polled
one, with its clock optionally pinned; `null` clears it), and
`fire(type, label, detail)` — the one deliberate way to drive the moment catalogue by hand, which `fire(type, label, detail)` — the one deliberate way to drive the moment catalogue by hand, which
is what the moment mockups and the moment assertions use instead of waiting for a fixture to is what the moment mockups and the moment assertions use instead of waiting for a fixture to
contain one of each. contain one of each.
@ -95,7 +98,8 @@ Builds, serves, and writes sixteen PNGs to `mockups/`, at 1920x1080 and DPR 1:
`steady-t1-{senses,connectome,ladder}` and `describe` for the four tabs, `big-moment-t1` 2.5 s into that `steady-t1-{senses,connectome,ladder}` and `describe` for the four tabs, `big-moment-t1` 2.5 s into that
fixture's milestone, `moment-{milestone,badge,sugar,rollback}` shot 300 ms after the trigger (the fixture's milestone, `moment-{milestone,badge,sugar,rollback}` shot 300 ms after the trigger (the
middle of every arrival in the catalogue), `macros-{overworld,running,outcome,battle,indoors}` for middle of every arrival in the catalogue), `macros-{overworld,running,outcome,battle,indoors}` for
the macro strip's five states, and the two `fly-*` review crops at 2x. `--only <substring>` shoots the macro strip's five states, `recovery-<phase>-<action>` for the auto-recovery splash, and the
two `fly-*` review crops at 2x. `--only <substring>` shoots
just the ones whose name contains it, which is how one panel gets re-reviewed without rewriting just the ones whose name contains it, which is how one panel gets re-reviewed without rewriting
every committed PNG. every committed PNG.
@ -194,6 +198,7 @@ Four rail panels instead of layout v1's five, on a 12 px gutter; the left column
| CHAT | 1012x244 | The last seven chat lines, or nothing at all | `panels/ChatPanel.tsx` | | CHAT | 1012x244 | The last seven chat lines, or nothing at all | `panels/ChatPanel.tsx` |
| Moment layer | — | Caption band, rail flash, particles, day slide | `panels/MomentLayer.tsx` | | Moment layer | — | Caption band, rail flash, particles, day slide | `panels/MomentLayer.tsx` |
| Stale feed banner | 1824x40 | Over the title strip after 2 s of silence | `panels/StaleBanner.tsx` | | Stale feed banner | 1824x40 | Over the title strip after 2 s of silence | `panels/StaleBanner.tsx` |
| Recovery splash | over the game | The auto-recovery notice: a text box, or the whole game while flysim restarts | `panels/RecoverySplash.tsx` |
### The progress cluster ### The progress cluster
@ -264,6 +269,48 @@ Bot lines green, names amber, text ink: a viewer has to be able to tell the brid
replies from a person at a glance, because the bridge is the only thing on this stream that can be replies from a person at a glance, because the bridge is the only thing on this stream that can be
made to say something by accident. made to say something by accident.
### Recovery splash
`infra/bin/fly-loop-recover` (`infra/docs/loop-recovery.md`) unsticks a confirmed macro loop by
restarting flysim or resetting the run to an earlier rung. Without a word on screen, viewers see
the game freeze and jump. The splash says what is happening, in the Game Boy's own four greens so
it reads as the cartridge's text box, not as a rail panel or an alarm:
| Phase | Layout | Reset copy | Restart copy |
|---|---|---|---|
| `countdown` | Text box over the bottom ~40% of the game, big `M:SS` to `executeAt`, the loop and how long it was stuck. The stuck loop stays visible above it | The fly is stuck in a loop! / Rewinding to PEWTER CITY in 0:42 | … / Shaking it off in 0:42 |
| `acting` | Covers the whole 800x720 game panel, which is frozen or blank while flysim is down; a stepped progress bar | Rewinding… / Back to PEWTER CITY | Shaking it off… / Same place, fresh start |
| `done` | Text box, 8 s after the helper's `updatedAt` | Back at PEWTER CITY! | All shaken off! |
| `failed` | Text box, 20 s | That didn't work / A human will take a look | same |
**Where the notice comes from.** Not the feed: flysim is the thing being restarted. The helper
writes `/run/fly/wd/recovery-notice.json` atomically (tmp + rename); `flystage-web`
(`infra/config/serve.mjs`, the page's own static server, User=fly, same container, independent of
flysim) serves it at `/recovery-notice.json` — 200 with the bytes, or 204 when there is no file
— and the page polls that once a second (`src/lib/recovery-poll.ts`). `FLY_RECOVERY_NOTICE`
overrides the path, for `serve.mjs` and for the Vite dev and preview servers alike, which serve
the same route.
**It can never break the stream.** `src/lib/recovery.ts` is pure and stateless: a missing,
malformed, oversized (> 16 KiB) or wrong-version file is no splash; a notice whose `updatedAt` is
more than 15 minutes old is ignored; `acting` stops covering the game 10 minutes after its last
write even if the helper never follows up, so a dead helper cannot hide the game; and every
string the helper wrote is clipped to letters, digits, spaces and `.,'&:#/-` at a fixed length
before it can reach the screen. Because the view is a function of the file and the wall clock
alone, a page that reloads mid-recovery shows exactly what one that watched it all would.
To look at it by hand:
```sh
export FLY_RECOVERY_NOTICE=$PWD/.recovery-notice.json # any writable path
npm run dev -w @flybrain/stage # http://127.0.0.1:5273/?recovery=1
npm run recovery-notice -w @flybrain/stage -- --phase countdown --action reset --in 45
npm run recovery-notice -w @flybrain/stage -- --demo restart # 20 s countdown, 15 s acting, done
npm run recovery-notice -w @flybrain/stage -- --clear
```
`npm run mockups -- --only recovery` writes the eight `mockups/recovery-<phase>-<action>.png`.
### Moments ### Moments
`docs/design/animation.md`'s catalogue, wired. The engine (`src/motion/`, landed separately) owns `docs/design/animation.md`'s catalogue, wired. The engine (`src/motion/`, landed separately) owns

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View file

@ -14,7 +14,8 @@
"test:e2e": "playwright test", "test:e2e": "playwright test",
"mockups": "tsx tools/mockup.mts", "mockups": "tsx tools/mockup.mts",
"fonts": "tsx tools/font-compare.mts", "fonts": "tsx tools/font-compare.mts",
"record": "tsx tools/record-fixture.mts" "record": "tsx tools/record-fixture.mts",
"recovery-notice": "tsx tools/recovery-notice.mts"
}, },
"dependencies": { "dependencies": {
"@flybrain/brain": "*", "@flybrain/brain": "*",

View file

@ -50,6 +50,8 @@ import {
boxStyle, boxStyle,
} from '@/lib/geometry'; } from '@/lib/geometry';
import { stageOptions } from '@/lib/query'; import { stageOptions } from '@/lib/query';
import { parseRecoveryNotice, recoveryView, type RecoveryView } from '@/lib/recovery';
import { RecoveryPoller } from '@/lib/recovery-poll';
import { Director, FLY_HEAD_ANCHOR } from '@/motion/director'; import { Director, FLY_HEAD_ANCHOR } from '@/motion/director';
import { MotionEngine } from '@/motion/engine'; import { MotionEngine } from '@/motion/engine';
import type { MomentType } from '@/motion/moments'; import type { MomentType } from '@/motion/moments';
@ -68,6 +70,7 @@ import { FlyStrip } from '@/panels/FlyStrip';
import { GamePanel } from '@/panels/GamePanel'; import { GamePanel } from '@/panels/GamePanel';
import { MomentLayer } from '@/panels/MomentLayer'; import { MomentLayer } from '@/panels/MomentLayer';
import { ProgressCluster } from '@/panels/ProgressCluster'; import { ProgressCluster } from '@/panels/ProgressCluster';
import { RecoverySplash, nowSeconds, useRecovery } from '@/panels/RecoverySplash';
import { StaleBanner } from '@/panels/StaleBanner'; import { StaleBanner } from '@/panels/StaleBanner';
import { TabSlot } from '@/panels/TabSlot'; import { TabSlot } from '@/panels/TabSlot';
import { TitleStrip } from '@/panels/TitleStrip'; import { TitleStrip } from '@/panels/TitleStrip';
@ -632,6 +635,14 @@ export function App() {
loop.start(); loop.start();
// -- The auto-recovery splash's notice ----------------------------------------------------
// Polled from `flystage-web`, not the feed: flysim is what the helper restarts
// (`src/lib/recovery-poll.ts`). Off in player mode unless `?recovery=1`.
const recoveryPoller = options.recovery
? new RecoveryPoller({ onChange: (notice) => useRecovery.setState({ notice }) })
: null;
recoveryPoller?.start();
// -- Test and operator surface ------------------------------------------------------------ // -- Test and operator surface ------------------------------------------------------------
window.__stage = { window.__stage = {
options, options,
@ -687,6 +698,19 @@ export function App() {
ingest.commit(hot.lastSnapshotMs, true); ingest.commit(hot.lastSnapshotMs, true);
return useStage.getState().chat.length; return useStage.getState().chat.length;
}, },
/**
* Put a notice on the recovery splash by hand, the same way `fire` drives a moment: for the
* splash mockups and e2e assertions. The raw object goes through the same validation as a
* polled file, the poller stops so it cannot overwrite it, and `nowS` pins the splash's clock
* so a countdown screenshot is reproducible. `null` clears it. Returns what the splash shows.
*/
recovery: (raw, nowS) => {
recoveryPoller?.stop();
const notice = raw === null ? null : parseRecoveryNotice(raw);
const pinnedNowS = nowS ?? null;
useRecovery.setState({ notice, pinnedNowS });
return recoveryView(notice, nowSeconds(pinnedNowS));
},
fly: () => ({ fly: () => ({
// The renderer that is actually drawing, which is not always the one that was asked for: // The renderer that is actually drawing, which is not always the one that was asked for:
// `webgl` falls back to `paper` on a host with no usable GL context (`src/fly/index.ts`). // `webgl` falls back to `paper` on a host with no usable GL context (`src/fly/index.ts`).
@ -714,6 +738,7 @@ export function App() {
if (readyTimer !== null) clearTimeout(readyTimer); if (readyTimer !== null) clearTimeout(readyTimer);
loop.stop(); loop.stop();
source?.stop(); source?.stop();
recoveryPoller?.stop();
fly?.dispose(); fly?.dispose();
worker.terminate(); worker.terminate();
void engine?.stop(); void engine?.stop();
@ -739,6 +764,7 @@ export function App() {
<ChatPanel source={options.chat} /> <ChatPanel source={options.chat} />
<MomentLayer particleRef={particleCanvas} /> <MomentLayer particleRef={particleCanvas} />
<RecoverySplash />
<StaleBanner /> <StaleBanner />
</div> </div>
); );
@ -764,6 +790,8 @@ declare global {
fire: (type: MomentType, label?: string, detail?: string) => number | null; fire: (type: MomentType, label?: string, detail?: string) => number | null;
/** Replace the held feed's chat ring by hand; returns how many lines the panel accepted. */ /** Replace the held feed's chat ring by hand; returns how many lines the panel accepted. */
chat: (lines: readonly { by: string; text: string; bot?: boolean }[]) => number; chat: (lines: readonly { by: string; text: string; bot?: boolean }[]) => number;
/** Show a recovery notice by hand (validated like a polled one); null clears it. */
recovery: (notice: unknown, nowS?: number) => RecoveryView | null;
fly: () => { fly: () => {
mode: string; mode: string;
requested: string; requested: string;

View file

@ -3,6 +3,7 @@
@import './theme/panels.css'; @import './theme/panels.css';
@import './theme/rail.css'; @import './theme/rail.css';
@import './theme/motion.css'; @import './theme/motion.css';
@import './theme/recovery.css';
/** /**
* Self-hosted OFL faces. `font-display: block` with the default 3 s block period is deliberate: * Self-hosted OFL faces. `font-display: block` with the default 3 s block period is deliberate:

View file

@ -57,6 +57,12 @@ export interface StageOptions {
audio: boolean; audio: boolean;
/** `?metrics=1` keeps the paint-stage histogram and prints it on demand. */ /** `?metrics=1` keeps the paint-stage histogram and prints it on demand. */
metrics: boolean; metrics: boolean;
/**
* Poll `flystage-web` for the auto-recovery notice (`src/lib/recovery.ts`). On by default in
* `live` mode and off in `player` mode, so a fixture screenshot can never pick up a notice file
* that happens to exist on the machine; `?recovery=1` / `?recovery=0` force it either way.
*/
recovery: boolean;
} }
const THEMES: readonly StageTheme[] = ['t1', 't2', 't3']; const THEMES: readonly StageTheme[] = ['t1', 't2', 't3'];
@ -114,6 +120,7 @@ export function parseStageOptions(search: string, defaultFeedUrl = 'ws://127.0.0
}, },
audio: flag(params, 'audio', true), audio: flag(params, 'audio', true),
metrics: flag(params, 'metrics', false), metrics: flag(params, 'metrics', false),
recovery: flag(params, 'recovery', mode === 'live'),
}; };
} }

View file

@ -0,0 +1,88 @@
/**
* Polls `flystage-web` for the recovery notice (`src/lib/recovery.ts`).
*
* Deliberately not the feed: flysim is the thing being restarted, so during `acting` there is no
* feed and no control API to ask. `flystage-web` is the page's own static server, in the same
* container and independent of flysim, and serves the helper's file at
* {@link RECOVERY_NOTICE_ROUTE} (`infra/config/serve.mjs`).
*
* Once a second, with a short timeout, and every failure — no server, a 404 from an older
* `serve.mjs`, a 204 for "no file", a half-written body, a hung request — is "no notice". The
* poller never throws into the page and never retries faster than its interval.
*/
import { RECOVERY_NOTICE_ROUTE, parseRecoveryBody, type RecoveryNotice } from './recovery';
export const RECOVERY_POLL_MS = 1000;
const TIMEOUT_MS = 2500;
type Fetch = (input: string, init?: RequestInit) => Promise<Response>;
export interface RecoveryPollerOptions {
url?: string;
intervalMs?: number;
fetch?: Fetch;
onChange: (notice: RecoveryNotice | null) => void;
}
export class RecoveryPoller {
private readonly url: string;
private readonly intervalMs: number;
private readonly fetchFn: Fetch;
private readonly onChange: (notice: RecoveryNotice | null) => void;
private timer: ReturnType<typeof setTimeout> | null = null;
private stopped = true;
/** The last body seen, so an unchanged file does not re-render the panel. */
private lastKey = '\u0000';
constructor(options: RecoveryPollerOptions) {
this.url = options.url ?? RECOVERY_NOTICE_ROUTE;
this.intervalMs = options.intervalMs ?? RECOVERY_POLL_MS;
this.fetchFn = options.fetch ?? ((input, init) => fetch(input, init));
this.onChange = options.onChange;
}
start(): void {
if (!this.stopped) return;
this.stopped = false;
void this.tick();
}
stop(): void {
this.stopped = true;
if (this.timer !== null) clearTimeout(this.timer);
this.timer = null;
}
/** One poll. Public so the unit tests can drive it without timers. */
async poll(): Promise<RecoveryNotice | null> {
let body = '';
const abort = new AbortController();
const timeout = setTimeout(() => abort.abort(), TIMEOUT_MS);
try {
const response = await this.fetchFn(this.url, { cache: 'no-store', signal: abort.signal });
if (response.status === 200) body = await response.text();
} catch {
body = '';
} finally {
clearTimeout(timeout);
}
const notice = parseRecoveryBody(body);
const key = notice === null ? '' : JSON.stringify(notice);
if (key !== this.lastKey) {
this.lastKey = key;
try {
this.onChange(notice);
} catch (error) {
console.warn(`recovery splash: ${(error as Error).message}`);
}
}
return notice;
}
private async tick(): Promise<void> {
if (this.stopped) return;
await this.poll();
if (this.stopped) return;
this.timer = setTimeout(() => void this.tick(), this.intervalMs);
}
}

View file

@ -0,0 +1,286 @@
/**
* The recovery splash's model: the notice file's contract, its validation, and what the splash
* shows at a given wall-clock second.
*
* `infra/bin/fly-loop-recover` (the auto-unstick helper) writes one small JSON file while it acts
* on a confirmed macro loop — restarting flysim, or resetting the run to an earlier rung — and
* `flystage-web` serves it to this page at {@link RECOVERY_NOTICE_ROUTE}. Viewers otherwise see
* the game freeze or jump for no reason; the splash says what is happening.
*
* Everything here is pure: the poller (`src/lib/recovery-poll.ts`) fetches, the panel
* (`src/panels/RecoverySplash.tsx`) renders, and this file decides. The one rule it exists to
* keep is that **the notice can never break the stream**: a missing, malformed, oversized, future
* or stale file is no notice at all, and every string the helper wrote is clipped to a closed
* character set before it can reach the screen.
*/
/** Where `flystage-web` (and the Vite dev/preview servers) serve the notice file. */
export const RECOVERY_NOTICE_ROUTE = '/recovery-notice.json';
/** Ignore a notice whose `updatedAt` is older than this (the contract: 15 minutes). */
export const RECOVERY_STALE_S = 15 * 60;
/** How long `done` stays up after the helper wrote it. */
export const RECOVERY_DONE_S = 8;
/** How long `failed` stays up after the helper wrote it. */
export const RECOVERY_FAILED_S = 20;
/**
* Stop covering the game this long after an `acting` write, even if the helper never follows up.
* A helper that died mid-recovery must not leave the game hidden behind a splash; with the game
* uncovered again the page's own STALE FEED banner is the honest state.
*/
export const RECOVERY_ACTING_MAX_S = 10 * 60;
/** Tolerated clock skew between the helper's `updatedAt` and the page's clock. */
const FUTURE_SKEW_S = 120;
export type RecoveryPhase = 'countdown' | 'acting' | 'done' | 'failed';
export type RecoveryAction = 'restart' | 'reset';
/** A validated notice. Field names are the file's own; see `infra/docs/loop-recovery.md`. */
export interface RecoveryNotice {
id: string;
phase: RecoveryPhase;
action: RecoveryAction;
fromRung: number | null;
fromLabel: string;
/** Only for `reset`; null otherwise. */
toRung: number | null;
toLabel: string;
reason: string;
loop: string[];
stuckSeconds: number | null;
announcedAt: number;
executeAt: number;
updatedAt: number;
}
const PHASES: readonly RecoveryPhase[] = ['countdown', 'acting', 'done', 'failed'];
const ACTIONS: readonly RecoveryAction[] = ['restart', 'reset'];
/** Longest place name kept (the ladder's longest is 15 characters). */
const LABEL_MAX = 24;
/** Longest macro name kept (the pad's are at most 12, e.g. `GO OBJECTIVE`). */
const MACRO_MAX = 16;
/** At most this many macros of the loop are shown. */
const LOOP_MAX = 4;
/**
* Clip a helper-written string to what the splash may draw: printable ASCII letters, digits,
* spaces and a little punctuation (place names like `MT. MOON` or `S.S. ANNE`, `ROUTE 22`), with
* runs of whitespace collapsed, capped at `max` characters. Anything else is dropped, so a notice
* cannot put markup, control characters or an unbounded line on air.
*/
export function cleanText(value: unknown, max: number): string {
if (typeof value !== 'string') return '';
return value
.replace(/\s+/g, ' ')
.replace(/[^A-Za-z0-9 .,'&:#/-]/g, '')
.replace(/ {2,}/g, ' ')
.trim()
.slice(0, max)
.trim();
}
function finite(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function rung(value: unknown): number | null {
const n = finite(value);
return n !== null && Number.isInteger(n) && n >= 0 && n < 1000 ? n : null;
}
/**
* Validate a parsed JSON value as a v1 notice, or return null.
*
* Required: `v === 1`, `id`, a known `phase` and `action`, and finite `announcedAt`, `executeAt`,
* `updatedAt`. Everything else is optional and degrades the copy rather than the notice: a reset
* without a `toLabel` still says it is rewinding, just not to where.
*/
export function parseRecoveryNotice(raw: unknown): RecoveryNotice | null {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
const o = raw as Record<string, unknown>;
if (o.v !== 1) return null;
const id = typeof o.id === 'string' ? o.id.slice(0, 64) : '';
if (id === '') return null;
if (!PHASES.includes(o.phase as RecoveryPhase)) return null;
if (!ACTIONS.includes(o.action as RecoveryAction)) return null;
const announcedAt = finite(o.announcedAt);
const executeAt = finite(o.executeAt);
const updatedAt = finite(o.updatedAt);
if (announcedAt === null || executeAt === null || updatedAt === null) return null;
const action = o.action as RecoveryAction;
const loop = Array.isArray(o.loop)
? o.loop
.map((step) => cleanText(step, MACRO_MAX).toUpperCase())
.filter((step) => step !== '')
.slice(0, LOOP_MAX)
: [];
const stuck = finite(o.stuckSeconds);
return {
id,
phase: o.phase as RecoveryPhase,
action,
fromRung: rung(o.fromRung),
fromLabel: cleanText(o.fromLabel, LABEL_MAX).toUpperCase(),
toRung: action === 'reset' ? rung(o.toRung) : null,
toLabel: action === 'reset' ? cleanText(o.toLabel, LABEL_MAX).toUpperCase() : '',
reason: cleanText(o.reason, 32),
loop,
stuckSeconds: stuck !== null && stuck >= 0 ? stuck : null,
announcedAt,
executeAt,
updatedAt,
};
}
/** Parse the body the server returned. Never throws. */
export function parseRecoveryBody(body: string): RecoveryNotice | null {
if (body.length === 0 || body.length > 16_384) return null;
try {
return parseRecoveryNotice(JSON.parse(body));
} catch {
return null;
}
}
/**
* How the splash sits on the stage: `box` is the Pokémon-style text box along the bottom of the
* game, leaving the stuck loop visible above it; `cover` fills the game panel, which is frozen or
* blank while flysim restarts.
*/
export type RecoveryLayout = 'box' | 'cover';
/** What the splash draws. Every string is already final copy. */
export interface RecoveryView {
id: string;
phase: RecoveryPhase;
action: RecoveryAction;
layout: RecoveryLayout;
/** The small chip in the frame's top edge. */
chip: string;
/** The first line, Pokémon-dialogue style. */
headline: string;
/** The second line. */
body: string;
/** `M:SS` during the countdown, otherwise empty. */
countdown: string;
/** `GO OBJECTIVE > GO WARP`, or empty. */
loop: string;
/** `STUCK 30 MIN`, or empty. */
stuck: string;
/** Whether to animate the trailing dots / blinking arrow. */
busy: boolean;
}
/** `0:42`, `1:00`, `12:05`. Negative clamps to `0:00`. */
export function formatCountdown(seconds: number): string {
const s = Math.max(0, Math.ceil(seconds));
const m = Math.floor(s / 60);
return `${m}:${String(s % 60).padStart(2, '0')}`;
}
/** `STUCK 30 MIN`, `STUCK 2 H`, `STUCK 1 H 5 MIN`; empty under a minute or when unknown. */
export function formatStuck(seconds: number | null): string {
if (seconds === null || seconds < 60) return '';
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `STUCK ${minutes} MIN`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m === 0 ? `STUCK ${h} H` : `STUCK ${h} H ${m} MIN`;
}
/**
* Whether the notice is showing at `nowS` (epoch seconds), per the contract's lifetimes.
*
* Stateless on purpose: a page that reloads mid-recovery, or a Chromium the watchdog restarted,
* draws exactly what a page that watched the whole thing would, from the file alone.
*/
export function recoveryVisible(notice: RecoveryNotice, nowS: number): boolean {
const age = nowS - notice.updatedAt;
if (age > RECOVERY_STALE_S) return false;
if (age < -FUTURE_SKEW_S) return false;
switch (notice.phase) {
case 'countdown':
return true;
case 'acting':
return age <= RECOVERY_ACTING_MAX_S;
case 'done':
return age <= RECOVERY_DONE_S;
case 'failed':
return age <= RECOVERY_FAILED_S;
}
}
/** The splash at `nowS`, or null when nothing should be on screen. */
export function recoveryView(notice: RecoveryNotice | null, nowS: number): RecoveryView | null {
if (notice === null || !recoveryVisible(notice, nowS)) return null;
const reset = notice.action === 'reset';
const to = notice.toLabel;
const base = {
id: notice.id,
phase: notice.phase,
action: notice.action,
loop: notice.loop.join(' > '),
stuck: formatStuck(notice.stuckSeconds),
};
switch (notice.phase) {
case 'countdown': {
const remaining = notice.executeAt - nowS;
const now = remaining <= 0;
return {
...base,
layout: 'box',
chip: 'AUTO RECOVERY',
headline: 'The fly is stuck in a loop!',
body: reset
? now
? to !== '' ? `Rewinding to ${to} now` : 'Rewinding now'
: to !== '' ? `Rewinding to ${to} in` : 'Rewinding to the last milestone in'
: now
? 'Shaking it off now'
: 'Shaking it off in',
countdown: formatCountdown(remaining),
busy: now,
};
}
case 'acting':
return {
...base,
layout: 'cover',
chip: 'AUTO RECOVERY',
headline: reset ? 'Rewinding' : 'Shaking it off',
body: reset
? to !== '' ? `Back to ${to}` : 'Back to the last milestone'
: 'Same place, fresh start',
countdown: '',
busy: true,
};
case 'done':
return {
...base,
layout: 'box',
chip: 'AUTO RECOVERY',
headline: reset ? (to !== '' ? `Back at ${to}!` : 'Rewound!') : 'All shaken off!',
body: "Go get 'em, little fly",
countdown: '',
loop: '',
stuck: '',
busy: false,
};
case 'failed':
return {
...base,
layout: 'box',
chip: 'AUTO RECOVERY',
headline: "That didn't work",
body: 'A human will take a look',
countdown: '',
loop: '',
stuck: '',
busy: false,
};
}
}

View file

@ -0,0 +1,129 @@
import { useEffect, useState } from 'react';
import { create } from 'zustand';
import { LAYOUT, boxStyle } from '@/lib/geometry';
import { recoveryView, type RecoveryNotice, type RecoveryView } from '@/lib/recovery';
/**
* The auto-recovery splash, over the game panel.
*
* `infra/bin/fly-loop-recover` announces a recovery a minute before it acts, then restarts flysim
* or resets the run to an earlier rung; without this, viewers see the game freeze and jump for no
* reason. The model is `src/lib/recovery.ts`, the data path `src/lib/recovery-poll.ts`.
*
* Two layouts, both in the Game Boy's own four greens, so it reads as the cartridge's text box
* rather than as one more rail panel:
*
* - **box** (countdown, done, failed): a Pokémon-style text box across the bottom of the game,
* with the countdown beside it. The top two thirds of the game stay visible, because the stuck
* loop is the thing the countdown is about.
* - **cover** (acting): the whole game panel, because the picture there is frozen or blank while
* flysim restarts.
*
* It re-renders at 4 Hz while a notice is held — the stage's own React cadence — and not at all
* otherwise. The countdown is wall-clock arithmetic against the helper's `executeAt`, which is why
* the page needs nothing but the file: it is right after a reload, and while the feed is down.
*/
export const useRecovery = create<{ notice: RecoveryNotice | null; pinnedNowS: number | null }>(() => ({
notice: null,
pinnedNowS: null,
}));
const TICK_MS = 250;
export function nowSeconds(pinned: number | null): number {
return pinned ?? Date.now() / 1000;
}
export function RecoverySplash() {
const notice = useRecovery((state) => state.notice);
const pinned = useRecovery((state) => state.pinnedNowS);
const [nowS, setNowS] = useState(() => nowSeconds(pinned));
useEffect(() => {
setNowS(nowSeconds(pinned));
if (notice === null || pinned !== null) return;
const timer = setInterval(() => setNowS(nowSeconds(null)), TICK_MS);
return () => clearInterval(timer);
}, [notice, pinned]);
let view: RecoveryView | null = null;
try {
view = recoveryView(notice, nowS);
} catch {
view = null;
}
if (view === null) return null;
return (
<div
className="recovery"
data-testid="recovery-splash"
data-phase={view.phase}
data-action={view.action}
data-layout={view.layout}
style={{ ...boxStyle(LAYOUT.game), zIndex: 50 }}
>
{view.layout === 'cover' ? <Cover view={view} /> : <Box view={view} />}
</div>
);
}
function LoopLine({ view }: { view: RecoveryView }) {
if (view.loop === '' && view.stuck === '') return null;
return (
<div className="recovery__loop" data-testid="recovery-loop">
{view.loop !== '' ? <span className="recovery__loop-macros">{view.loop}</span> : null}
{view.loop !== '' && view.stuck !== '' ? <span className="recovery__sep" aria-hidden /> : null}
{view.stuck !== '' ? <span>{view.stuck}</span> : null}
</div>
);
}
function Box({ view }: { view: RecoveryView }) {
return (
<div className="recovery__box">
<span className="recovery__chip">{view.chip}</span>
<div className="recovery__row">
<div className="recovery__text">
<p className="recovery__headline" data-testid="recovery-headline">
{view.headline}
</p>
<p className="recovery__body" data-testid="recovery-body">
{view.body}
{view.busy ? <span className="recovery__dots" aria-hidden /> : null}
</p>
</div>
{view.countdown !== '' ? (
<div className="recovery__countdown" data-testid="recovery-countdown">
{view.countdown}
</div>
) : null}
</div>
<LoopLine view={view} />
<span className="recovery__arrow" aria-hidden />
</div>
);
}
function Cover({ view }: { view: RecoveryView }) {
return (
<div className="recovery__cover">
<span className="recovery__chip recovery__chip--cover">{view.chip}</span>
<p className="recovery__big" data-testid="recovery-headline">
{view.headline}
<span className="recovery__dots" aria-hidden />
</p>
<p className="recovery__body recovery__body--cover" data-testid="recovery-body">
{view.body}
</p>
<div className={`recovery__bar recovery__bar--${view.action}`} aria-hidden>
{Array.from({ length: 8 }, (_, index) => (
<span key={index} style={{ animationDelay: `${index * 150}ms` }} />
))}
</div>
<div className="recovery__was">{view.loop !== '' ? 'IT WAS LOOPING ON' : null}</div>
<LoopLine view={view} />
</div>
);
}

View file

@ -0,0 +1,230 @@
/**
* The auto-recovery splash (`src/panels/RecoverySplash.tsx`), over the 800x720 game panel.
*
* Its own palette on purpose: the Game Boy's four greens, so the splash reads as the cartridge's
* own text box / screen and never as a rail panel or an alarm. Square corners, a 4 px outer frame
* and a 2 px inner line, like every other box on the page (`tokens.css`). Type sits on the page's
* floors: Press Start 2P for the dialogue line and the countdown, VT323 for the rest.
*/
.recovery {
--gb-0: #e0f0c8; /* lightest: the paper */
--gb-1: #8bac0f;
--gb-2: #306230;
--gb-3: #0f380f; /* darkest: the ink */
pointer-events: none;
}
/* -- Box: countdown, done, failed ---------------------------------------------------------- */
.recovery__box {
position: absolute;
left: 16px;
right: 16px;
bottom: 16px;
padding: 30px 28px 20px;
background: var(--gb-0);
color: var(--gb-3);
border: var(--border-w) solid var(--gb-3);
outline: var(--border-inner-w) solid var(--gb-0);
outline-offset: calc(-1 * var(--border-w) - 6px);
box-shadow: inset 0 0 0 10px var(--gb-0), inset 0 0 0 12px var(--gb-2);
animation: recovery-rise 320ms var(--ease-pixel, steps(8, end)) both;
}
.recovery__chip {
position: absolute;
top: -18px;
left: 24px;
padding: 2px 12px;
font-family: var(--font-label);
font-size: var(--fs-body);
line-height: 28px;
background: var(--gb-3);
color: var(--gb-0);
letter-spacing: 0.04em;
}
.recovery__row {
display: flex;
align-items: center;
gap: 24px;
}
.recovery__text {
flex: 1 1 auto;
min-width: 0;
}
.recovery__headline {
margin: 0 0 14px;
font-family: var(--font-pixel);
font-size: 24px;
line-height: 1.45;
}
.recovery__body {
margin: 0;
font-family: var(--font-text);
font-size: 40px;
line-height: 1.05;
color: var(--gb-2);
}
.recovery__countdown {
flex: 0 0 auto;
font-family: var(--font-pixel);
font-size: 52px;
line-height: 1;
padding: 16px 14px 12px;
background: var(--gb-3);
color: var(--gb-0);
font-variant-numeric: tabular-nums;
}
.recovery__loop {
margin-top: 14px;
font-family: var(--font-text);
font-size: 30px;
line-height: 1;
color: var(--gb-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recovery__loop-macros {
color: var(--gb-3);
}
.recovery__sep {
display: inline-block;
width: 8px;
height: 8px;
margin: 0 14px;
vertical-align: middle;
background: var(--gb-1);
}
/* The Pokémon "more text" arrow, blinking in the corner. Drawn, not typed: no glyph to miss. */
.recovery__arrow {
position: absolute;
right: 22px;
bottom: 16px;
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 12px solid var(--gb-3);
animation: recovery-blink 1s steps(1, end) infinite;
}
/* Three dots that count up, for "working on it". Steps, not a fade. */
.recovery__dots::after {
content: '...';
display: inline-block;
width: 3ch;
overflow: hidden;
vertical-align: bottom;
text-align: left;
animation: recovery-dots 1.2s steps(4, jump-none) infinite;
}
/* -- Cover: acting --------------------------------------------------------------------------- */
.recovery__cover {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
padding: 40px;
background: var(--gb-0);
color: var(--gb-3);
border: var(--border-w) solid var(--gb-3);
box-shadow: inset 0 0 0 12px var(--gb-0), inset 0 0 0 16px var(--gb-2);
text-align: center;
}
.recovery__chip--cover {
position: static;
}
.recovery__big {
margin: 0;
font-family: var(--font-pixel);
font-size: 44px;
line-height: 1.3;
}
.recovery__body--cover {
font-size: 48px;
}
.recovery__was {
margin-top: 8px;
font-family: var(--font-label);
font-size: var(--fs-body);
color: var(--gb-2);
min-height: 1px;
}
.recovery__cover .recovery__loop {
margin-top: 0;
max-width: 100%;
}
/* Eight cells lighting in turn: right-to-left for a rewind, left-to-right for a restart. */
.recovery__bar {
display: flex;
gap: 8px;
}
.recovery__bar--reset {
flex-direction: row-reverse;
}
.recovery__bar span {
width: 40px;
height: 24px;
background: var(--gb-1);
animation: recovery-cell 1.2s steps(1, end) infinite;
}
@keyframes recovery-rise {
from {
transform: translateY(24px);
opacity: 0;
}
to {
transform: none;
opacity: 1;
}
}
@keyframes recovery-blink {
50% {
visibility: hidden;
}
}
@keyframes recovery-dots {
from {
width: 0;
}
to {
width: 3ch;
}
}
@keyframes recovery-cell {
0%,
12% {
background: var(--gb-3);
}
13%,
100% {
background: var(--gb-1);
}
}

View file

@ -0,0 +1,138 @@
/**
* The auto-recovery splash, against the real build.
*
* The first two tests go through the page's real data path — the poller fetching
* `/recovery-notice.json` — with Playwright answering the route, which is exactly what
* `flystage-web` does with the helper's file. The rest drive the splash through
* `window.__stage.recovery` with a pinned clock, for the geometry and the text floor.
*/
import { expect, test, type Page } from '@playwright/test';
import { stageUrl } from './stage';
const now = (): number => Math.floor(Date.now() / 1000);
function notice(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const t = now();
return {
v: 1,
id: `${t}-reset`,
phase: 'countdown',
action: 'reset',
fromRung: 12,
fromLabel: 'MT. MOON',
toRung: 11,
toLabel: 'PEWTER CITY',
reason: 'unrewarded',
loop: ['GO OBJECTIVE', 'GO WARP'],
stuckSeconds: 1800,
announcedAt: t,
executeAt: t + 45,
updatedAt: t,
...overrides,
};
}
async function open(page: Page): Promise<void> {
await page.goto(`${stageUrl({ t: 95, tab: 'senses', audio: false })}&recovery=1`);
await page.waitForSelector('html[data-ready="1"]', { timeout: 60_000 });
}
test('a polled notice shows the countdown, follows the phases and clears', async ({ page }) => {
let body: string | null = JSON.stringify(notice());
await page.route('**/recovery-notice.json', (route) =>
body === null
? route.fulfill({ status: 204 })
: route.fulfill({ status: 200, contentType: 'application/json', body }),
);
await open(page);
const splash = page.getByTestId('recovery-splash');
await expect(splash).toBeVisible({ timeout: 10_000 });
await expect(splash).toHaveAttribute('data-phase', 'countdown');
await expect(splash).toHaveAttribute('data-layout', 'box');
await expect(page.getByTestId('recovery-countdown')).toHaveText(/^0:4\d$/);
await expect(page.getByTestId('recovery-body')).toContainText('Rewinding to PEWTER CITY');
await expect(page.getByTestId('recovery-loop')).toContainText('GO OBJECTIVE > GO WARP');
body = JSON.stringify(notice({ phase: 'acting' }));
await expect(splash).toHaveAttribute('data-layout', 'cover', { timeout: 5_000 });
body = JSON.stringify(notice({ phase: 'done' }));
await expect(splash).toHaveAttribute('data-phase', 'done', { timeout: 5_000 });
// The file going away is also an end: the page never holds a notice the server no longer has.
body = null;
await expect(splash).toHaveCount(0, { timeout: 5_000 });
});
test('stale, malformed and missing notices never show', async ({ page }) => {
const bodies = [
JSON.stringify(notice({ updatedAt: now() - 16 * 60 })),
'{"v":1,"phase":"countdown"',
JSON.stringify(notice({ v: 2 })),
JSON.stringify(notice({ phase: 'done', updatedAt: now() - 60 })),
];
let index = 0;
await page.route('**/recovery-notice.json', (route) => {
const body = bodies[Math.min(index, bodies.length - 1)] as string;
index += 1;
return route.fulfill({ status: 200, contentType: 'application/json', body });
});
await open(page);
await expect.poll(() => index, { timeout: 10_000 }).toBeGreaterThan(bodies.length);
await expect(page.getByTestId('recovery-splash')).toHaveCount(0);
});
test('player mode does not poll unless asked', async ({ page }) => {
let hits = 0;
await page.route('**/recovery-notice.json', (route) => {
hits += 1;
return route.fulfill({ status: 204 });
});
await page.goto(stageUrl({ t: 95, tab: 'senses', audio: false }));
await page.waitForSelector('html[data-ready="1"]', { timeout: 60_000 });
await page.waitForTimeout(2500);
expect(hits).toBe(0);
});
test('the box leaves the top of the game clear; the cover stays inside the game', async ({ page }) => {
await open(page);
const game = await page.getByTestId('game').boundingBox();
if (!game) throw new Error('no game box');
const t = 1_790_629_095;
const base = notice({ announcedAt: t, executeAt: t + 60, updatedAt: t });
await page.evaluate(([raw, nowS]) => window.__stage?.recovery(raw, nowS as number), [base, t + 18] as const);
// Measure after the 320 ms rise, not during it.
await page.locator('.recovery__box').evaluate((element) =>
Promise.all(element.getAnimations().map((animation) => animation.finished)).then(() => undefined),
);
const box = await page.locator('.recovery__box').boundingBox();
if (!box) throw new Error('no text box');
expect(box.y).toBeGreaterThan(game.y + game.height / 2);
expect(box.x).toBeGreaterThanOrEqual(game.x);
expect(box.x + box.width).toBeLessThanOrEqual(game.x + game.width);
expect(box.y + box.height).toBeLessThanOrEqual(game.y + game.height);
await expect(page.getByTestId('recovery-countdown')).toHaveText('0:42');
await page.evaluate(
([raw, nowS]) => window.__stage?.recovery(raw, nowS as number),
[{ ...base, phase: 'acting', updatedAt: t + 60 }, t + 70] as const,
);
const cover = await page.locator('.recovery__cover').boundingBox();
if (!cover) throw new Error('no cover');
expect(cover).toEqual(game);
// Every run of text on the splash sits on the page's body floor.
const sizes = await page.evaluate(() =>
[...document.querySelectorAll('[data-testid="recovery-splash"] *')]
.filter((element) => [...element.childNodes].some((node) => node.nodeType === 3 && node.textContent?.trim()))
.map((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
);
expect(sizes.length).toBeGreaterThan(3);
for (const size of sizes) expect(size).toBeGreaterThanOrEqual(24);
await page.evaluate(() => window.__stage?.recovery(null));
await expect(page.getByTestId('recovery-splash')).toHaveCount(0);
});

View file

@ -0,0 +1,261 @@
/**
* The recovery splash's model, poller and server route.
*
* The one property every test here protects: the notice file can never break the stream. A
* missing, malformed, stale or hostile file is no splash, and a good one shows exactly the
* contract's lifetimes (countdown until acting, acting while flysim is down, done ~8 s, failed
* briefly) from the file alone.
*/
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { parseStageOptions } from '../../src/lib/query';
import {
RECOVERY_ACTING_MAX_S,
RECOVERY_DONE_S,
RECOVERY_FAILED_S,
RECOVERY_STALE_S,
cleanText,
formatCountdown,
formatStuck,
parseRecoveryBody,
parseRecoveryNotice,
recoveryView,
} from '../../src/lib/recovery';
import { RecoveryPoller } from '../../src/lib/recovery-poll';
const T = 1_790_629_095;
function notice(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
v: 1,
id: `${T}-reset`,
phase: 'countdown',
action: 'reset',
fromRung: 12,
fromLabel: 'MT. MOON',
toRung: 11,
toLabel: 'PEWTER CITY',
reason: 'unrewarded',
loop: ['GO OBJECTIVE', 'GO WARP'],
stuckSeconds: 1800,
announcedAt: T,
executeAt: T + 60,
updatedAt: T,
...overrides,
};
}
const view = (raw: Record<string, unknown>, now: number) => recoveryView(parseRecoveryNotice(raw), now);
test('the contract example parses', () => {
const parsed = parseRecoveryNotice(notice());
assert.ok(parsed);
assert.equal(parsed.phase, 'countdown');
assert.equal(parsed.action, 'reset');
assert.equal(parsed.toLabel, 'PEWTER CITY');
assert.equal(parsed.fromLabel, 'MT. MOON');
assert.deepEqual(parsed.loop, ['GO OBJECTIVE', 'GO WARP']);
});
test('anything malformed is no notice at all', () => {
const bad: unknown[] = [
null,
42,
'x',
[],
notice({ v: 2 }),
notice({ v: '1' }),
notice({ id: '' }),
notice({ id: 7 }),
notice({ phase: 'panic' }),
notice({ action: 'reboot' }),
notice({ executeAt: 'soon' }),
notice({ updatedAt: Number.NaN }),
notice({ announcedAt: undefined }),
];
for (const raw of bad) assert.equal(parseRecoveryNotice(raw), null, JSON.stringify(raw));
for (const body of ['', '{', 'null', '{"v":1}', 'x'.repeat(20_000)]) assert.equal(parseRecoveryBody(body), null);
assert.ok(parseRecoveryBody(JSON.stringify(notice())));
});
test('optional fields degrade the copy, not the notice', () => {
const bare = view({ v: 1, id: 'a', phase: 'countdown', action: 'reset', announcedAt: T, executeAt: T + 30, updatedAt: T }, T);
assert.ok(bare);
assert.equal(bare.body, 'Rewinding to the last milestone in');
assert.equal(bare.loop, '');
assert.equal(bare.stuck, '');
});
test('helper strings are clipped to a closed character set', () => {
assert.equal(cleanText('<img src=x onerror=alert(1)>', 24), 'img srcx onerroralert1');
assert.equal(cleanText('S.S. ANNE', 24), 'S.S. ANNE');
assert.equal(cleanText(' ROUTE\n\t22 ', 24), 'ROUTE 22');
assert.equal(cleanText('A'.repeat(100), 24).length, 24);
assert.equal(cleanText(12, 24), '');
const parsed = parseRecoveryNotice(notice({ loop: ['go objective', 5, '', 'A', 'B', 'C', 'D'], toLabel: 'pewter\u0000city' }));
assert.ok(parsed);
assert.deepEqual(parsed.loop, ['GO OBJECTIVE', 'A', 'B', 'C']);
assert.equal(parsed.toLabel, 'PEWTERCITY');
// A restart has no destination, whatever the file says.
assert.equal(parseRecoveryNotice(notice({ action: 'restart' }))?.toLabel, '');
});
test('countdown: the text box with M:SS to executeAt, reset and restart copy differ', () => {
const reset = view(notice(), T + 18);
assert.ok(reset);
assert.equal(reset.layout, 'box');
assert.equal(reset.countdown, '0:42');
assert.equal(reset.headline, 'The fly is stuck in a loop!');
assert.equal(reset.body, 'Rewinding to PEWTER CITY in');
assert.equal(reset.loop, 'GO OBJECTIVE > GO WARP');
assert.equal(reset.stuck, 'STUCK 30 MIN');
const restart = view(notice({ action: 'restart' }), T);
assert.ok(restart);
assert.equal(restart.body, 'Shaking it off in');
assert.equal(restart.countdown, '1:00');
// Past executeAt but not yet acting: say "now", never a negative clock.
const late = view(notice(), T + 75);
assert.ok(late);
assert.equal(late.countdown, '0:00');
assert.equal(late.body, 'Rewinding to PEWTER CITY now');
assert.equal(late.busy, true);
});
test('acting: covers the game while flysim is down, and gives up after a cap', () => {
const acting = notice({ phase: 'acting', updatedAt: T + 60 });
const reset = view(acting, T + 90);
assert.ok(reset);
assert.equal(reset.layout, 'cover');
assert.equal(reset.headline, 'Rewinding');
assert.equal(reset.body, 'Back to PEWTER CITY');
const restart = view({ ...acting, action: 'restart' }, T + 90);
assert.equal(restart?.headline, 'Shaking it off');
assert.equal(view(acting, T + 60 + RECOVERY_ACTING_MAX_S + 1), null);
});
test('done shows about 8 s, failed a little longer, then both clear', () => {
const done = notice({ phase: 'done', updatedAt: T + 120 });
assert.equal(view(done, T + 120)?.headline, 'Back at PEWTER CITY!');
assert.equal(view({ ...done, action: 'restart' }, T + 121)?.headline, 'All shaken off!');
assert.ok(view(done, T + 120 + RECOVERY_DONE_S));
assert.equal(view(done, T + 120 + RECOVERY_DONE_S + 1), null);
const failed = notice({ phase: 'failed', updatedAt: T + 120 });
assert.equal(view(failed, T + 125)?.body, 'A human will take a look');
assert.equal(view(failed, T + 120 + RECOVERY_FAILED_S + 1), null);
});
test('a notice older than 15 minutes, or from the far future, is ignored', () => {
assert.ok(view(notice(), T + RECOVERY_STALE_S));
assert.equal(view(notice(), T + RECOVERY_STALE_S + 1), null);
assert.equal(view(notice(), T - 3600), null);
});
test('formatting', () => {
assert.equal(formatCountdown(59.2), '1:00');
assert.equal(formatCountdown(5), '0:05');
assert.equal(formatCountdown(725), '12:05');
assert.equal(formatCountdown(-3), '0:00');
assert.equal(formatStuck(null), '');
assert.equal(formatStuck(30), '');
assert.equal(formatStuck(1800), 'STUCK 30 MIN');
assert.equal(formatStuck(7200), 'STUCK 2 H');
assert.equal(formatStuck(3900), 'STUCK 1 H 5 MIN');
});
test('the page polls in live mode, not in player mode, unless told', () => {
assert.equal(parseStageOptions('?mode=live').recovery, true);
assert.equal(parseStageOptions('').recovery, false);
assert.equal(parseStageOptions('?recovery=1').recovery, true);
assert.equal(parseStageOptions('?mode=live&recovery=0').recovery, false);
});
test('the poller turns every failure into "no notice" and reports changes once', async () => {
const seen: (string | null)[] = [];
let reply: () => Promise<Response> = () => Promise.resolve(new Response(JSON.stringify(notice()), { status: 200 }));
const poller = new RecoveryPoller({
fetch: () => reply(),
onChange: (n) => seen.push(n === null ? null : n.phase),
});
assert.equal((await poller.poll())?.phase, 'countdown');
await poller.poll(); // unchanged: no second callback
reply = () => Promise.resolve(new Response(null, { status: 204 }));
assert.equal(await poller.poll(), null);
reply = () => Promise.resolve(new Response('{"v":1,', { status: 200 }));
assert.equal(await poller.poll(), null);
reply = () => Promise.resolve(new Response('not found', { status: 404 }));
assert.equal(await poller.poll(), null);
reply = () => Promise.reject(new Error('ECONNREFUSED'));
assert.equal(await poller.poll(), null);
reply = () => Promise.resolve(new Response(JSON.stringify(notice({ phase: 'acting' })), { status: 200 }));
assert.equal((await poller.poll())?.phase, 'acting');
assert.deepEqual(seen, ['countdown', null, 'acting']);
});
test('the poller survives a throwing listener', async () => {
const poller = new RecoveryPoller({
fetch: () => Promise.resolve(new Response(JSON.stringify(notice()), { status: 200 })),
onChange: () => {
throw new Error('boom');
},
});
const warn = console.warn;
console.warn = () => {};
try {
assert.equal((await poller.poll())?.phase, 'countdown');
} finally {
console.warn = warn;
}
});
test('flystage-web serves the notice file at /recovery-notice.json, 204 without one', async () => {
const here = dirname(fileURLToPath(import.meta.url));
const serve = resolve(here, '../../../../infra/config/serve.mjs');
const dir = await mkdtemp(join(tmpdir(), 'recovery-notice-'));
const file = join(dir, 'recovery-notice.json');
const child = spawn(process.execPath, [serve, dir, '127.0.0.1', '0'], {
env: { ...process.env, FLY_RECOVERY_NOTICE: file },
stdio: ['ignore', 'pipe', 'inherit'],
});
try {
const base = await new Promise<string>((done, fail) => {
let out = '';
child.stdout.on('data', (chunk: Buffer) => {
out += chunk.toString();
const match = /(http:\/\/127\.0\.0\.1:\d+)\//.exec(out);
if (match?.[1]) done(match[1]);
});
child.on('exit', () => fail(new Error(`serve.mjs exited: ${out}`)));
});
const missing = await fetch(`${base}/recovery-notice.json`);
assert.equal(missing.status, 204);
assert.equal(missing.headers.get('cache-control'), 'no-store');
const body = JSON.stringify(notice());
await writeFile(file, body);
const present = await fetch(`${base}/recovery-notice.json?t=1`);
assert.equal(present.status, 200);
assert.equal(await present.text(), body);
await writeFile(file, 'x'.repeat(20_000));
assert.equal((await fetch(`${base}/recovery-notice.json`)).status, 204, 'oversized files are not served');
// The static half still works, and the notice is not reachable any other way.
await writeFile(join(dir, 'index.html'), '<p>stage</p>');
assert.equal(await (await fetch(`${base}/`)).text(), '<p>stage</p>');
} finally {
child.kill('SIGTERM');
await rm(dir, { recursive: true, force: true });
}
});

View file

@ -24,6 +24,8 @@
* macros-center.png wide pad with both of its columns filled * macros-center.png wide pad with both of its columns filled
* macros-tab.png * macros-tab.png
* pad-strip.png * pad-strip.png
* recovery-{countdown,acting,done,failed}-{reset,restart}.png
* the auto-recovery splash, each phase and both actions
* *
* T1 Instrument is the chosen theme (`docs/stream-mvp-plan.md`, decisions 2026-09-15 evening), so * T1 Instrument is the chosen theme (`docs/stream-mvp-plan.md`, decisions 2026-09-15 evening), so
* the theme sweep the first gate needed is gone: what these images are for now is the *layout* and * the theme sweep the first gate needed is gone: what these images are for now is the *layout* and
@ -142,6 +144,34 @@ const MOMENTS: { name: string; type: string; label: string; detail: string; tab?
{ name: 'moment-rollback', type: 'rollback', label: 'Rolled back to the archived frame', detail: 'try 3' }, { name: 'moment-rollback', type: 'rollback', label: 'Rolled back to the archived frame', detail: 'try 3' },
]; ];
/**
* The recovery splash shots (`src/panels/RecoverySplash.tsx`): every phase for both actions,
* driven through `window.__stage.recovery` with the splash's clock pinned 18 s into the countdown,
* so the clock reads 0:42 on every machine. The notice is the contract's own example.
*/
const RECOVERY_T = 1_790_629_095;
const RECOVERY_SHOTS = (['countdown', 'acting', 'done', 'failed'] as const).flatMap((phase) =>
(['reset', 'restart'] as const).map((action) => ({
name: `recovery-${phase}-${action}`,
nowS: phase === 'countdown' ? RECOVERY_T + 18 : RECOVERY_T + 63,
notice: {
v: 1,
id: `${RECOVERY_T}-${action}`,
phase,
action,
fromRung: 12,
fromLabel: 'MT. MOON',
...(action === 'reset' ? { toRung: 11, toLabel: 'PEWTER CITY' } : {}),
reason: 'unrewarded',
loop: ['GO OBJECTIVE', 'GO WARP'],
stuckSeconds: 1800,
announcedAt: RECOVERY_T,
executeAt: RECOVERY_T + 60,
updatedAt: phase === 'countdown' ? RECOVERY_T : RECOVERY_T + 60,
},
})),
);
function run(command: string, args: string[]): Promise<void> { function run(command: string, args: string[]): Promise<void> {
return new Promise((done, fail) => { return new Promise((done, fail) => {
const child = spawn(command, args, { cwd: appDir, stdio: 'inherit' }); const child = spawn(command, args, { cwd: appDir, stdio: 'inherit' });
@ -264,6 +294,23 @@ async function main(): Promise<void> {
console.log(` ${path} (${String(state?.moment)} ${String(state?.momentPhase)}, ${String(state?.particles)} particles, tab ${String(state?.tab)})`); console.log(` ${path} (${String(state?.moment)} ${String(state?.momentPhase)}, ${String(state?.particles)} particles, tab ${String(state?.tab)})`);
} }
for (const shot of RECOVERY_SHOTS) {
if (!wanted(shot.name)) continue;
await page.goto(url(baseUrl, { fixture: 'steady', t: 95, tab: 'senses' }));
await settle(page);
const shown = await page.evaluate(([notice, nowS]) => window.__stage?.recovery(notice, nowS as number) ?? null, [
shot.notice,
shot.nowS,
] as const);
if (shown === null) throw new Error(`${shot.name}: the splash refused its notice`);
// The text box rises in over 320 ms (`src/theme/recovery.css`).
await page.waitForTimeout(500);
const path = resolve(outDir, `${shot.name}.png`);
await page.screenshot({ path, clip: FRAME });
written.push(path);
console.log(` ${path} (${shown.layout}: ${shown.headline} / ${shown.body} ${shown.countdown})`);
}
// Two extra review shots: the fly strip alone, at 2x, once per renderer. The strip is 800x220 // Two extra review shots: the fly strip alone, at 2x, once per renderer. The strip is 800x220
// in a 1920x1080 frame, which is too small to judge an animal in; these are what a human looks // in a 1920x1080 frame, which is too small to judge an animal in; these are what a human looks
// at to decide whether it reads. // at to decide whether it reads.

View file

@ -0,0 +1,92 @@
/**
* Write a fake auto-recovery notice, for looking at the recovery splash by hand.
*
* The file is the one `infra/bin/fly-loop-recover` writes (contract: `infra/docs/loop-recovery.md`,
* "Stream notice"), written the same way — a temporary file renamed over the target, mode 0644 —
* to `--out`, else `$FLY_RECOVERY_NOTICE`. Point the page's server at the same path:
*
* ```sh
* export FLY_RECOVERY_NOTICE=$PWD/.recovery-notice.json # any writable path
* npm run dev -w @flybrain/stage # serves it at /recovery-notice.json
* # http://127.0.0.1:5273/?mode=player&recovery=1
*
* npm run recovery-notice -w @flybrain/stage -- --phase countdown --action reset --in 45
* npm run recovery-notice -w @flybrain/stage -- --phase acting --action restart
* npm run recovery-notice -w @flybrain/stage -- --demo reset # countdown 20 s, acting 15 s, done
* npm run recovery-notice -w @flybrain/stage -- --clear
* ```
*
* On the release container the deployed `serve.mjs` reads `/run/fly/wd/recovery-notice.json`
* unless its unit sets `FLY_RECOVERY_NOTICE`; this tool is for a dev machine, not for faking a
* recovery on air.
*/
import { chmod, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
type Phase = 'countdown' | 'acting' | 'done' | 'failed';
type Action = 'restart' | 'reset';
function arg(name: string): string | null {
const at = process.argv.indexOf(`--${name}`);
return at === -1 ? null : (process.argv[at + 1] ?? null);
}
const out = arg('out') ?? process.env.FLY_RECOVERY_NOTICE ?? '';
if (out === '') {
console.error('recovery-notice: pass --out <path> or set FLY_RECOVERY_NOTICE');
process.exit(2);
}
async function write(phase: Phase, action: Action, id: string, announcedAt: number, executeAt: number): Promise<void> {
const notice = {
v: 1,
id,
phase,
action,
fromRung: Number(arg('from-rung') ?? 12),
fromLabel: arg('from-label') ?? 'MT. MOON',
...(action === 'reset' ? { toRung: Number(arg('to-rung') ?? 11), toLabel: arg('to-label') ?? 'PEWTER CITY' } : {}),
reason: arg('reason') ?? 'unrewarded',
loop: (arg('loop') ?? 'GO OBJECTIVE,GO WARP').split(',').map((step) => step.trim()),
stuckSeconds: Number(arg('stuck') ?? 1800),
announcedAt,
executeAt,
updatedAt: Math.floor(Date.now() / 1000),
};
const tmp = join(dirname(out), `.recovery-notice.${process.pid}.tmp`);
await writeFile(tmp, `${JSON.stringify(notice, null, 2)}\n`);
await chmod(tmp, 0o644);
await rename(tmp, out);
console.log(`${phase} ${action} -> ${out}`);
}
const sleep = (s: number): Promise<void> => new Promise((done) => setTimeout(done, s * 1000));
async function main(): Promise<void> {
if (process.argv.includes('--clear')) {
await rm(out, { force: true });
console.log(`removed ${out}`);
return;
}
const now = Math.floor(Date.now() / 1000);
const demo = arg('demo');
if (demo !== null) {
const action = (demo === 'restart' ? 'restart' : 'reset') as Action;
const id = `${now}-${action}`;
await write('countdown', action, id, now, now + 20);
await sleep(20);
await write('acting', action, id, now, now + 20);
await sleep(15);
await write(process.argv.includes('--fail') ? 'failed' : 'done', action, id, now, now + 20);
return;
}
const phase = (arg('phase') ?? 'countdown') as Phase;
const action = (arg('action') ?? 'reset') as Action;
const lead = Number(arg('in') ?? 60);
await write(phase, action, `${now}-${action}`, now, now + lead);
}
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});

View file

@ -16,7 +16,8 @@
*/ */
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { createReadStream } from 'node:fs'; import { createReadStream } from 'node:fs';
import { cp, stat } from 'node:fs/promises'; import { cp, readFile, stat } from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { dirname, join, normalize, resolve } from 'node:path'; import { dirname, join, normalize, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
@ -87,8 +88,41 @@ function datasetArtifacts(): Plugin {
}; };
} }
/**
* `GET /recovery-notice.json` in dev and preview, the same route `infra/config/serve.mjs` gives
* the deployed page: the auto-recovery helper's notice file (`FLY_RECOVERY_NOTICE`, default
* `/run/fly/wd/recovery-notice.json`), or 204 when there is none. What makes
* `npm run recovery-notice` (`tools/recovery-notice.mts`) work against a local page.
*/
function recoveryNotice(): Plugin {
const serve = async (req: IncomingMessage, res: ServerResponse, next: () => void): Promise<void> => {
if ((req.url ?? '').split('?')[0] !== '/recovery-notice.json') return next();
const path = process.env.FLY_RECOVERY_NOTICE || '/run/fly/wd/recovery-notice.json';
res.setHeader('cache-control', 'no-store');
try {
const info = await stat(path);
if (!info.isFile() || info.size === 0 || info.size > 16_384) throw new Error('no notice');
const body = await readFile(path);
res.setHeader('content-type', 'application/json; charset=utf-8');
res.end(body);
} catch {
res.statusCode = 204;
res.end();
}
};
return {
name: 'flystage-recovery-notice',
configureServer(server) {
server.middlewares.use((req, res, next) => void serve(req, res, next));
},
configurePreviewServer(server) {
server.middlewares.use((req, res, next) => void serve(req, res, next));
},
};
}
export default defineConfig(({ command }) => ({ export default defineConfig(({ command }) => ({
plugins: [react(), tailwindcss(), datasetArtifacts()], plugins: [react(), tailwindcss(), datasetArtifacts(), recoveryNotice()],
define: { define: {
__STAGE_VERSION__: JSON.stringify(stageVersion(command)), __STAGE_VERSION__: JSON.stringify(stageVersion(command)),
}, },

View file

@ -89,7 +89,9 @@ decoder preset to use.
disassembly at commit 0cd19d3 (`symbols.rs`), gates rewards on a playable state, baselines already disassembly at commit 0cd19d3 (`symbols.rs`), gates rewards on a playable state, baselines already
achieved flags on the first sample so a restore never replays them, and pays only positive achieved flags on the first sample so a restore never replays them, and pays only positive
rewards: story flags, exploration coverage (capped per map), new areas, Pokédex entries, trainer rewards: story flags, exploration coverage (capped per map), new areas, Pokédex entries, trainer
flags, decaying wild wins, badges. Version `pokered-unique8-v5`. flags, decaying wild wins, badges, exits found outdoors, catches, conversations indoors and items
picked up (`docs/rewards-learning.md`). Version `pokered-unique8-v7`; a deploy that names the
previous version in `FLY_ACCEPT_ADAPTERS` migrates its checkpoints instead of refusing them.
- Ratchet (`ratchet.rs`): a 38-rung ladder (boot, bedroom, Pallet Town, Oak's lab, starter, parcel, - Ratchet (`ratchet.rs`): a 38-rung ladder (boot, bedroom, Pallet Town, Oak's lab, starter, parcel,
Pokédex, each town, each badge, the Elite Four, Champion). On first reaching a higher rung in a Pokédex, each town, each badge, the Elite Four, Champion). On first reaching a higher rung in a
safe state it archives the emulator snapshot; on a stall (120 s without new exploration) or a game safe state it archives the emulator snapshot; on a stall (120 s without new exploration) or a game
@ -131,6 +133,9 @@ publish. Pacing uses absolute deadlines at 1.0x by default; it never skips frame
snapshot at 30 Hz: a JSON header (status, rates, learning stats, game mode, milestone rank and snapshot at 30 Hz: a JSON header (status, rates, learning stats, game mode, milestone rank and
total, sugar state, events, chat ring) followed by attachments: RGBA frame, f32 stereo 48 kHz total, sugar state, events, chat ring) followed by attachments: RGBA frame, f32 stereo 48 kHz
audio (binjgb's unipolar u8 converted and DC-blocked), and a 17,407-byte spike bitset. audio (binjgb's unipolar u8 converted and DC-blocked), and a 17,407-byte spike bitset.
flysim serves it itself by default; with `FLY_FEED_VIA=bus` it publishes each snapshot on an
embedded flybus router and the `fly-edge` process serves the same bytes
(`docs/design/flybus.md`, "Feed over the bus").
- Control API (`docs/control-api.md`): loopback HTTP :7401. `POST /stimulate` (sugar: a timed PAM - Control API (`docs/control-api.md`): loopback HTTP :7401. `POST /stimulate` (sugar: a timed PAM
pulse, rate-limited server side), `POST /reward` (present, disabled by config), `POST /chat` pulse, rate-limited server side), `POST /reward` (present, disabled by config), `POST /chat`
(sanitized, deny-listed, ring of 12), `/status`, `/checkpoint`, `/pause`, `/resume`, (sanitized, deny-listed, ring of 12), `/status`, `/checkpoint`, `/pause`, `/resume`,
@ -159,8 +164,8 @@ sequenceDiagram
S->>S: every 5 s hot copy, every 300 s durable checkpoint S->>S: every 5 s hot copy, every 300 s durable checkpoint
``` ```
Where: `services/flysim/crates/flysim/src/{main,config,simloop,pacing,snapshot,feed,api,chat,store,eventlog,metrics}.rs`, Where: `services/flysim/crates/flysim/src/{main,config,simloop,pacing,snapshot,feed,feedbus,api,chat,store,eventlog,metrics}.rs`,
`docs/design/flysim.md`. `services/flysim/crates/fly-edge`, `docs/design/flysim.md`.
## 4. Stage page ## 4. Stage page

View file

@ -1,6 +1,7 @@
# flybus: the communications bus # flybus: the communications bus
Status: **crate landed, nothing wired onto it**. Written 2026-09-22. Index only; the Status: **crate landed; the feed rides it behind `FLY_FEED_VIA=bus`, off by default**.
Written 2026-09-22, amended 2026-09-23 (EDGE-01, below). Index only; the
authority for the API and the wire format is the crate's own authority for the API and the wire format is the crate's own
[README](../../services/flysim/crates/flybus/README.md), and the audit of the crate against [README](../../services/flysim/crates/flybus/README.md), and the audit of the crate against
the draft is the [conformance report](session-framework/bus-conformance.md). the draft is the [conformance report](session-framework/bus-conformance.md).
@ -37,8 +38,8 @@ does not change any published contract by existing.
## Crate layout ## Crate layout
`services/flysim/crates/flybus`, a workspace member of the flysim workspace; no other crate `services/flysim/crates/flybus`, a workspace member of the flysim workspace. `flysim` depends
depends on it yet. on it for the feed publisher (`src/feedbus.rs`) and `fly-edge` for the subscriber.
| Module | Contents | | Module | Contents |
| --- | --- | | --- | --- |
@ -59,16 +60,131 @@ allocate/seal/read with quotas and router restarts, plus the conformance suites
## Wiring still pending ## Wiring still pending
- **flysim publisher.** Router startup inside the sim service, a store root under its - ~~**flysim publisher.**~~ Done 2026-09-23 behind `FLY_FEED_VIA=bus`: see "Feed over the bus".
runtime directory, and snapshot publication as artifact plus header envelope.
- **flysim control services.** The control endpoints as RPC services with grants, so the - **flysim control services.** The control endpoints as RPC services with grants, so the
"no button endpoint" structural guarantee is expressed as a grant table. "no button endpoint" structural guarantee is expressed as a grant table.
- **Stage and bridge clients.** Both are TypeScript/Node; the crate is Rust only, so either - **Stage and bridge clients.** Both are TypeScript/Node; the crate is Rust only, so either
a binding or a thin translating edge process is required before they leave the WebSocket a binding or a thin translating edge process is required before they leave the WebSocket
and HTTP surfaces. and HTTP surfaces. The operator chose the edge process (port decisions, 2026-09-23); for
- **Sizing.** `max_store_bytes`, `max_retained_bytes` and `max_latest_in_flight` need values the feed it exists (`fly-edge`), and they keep the WebSocket contract unchanged. The
chosen for 1.2 MB frames at 30 to 60 Hz with a slow consumer, not the defaults. control API (:7401) is the next slice and stays in flysim until then.
- **Lifecycle.** Orphaned store directories are cleaned only when a new router starts on the - ~~**Sizing.**~~ Decided 2026-09-23: amendment "Feed sizing" below.
same root, so service restart order and the store root's location need a decision. - ~~**Lifecycle.**~~ Decided 2026-09-23: amendment "Feed store lifecycle" below.
- **Migration order.** The feed is the cheaper first move; control should follow only once - **Migration order.** The feed is the cheaper first move; control should follow only once
the bus carries the feed in production for a full session. the bus carries the feed in production for a full session.
## Feed over the bus (2026-09-23, EDGE-01)
`feed.via` (`FLY_FEED_VIA`) picks who serves `ws://127.0.0.1:7400/feed`. `direct` is the
default and is the behaviour that predates the bus. With `bus`:
```text
sim thread --watch<Snapshot>--> publisher task --flybus (in memory)--> Router
(unchanged) (flysim-bus runtime) | <bus_dir>/edge.sock
v (bound to "fly-edge")
fly-edge: Subscription -> watch -> flysim::feed :7400
```
- flysim does not bind `feed.bind`. It starts a `Router` on a runtime of its own (two
threads, `flysim-bus`), store root `<bus_dir>/store`, closed policy: `flysim` may declare
and publish `fly.feed.snapshots`, `fly-edge` may only subscribe to it, and the Unix socket
`<bus_dir>/edge.sock` is launcher-bound to `fly-edge`.
- The topic is `retained: latest`. Each publication is one snapshot: the attachments the
header lists as sealed artifacts named `frame` (`image/x-rgba`), `audio`
(`audio/x-f32le`), `spikes` (`application/x-spike-bitset`), and the header as the payload
`{"header": {...}}`. A header over 48 KiB of JSON goes as a `header` artifact instead, so
the 65,536-byte envelope limit can never make a snapshot unpublishable.
- The sim thread is untouched. The publisher reads the same `watch` slot the direct server
reads, so a slow bus skips snapshots the way a slow WebSocket client does, and nothing on
the bus can hold the loop's publish. `fly_bus_published_total` and
`fly_bus_publish_failures_total` count it.
- `fly-edge` subscribes `latest`, one in flight, with replay, rebuilds each `Snapshot` with
`feedbus::receive` and serves it with flysim's own `feed::router`. `hello`, `wants`,
drop-oldest, the idle header and the framing are therefore the same code, and the bytes are
the same bytes: `crates/fly-edge/tests/parity.rs` replays the committed stage fixtures
through both paths at once and requires byte-equal messages per client flavour.
- `fly_frames_sent_total` and `fly_feed_clients` move to the edge with the clients; it exports
them under the same names on `FLY_EDGE_METRICS_ADDR` (`127.0.0.1:9102` in
`infra/units/flyedge.service`), and watchdog check 2 follows `FLY_FEED_VIA` in `fly.env` to
them. flysim's own copies read 0 in bus mode; `/status` is otherwise unchanged. The edge also
exports `fly_edge_bus_connected`, `fly_edge_bus_lost_total`, `fly_edge_bind_failures_total`
(the bus answered but :7400 was taken, most likely by a flysim still in direct mode) and
`fly_edge_decode_failures_total`.
- Nothing about the fly changes: the readout, the reward catalog, the adapter version and the
compatibility string are byte-identical in both modes (`--print-compatibility`).
### Amendment 2026-09-23: feed sizing
Measured on the live fly (release build, the real cartridge): a running snapshot is a
**92,160-byte** frame (160x144 RGBA; not the 640x480 "1.2 MB" the pending list assumed), a
**17,407-byte** spike bitset (139,255 neurons), about **12,800 bytes** of audio at realtime
(1,600 stereo f32 frames per 30 Hz snapshot at 48 kHz) and a 2 to 3 KB header: **122,367
bytes** of artifacts, about 3.7 MB/s at 30 Hz. `flysim::feedbus::limits()`:
| Limit | Value | Why |
| --- | --- | --- |
| `max_clients` | 8 | connections, pending handshakes included: the in-process publisher and the edge's one socket seat |
| `max_subscriptions_per_client` | 4 | the edge needs 1; this is what bounds the worst case |
| `max_latest_in_flight` | 2 | the default; the edge asks for 1 |
| `max_artifact_bytes` | 4 MiB | ten seconds of audio that piled up behind a late publish |
| `max_store_bytes` | 32 MiB | tmpfs, so RAM; ten times the worst case below |
| `max_retained_bytes` | 8 MiB | one retained snapshot, plus a large header artifact |
| `max_owners_per_client` / reserved | 64 / 8 | three artifacts per delivery, a few deliveries |
| others | small counts | one topic, no services |
A `latest` subscriber that never consumes pins at most its queued slot plus its in-flight
credits (3 snapshots); the topic pins one retained value; the publisher holds one snapshot of
staging plus the sealed copy while sealing. Only one client can subscribe at all: the publisher
is in process, and `edge.sock` is launcher-bound to `fly-edge`, which the router admits once at
a time (a second connection is refused as already connected). The worst case is therefore that
one client holding all 4 subscriptions it may open, none consuming: 4 x 3 + 1 + 2 = **15
snapshots, about 1.8 MB**, and publication never waits on any of them (a latest subscriber is
never a reason to refuse a publication, bus-v1 section 9). `crates/fly-edge/tests/stall.rs`
measures exactly that seat: four hoarding subscriptions, a fifth refused, a second connection
refused, pacer lag 0, no publication refused. The 15 is an upper bound; the measured store
was 472,061 bytes (under 4 snapshots), because fan-out adds roots and never copies, so four
subscriptions stuck on the same publications pin the same artifacts.
### Amendment 2026-09-23: feed store lifecycle
- **Location.** `feed.bus_dir` (`FLY_BUS_DIR`), `/run/fly/bus` on the containers: tmpfs,
0700, owned by `fly`, created by tmpfiles and again by flysim. The store root is
`<bus_dir>/store`, the socket `<bus_dir>/edge.sock`. A reboot empties it.
- **Owner.** The router lives in flysim; its lifetime is flysim's. flysim removes a stale
socket file at start, and `Router::new` removes any store directory whose `flock` is free,
i.e. one a crashed flysim left behind. A clean stop removes its own directory. The edge owns
nothing on disk.
- **Order.** flysim first, the edge after it: `flyedge.service` is `After=` and
`Requires=flysim.service`, so an explicit stop or restart of flysim (the unstick rule's
restart included) takes the edge with it. A crash-restart of flysim needs nothing: the edge
sees the connection close, drops every WebSocket client, unbinds :7400 and reconnects every
500 ms, binding :7400 again only when the first snapshot of the new router arrives. To the
stage that is exactly a flysim restart in direct mode: refused, then back.
- **Default.** `flyedge.service` is in no target and `07-enable.sh` does not enable it;
`05-deploy.sh` writes `FLY_FEED_VIA=direct` unless the env file says otherwise, and refuses
anything but `direct` or `bus` (any case, written lowercased). The switch and the way back
are in the unit's header. The edge gets a cpuset drop-in on the page's CPUs with the other
units, so once enabled it never runs on flysim's.
- **Paths.** `feed.bus_dir` must be absolute and non-empty (checked in both modes), since
flysim and the edge each resolve it.
- **Migration order** is unchanged: the feed first; control only after the bus has carried
the feed in production for a full session.
### Known limits (review round 1, 2026-09-23)
Accepted for now and written down rather than fixed:
- **Feed counters off the container.** In bus mode flysim's `:9101` reports
`fly_feed_clients` and `fly_frames_sent_total` as 0, and the edge's copies are on loopback
`:9102` only. The watchdog follows `FLY_FEED_VIA`; anything that scrapes `:9101` from off
the container (the metrics dashboard) goes blind to the feed until it also scrapes the edge.
- **Store quota is per router, not per client.** Any client on `edge.sock` may allocate
artifacts up to the store cap; a hostile process running as the same user could fill the
store and make flysim's publications fail. The loop is unaffected (a refusal is counted, never
waited on), but the feed would stall. Same-user processes are inside the trust boundary
(crate README, "Limitations").
- **Rollback while in bus mode.** Rolling back to a release without `fly-edge` while `fly.env`
still says `bus` leaves no one on :7400, and check 2 then reads the edge's absent `:9102` and
escalates. Switch back to `direct` first (the unit header's way back), then roll back.
- **Old fixtures.** `cold-open`, `steady` and `big-moment` predate `game.scene` and cannot be a
Rust `FeedHeader`, so fixture parity covers `macros`, `shop`, `center` and `bigpad`.

View file

@ -387,6 +387,17 @@ on-screen ticker cannot disagree with what the sim did.
absent from a `v5` state and restores empty, which is the truth about a run that was never paid absent from a `v5` state and restores empty, which is the truth about a run that was never paid
for a catch. `STATE_VERSION` does not move, because the schema did not. for a catch. `STATE_VERSION` does not move, because the schema did not.
**`v6` -> `v7`** (2026-09-23, the engagement rewards) is the same migration for the next pair,
and `pokered-unique8-v7`'s `migrates_from()` is `["pokered-unique8-v6"]` and nothing else -- `v5`
is no longer migrated, because the live run is `v6`. The deploy that ships it sets
`FLY_ACCEPT_ADAPTERS=pokered-unique8-v6`. No field is added this time: `talk` and `item` key
their ledgers into the existing `seen` array, as `boundary` did, so a `v6` state restores
unchanged with no `talk:` keys. The one step is at the first sample after the restore, not in
`import_state`: a ledger without the `items:seeded` key writes an `item:`/`hidden:` key for every
item the cartridge already shows as taken, pays for none, and marks the seed, so a rollback to a
slot from before a `v6`-era pickup cannot pay for it (`docs/rewards-learning.md`, "The seed").
`STATE_VERSION` stays 4.
- **Restarting a run from an earlier rung** (2026-09-22). `FLY_RESET_STATE=1` throws the run away; - **Restarting a run from an earlier rung** (2026-09-22). `FLY_RESET_STATE=1` throws the run away;
`infra/bin/fly-reset-to-milestone <N>` keeps it and rewinds it. It archives both stores to a `infra/bin/fly-reset-to-milestone <N>` keeps it and rewinds it. It archives both stores to a
dated directory, rewrites `milestone-<N>.checkpoint` with the ratchet's `attempts` and dated directory, rewrites `milestone-<N>.checkpoint` with the ratchet's `attempts` and

View file

@ -133,6 +133,28 @@ a trade and a Pokémon withdrawn from the PC. Reading a catch off it would need
tell those apart. The cartridge's own flag needs none, which is why the row above is the one the tell those apart. The cartridge's own flag needs none, which is why the row above is the one the
adapter reads. adapter reads.
### The engagement rewards' reads (2026-09-23)
**New 2026-09-23** (`talk` and `item`, `docs/rewards-learning.md`, "Engagement rewards"). All six
were resolved by `services/flysim/tools/resolve_wram.py` from `ram/wram.asm` at the pinned commit
and are bracketed by addresses `symbols.rs` already carried; two of the brackets needed the tool
to count `NUM_STATS` and `NUM_CITY_MAPS`, which the decomp defines as `const_value` over an
enumeration.
| what | symbol | address | notes | verified |
| --- | --- | --- | --- | --- |
| the text's subject | `wSpriteIndex` | `$cf13` | `DisplayTextID` copies its argument here: a sprite slot up to `wNumSprites`, else a text id. It arrives **about twenty frames after** `wFontLoaded` bit 0 rises, because `DisplayTextIDInit` loads the font's tiles first; until then it still holds the previous text's subject. | survey (`tests/rom_engage.rs`: the Viridian Forest north gate, the old man at slot 2, font bit on frame 820 and the argument on frame 840) |
| mid-step | `wWalkCounter` | `$cfc5` | non-zero for the frames of a step; the overworld only reads A at zero. Right after `wFontLoaded` in `ram/wram.asm`. | ROM, trace |
| an item ball's item | `wMapSpriteExtraData` | `$d504` | two bytes per sprite slot (slot 1 first): `(item id, 0)` for an `ITEM` `object_event`, `(trainer class, trainer number)` for a `TRAINER` one, zeroes otherwise -- `LoadMapHeader`'s `.itemBallSprite` / `.trainerSprite` / `.regularSprite` | survey (the forest's Antidote ball read `(11, 0)`) |
| taken or hidden, per object | `wToggleableObjectFlags` | `$d5a6` | `flag_array $100`, one bit per global toggleable index (`constants/toggle_constants.asm`); `PickUpItem`'s `HideObject` sets an item ball's bit after `GiveItem` succeeded | survey (the forest's Antidote ball's bit rose on the pickup frame) |
| this map's toggleables | `wToggleableObjectList` | `$d5ce` | up to sixteen `(sprite slot, global index)` pairs, `$ff`-terminated, written by `MarkTownVisitedAndLoadToggleableObjects` | survey |
| hidden items found | `wObtainedHiddenItemsFlags` | `$d6f0` | `flag_array MAX_HIDDEN_ITEMS` (112); `FoundHiddenItemText` sets the bit after `GiveItem` succeeded, and nothing else writes it | ROM (disassembly), trace |
Not used, and why: `hJoyPressed`/`hJoyHeld` would say "A was pressed" directly, but they are HRAM,
which neither `gen_symbols.py` nor `resolve_wram.py` resolves, and a hand-written address is the one
thing those tools exist to refuse. "The fly had the joypad and was standing still on the frame
before the box opened, and the box is about the thing it faces" is the same fact read out of WRAM.
### Battle menu and cursor, own turn against forced switch ### Battle menu and cursor, own turn against forced switch
`HandleMenuInput` is shared by every menu in the game, so which menu is up is read from where it `HandleMenuInput` is shared by every menu in the game, so which menu is up is read from where it
@ -893,3 +915,83 @@ point of reading the figure; a border drawn somewhere the cursor is not parked i
box; and a menu of more than two options is not this menu. What the pad makes of a readable prompt box; and a menu of more than two options is not this menu. What the pad makes of a readable prompt
is `pokemon_red::macros::palette`'s business (`docs/design/macros.md` 12.12 and 12.20), not this is `pokemon_red::macros::palette`'s business (`docs/design/macros.md` 12.12 and 12.20), not this
accessor's. accessor's.
## 12. The people off the screen, and a battle decided (2026-09-23, `docs/design/macros.md` 12.22)
Two readings row 58 added, both out of bytes the seam already had or a byte bracketed by two it had.
### `state::offscreen_npcs`
`CheckSpriteAvailability` (`engine/overworld/movement.asm`) writes `$ff` into
`SPRITESTATEDATA1_IMAGEINDEX` (offset 2) for a sprite that is a toggleable object switched off, that
is outside its window, or that stands on a text box's tiles. The window, for a sprite whose
`SPRITESTATEDATA2_MOVEMENTBYTE1` (offset 6) is `WALK` (`$fe`) or `STAY` (`$ff`), compares the
sprite's biased `MAPY` / `MAPX` (offsets 4 and 5) with `wYCoord` / `wXCoord`: drawn when equal, or
when the sprite's is greater by at most `SCREEN_HEIGHT / 2 - 1` (8) rows and
`SCREEN_WIDTH / 2 - 1` (9) columns. A scripted mover (movement byte below `WALK`) skips the test.
So a `$ff` sprite of the loaded map whose coordinates fall **outside** the window, and whose
movement byte is `WALK` or above, is reported with those coordinates: the cartridge would hide it
for being off the screen whatever else were true, and a sprite it is not updating does not move.
Everything else `$ff` is not reported. Measured from the row-58 checkpoint at the Pewter Gym's
doormat: BROCK at (4, 1) and the Jr. Trainer at (3, 6) reported, the guide at (7, 10) drawn.
What it cannot tell is a toggleable object switched off from one out of sight, because both are
`$ff` outside the window. Its one reader is the rung's own list of people; the ladder's person
places are Oak's lab and the gyms, and only the lab and Viridian Gym carry toggleable people
(`data/maps/toggleable_objects.asm`).
### `poke::CUR_OPPONENT`, `wCurOpponent`
Written when a battle is decided (`home/trainers.asm` for a trainer, the encounter check for a wild
one), cleared by `EndOfBattle` in the same block that clears `wIsInBattle`. Not in the generated
table: `ram/wram.asm` at the pinned commit declares `wIsInBattle:: db`,
`wPartyGainExpFlags:: flag_array PARTY_LENGTH` (one byte), `wCurOpponent:: db`,
`wBattleType:: db`, `wDamageMultipliers:: db`, `wGymLeaderNo:: db`, `wTrainerNo:: db`, and the
table's `wIsInBattle` (`$d057`), `wBattleType` (`$d05a`) and `wTrainerNo` (`$d05d`) are exactly
where that layout puts them, so the byte is `wBattleType - 1` = `$d059`, asserted against both
neighbours in `scene/tests.rs`. Measured on the cartridge in the Pewter Gym: `$00` in the
overworld, `$cd` (`OPP_JR_TRAINER_M`) from the last box of the trainer's challenge through the
219-frame transition and the battle. `controllable` reads it as zero.
## 13. What a move will do: the move table and the bytes its effect reads (2026-09-23, `docs/design/macros.md` 12.23)
Row 60. Seven addresses, resolved by `tools/resolve_wram.py` and emitted into `symbols.rs`; the
tool now follows the decomp's `const` and `_RS` counters and a struct macro's field labels, which
is what reaches the `battle_struct` fields, and it re-derives 77 of 81 pinned addresses with no
disagreement.
| symbol | address | what reads it |
| --- | --- | --- |
| `wPlayerMonStatMods` | `$cd1a` | six stages, ATTACK DEFENSE SPEED SPECIAL ACCURACY EVASION; 1 is -6, 7 normal, 13 is +6 |
| `wEnemyMonStatMods` | `$cd2e` | the same for the enemy |
| `wEnemyMonStatus` | `$cfe9` | the enemy's status byte (`battle_struct` +4) |
| `wEnemyMonType1` | `$cfea` | and `Type2` after it |
| `wEnemyMonAttack` | `$cff6` | the enemy's modified ATTACK DEFENSE SPEED SPECIAL, big-endian words |
| `wBattleMonAttack` | `$d025` | the same for the fly's Pokémon |
| `wEnemyBattleStatus2` | `$d068` | bit 1 Mist, 4 substitute, 5 must recharge |
**The move table is ROM.** `Moves` opens `SECTION "Battle Engine 7"`, which `layout.link` places
first in bank `$0E`, so it is `$0E:$4000`, six bytes a row in move-id order from `POUND`:
animation (the move id itself), effect, power, type, accuracy, PP. `state::move_data` reads a row
through `MemoryReader::read_rom`, the cartridge image, and refuses a row whose first byte is not the
id asked for. Measured from the row-60 checkpoint: TACKLE (`$21`) effect `$00` power 35, TAIL
WHIP (`$27`) effect `$13` (`DEFENSE_DOWN1_EFFECT`) power 0.
**`state::move_without_effect`** answers only refusals decided before the roll, for a move with no
power, from `engine/battle/effects.asm` and `MoveHitTest`:
- `*_UP1` / `*_UP2`: the user's stage is 13, or (ATTACK..SPECIAL) the stat is 999;
- `*_DOWN1` / `*_DOWN2`: the target has Mist or a substitute, its stage is 1, or (ATTACK..SPECIAL)
the stat is 1 -- `StatModifierDownEffect` restores the stage and prints "Nothing happened!"
then, so a low-level target reaches it before -6;
- `SLEEP_EFFECT`: any status, unless the target must recharge;
- `POISON_EFFECT`: a substitute, any status, or a Poison type;
- `PARALYZE_EFFECT`: any status, or an Electric move against a Ground type.
`None` outside a battle, without a cartridge, or with a stage byte outside 1..13.
**Not covered** (the reading would be the same kind, and nothing early in the game reaches it):
Confuse Ray and Supersonic on a confused target, Leech Seed on a seeded or Grass target, Focus
Energy, Mist, Reflect and Light Screen already up, Disable on a disabled target, and a damaging
move the type chart makes "doesn't affect" (the chart is another ROM table).

View file

@ -1445,6 +1445,211 @@ Nothing presses for the fly and nothing is ranked: one button leaves a pad it co
one wall moves to the tile that earned it, and one walk goes where it can. The decoder, the reward catalog, the adapter one wall moves to the tile that earned it, and one walk goes where it can. The decoder, the reward catalog, the adapter
version, the roles and the compatibility string are untouched. version, the roles and the compatibility string are untouched.
### 12.22 The rung's people are in the room when the screen does not show them (2026-09-23, row 58)
Live on v0.5.3, rank 10, for twenty-five minutes: `GO OBJECTIVE` into the Pewter Gym, `GO OUT`
straight back out, with `GO ITEM`, `GO FRONTIER`, `YES` and `NO` mixed in. Per ten brain minutes
about 93 `GO OUT`, 47 `GO OBJECTIVE`, 200 starts in all, every one `done`, **no reward event of any
kind**, the exploration count frozen at 1,892. Check 10 saw ten distinct names and said nothing.
Surveyed from the live checkpoint with the route probe (`FLY_PROBE_CATCH=route`,
`FLY_PROBE_CATCH_MAP=54`), which reads the room on the fly's Nth arrival.
- **The objective saw the room through the screen.** `objective_targets` read `npcs`, which is
what the cartridge *draws*, and `CheckSpriteAvailability` writes `$ff` into the image index of
every sprite outside a window of the player's coordinate. From the doormat at (4, 13) that window
holds the guide at (7, 10) and nobody else: BROCK at (4, 1) and the Jr. Trainer at (3, 6) are not
drawn. With the guide talked to (12.20), the rung's list was empty, so `GO OBJECTIVE` had nothing
to aim at inside and 12.5's rule -- the ways out are withheld while the rung's person is in the
room -- let `GO OUT` onto the pad. Outside, `GO OBJECTIVE` aimed at the gym's door. The pair
undoes itself in about a second, and nothing on either side of the door earns anything.
- **So the rung reads the people the cartridge hides only for being off the screen.** The window
is a function of `wYCoord`, `wXCoord` and the sprite's own biased coordinates, all already read,
so a sprite whose `$ff` falls outside it is one the cartridge would hide for that reason whatever
else were true, and a sprite the cartridge is not updating does not move
(`state::offscreen_npcs`). A `$ff` *inside* the window, or on a scripted mover, is not the
screen's and is not reported. Only the rung reads the list: a sprite outside the window may also
be a toggleable object switched off, which reads the same, so `GO NPC`, `TALK` and objects keep
what is drawn.
- **Facing any of the rung's people is the arrival.** 12.5 left out only the one ahead, which was
enough for one target; a gym names three, and in front of BROCK `GO OBJECTIVE` still had the
trainer to walk to. A fly facing a person the rung is waiting on has nothing left for a walk to
do, and `TALK` is the press.
Three frames the seam read as the fly's own were the cartridge's, and each wrote a ledger entry that
emptied the room again once the first fix let the fly into it:
- **A warp's tear.** `wCurMap` changes thirty-two frames before the header, the coordinates and the
warp table follow it, while the screen fades, and no joypad bit is set until the fade is over.
The seam read "map 54 at (16, 17)" -- Pewter City's doormat under the gym's id -- as an overworld
and dealt it a pad; a walk started there planned over the wrong map, and what it aimed at went
into the blocked ledger under the gym's id (live: `GO OUT` started and finished in 0.05 s). The
driver now reads a tear as the map byte having changed while the fly still stands on a warp of
the loaded table that leads to the map the byte names, deals it as `Unknown` with an empty pad,
and records no ground from it. Teleport pads -- Saffron Gym, two Silph Co. floors -- do not change
the map byte, so they are never a tear; a tear is bounded at ninety frames all the same.
- **A battle's transition.** Between a trainer's challenge closing and the battle screen there are
219 frames with every joypad and script bit clear. The pad was dealt, a walk toward BROCK pressed
into the animation and gave up after three refused steps -- BROCK blocked for ten brain minutes --
and the Jr. Trainer's conversation read as over, so the trainer the fly then lost to was
"talked to" for the session. `wCurOpponent` is set when a battle is decided and cleared by
`EndOfBattle` with `wIsInBattle`; it is not in the generated table and is derived as the byte
between two that are, both neighbours checked in a test (`macros-wram.md` section 12), and
`controllable` reads it.
- **A trainer walking up.** 12.4 reads a macro the cartridge ended by taking the joypad as a
refusal and wrote the target blocked and the tile pushed at once. A trainer who sees the fly
takes the joypad the same way. The entries now wait until the cartridge gives the joypad back:
in the overworld it was a refusal and is written as before; a battle teaches the ledgers nothing.
Nothing is ranked, nothing presses for the fly, and no button is added to any pad: `GO OBJECTIVE`
has a person to walk to where it had none, `GO OUT` is withheld by 12.5's own rule, and three
frames that were never the fly's deal nothing. The decoder, the reward catalog, the adapter
version, the roles and the compatibility string are untouched.
### 12.23 A move the cartridge answers with nothing is not dealt beside one it does not (2026-09-23, row 60)
Live on v0.5.5, early game after the reset to milestone 1: Route 1, Squirtle L5 (TACKLE, TAIL
WHIP) against a wild Pidgey, "Nothing happened!" on the screen. Since the reset `MOVE 2` 183
times and `MOVE 1` once; the last reward 25 brain minutes before the checkpoint, one wild win in
the whole run. Check 10 flagged `unrewarded` (1,600 decisions, no reward event, no new ground on
two probes), which is right, and it is unchanged.
- **The pad was at fault, not only the choice.** `MOVE n`'s precondition was "the slot holds a
move with PP", so TAIL WHIP stayed on the pad after it had walked the Pidgey's DEFENSE to the
point the cartridge refuses it. `StatModifierDownEffect` answers "Nothing happened!" when the
stage is already -6 **or the stat itself is already 1**, restoring the stage. The checkpoint is
the frame Squirtle fainted to a Pidgey L3 at DEFENSE -6 (stat 2); in the next battle the stat
reached 1 at -5. From there the pad dealt `MOVE 1, MOVE 2, RUN` and the fly pressed `MOVE 2`
until Squirtle fainted, woke at home and walked back: every battle lost, one wild win in the
run. The readout's favourite being `MOVE 2` is the fly's; a button that can do nothing at all
being on the pad is a macro that knows nothing about its own effect, section 12.2's trap.
- **What a move does is the cartridge's, read the same way for every move.** `state::move_data`
reads the move's row of `Moves` from the cartridge image (`$0E:$4000`, each row checked against
its own id) and `state::move_without_effect` answers the refusals the effect routines make on
bytes already in WRAM: a stat stage at its limit or a stat at 1 or 999, Mist or a substitute in
front of a stat-lowering move, a sleep, poison or paralysis move against a target that already has
a status, is Poison type, or is Ground type to an Electric move. No move is named; a miss is a
roll and is not answered. `macros-wram.md` section 13 has the bytes.
- **It is PP's rule.** A move the cartridge answers with nothing is not dealt beside one it does
not, exactly as a spent move is not (12.6, 12.8), and `MOVE 1` over the menu stops being FIGHT's
backstop only in that case. When no move would do anything the moves stay as PP deals them:
taking the last ones away would leave an open list whose only button is `BACK`, 12.11's pair,
and a turn that ends on "Nothing happened!" still ends. RUN, ITEM and SWITCH are untouched.
Nothing is ranked, weighted or pressed for the fly: a button leaves the pad while it cannot change
anything and comes back when it can (a new battle resets the stages). The decoder, the reward
catalog, the adapter version, the roles and the compatibility string are untouched.
### 12.24 The map graph is the disassembly's, piece by piece (2026-09-23, row 59)
Opened pre-emptively: the row-58 review carried the route survey past the Boulder Badge and the
fly walked Pewter City (39, 17) to Route 3 (0, 9) and back from about frame 68,000, `GO OBJECTIVE`
done on Route 3 538 times and `GO ROUTE` done on Pewter 537. Reproduced from a rank-11 checkpoint
the survey writes (`FLY_PROBE_SAVE_RANK=11`): 509 and 508, never on Route 4. The live fly got
through Route 3 anyway and met the next half on v0.6.0 at 22:20 UTC: rank 12, MT. MOON, the
objective Cerulean, on Route 4 per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103,
two new tiles, in and out of the Pokécenter and the cave mouth. Route 4 was one node with Cerulean
off its east edge, which the mountain cuts off from the cave mouth's side. From the live
checkpoint the route survey on `main` walks it 930 times in 72,000 frames.
**The geography table disagreed with the headers.** Checked row by row against
`data/maps/headers/*.asm` and `data/maps/objects/*.asm` at the pinned commit:
- Route 4 is **north** of Route 3, not east (`Route3.asm`: `connection north, Route4`); Route 3's
top edge is the road to Mt. Moon's Pokécenter. Mt. Moon's doors are both **on Route 4**:
(18, 5) into the first floor and (24, 5) into B1F, whose (27, 3) is the way out. Route 3 has no
warps. So Route 3's north edge named no map, and nothing on Route 3 was the way to the rung.
- Route 14 / 15 and Route 24 / 25 had the right neighbour in the wrong column (west and east,
not south and north). Route 24's east edge is Nugget Bridge's far end, rung 16's road.
**Four maps are pieces the player cannot walk between** (12.7's rule, measured by flooding every
tile of every map on the graph from the blocks, blockset, collision list, tile-pair walls and
ledges): Route 2 as before; **Route 4**, cut by the mountain into the cave mouth's side and
Cerulean's side; **Mt. Moon B1F**, four chambers of two ladders each; **B2F**, one large piece and
two small ones. The one road through is 1F (5, 5), B1F (21, 17), B2F (5, 7), B1F (27, 3); the
other two ladders on 1F lead to dead ends.
A split row is now any number of pieces, each with its doors (the warp index and tile) and what is
one step from it, a whole map or another map's piece. Three rules keep it honest:
- **Which piece the fly is in** is what its walk can reach on the decoded grid (section 15): the
piece whose doors it reaches, when exactly one piece's are. The grid has no ledges, so where it
reaches none (Route 4 below the ledges) the nearest door answers. Flooded over every tile of
the four maps' ground in the disassembly, the rule names the right piece for all of them.
- **Which piece a door lands in** is the cartridge's own answer: a warp names the destination
warp it arrives at (`wWarpEntries` byte 2), and each piece lists its warps. An edge lands in
the piece that lists the map it is stepped off.
- **The hop is a piece**, and an exit is toward the objective only if it lands in that piece. On
1F three ladders go down to B1F and one of them is the road.
**A connection nobody can walk across is not a road.** Four header connections have no tile where
both sides are land: Pallet Town / Route 21, Cinnabar / Route 20, Route 20 / 19 (sea) and Route 22 /
23 (the League's fence). They keep their name and offer no exit and no hop. Without this the road
from Pallet Town to Cerulean was by sea, and a fly that whited out in Mt. Moon, which the survey's
did, walked into Pallet's shore every two seconds.
**One seam frame, found on the way.** Route 3's first trainer closes his challenge onto five frames
of plain overworld before `StartTrainerBattle` decides the battle (`home/trainers.asm`: it runs
after `DisplayTextID`'s close-down). Row 58's pending push-back was decided on the first of them
and walled (11, 6), the one gap between Route 3's west end and the rest of the road, for the
session. A push-back is now a refusal only once the overworld has been the fly's for thirty frames
running; a battle inside them drops it.
From the badge, the route survey reaches Route 4 at frame 70,356 and Mt. Moon at 70,707 (rung 12),
no pushed tile; after whiting out in the cave it walks the land road back from Pallet Town. The
ROM test on the stub rotation reaches Mt. Moon in 56.7 brain minutes with 4 Pewter / Route 3
crossings; the base makes 3,391 in 80.4 and never stands on Route 4. From the live 2026-09-22
rank-11 checkpoint the branch reaches Mt. Moon too, where the base ends fenced on Route 3. From
the live Route 4 checkpoint the branch is in the cave on frame 279 and stays on the road; on the
stub rotation Route 4's west doors are crossed 13 times in twenty brain minutes (whiteouts and
walks back included) against 56 on `main`.
Nothing is ranked and nothing presses for the fly: a table of maps says what the headers say, a
split map has the pieces its ground has, and a frame that was the cartridge's is not read as the
fly's. The decoder, the reward catalog, the adapter version, the roles and the compatibility
string are untouched.
### 12.25 A trainer's challenge is the cartridge's until its battle is over (2026-09-23, row 61)
Live on v0.5.5, rung 9, for twenty minutes: `GO OBJECTIVE` into Viridian Forest's south gate (map
50, `$32`), `GO OUT` straight back onto Route 2, `GO WARP` back from the forest, `GO OBJECTIVE
blocked` in the forest, no reward. Reproduced with the route survey from the live checkpoint
(uniform choice per hold, xorshift seed 7), which walks the same ring for twenty brain minutes.
- **The ring's cause was a wall, not the gate.** The forest's only road to its north gate is a
two-wide corridor at x = 1-2; a Bug Catcher stands on (2, 18) facing west. A walk up the
corridor steps onto (1, 18), the trainer takes the joypad, and row 58's held push-back waits for
the joypad to come back. `DisplayEnemyTrainerTextAndStartBattle` clears `wJoyIgnore` before the
challenge text and `StartTrainerBattle` writes `wCurOpponent` only after that text's close-down:
**five frames** with no box, no script bit and `wCurOpponent` zero, which the seam read as the
fly's overworld. The push was written there; (1, 18) went into the pushed ledger, which has no
window, and from then on every walk to the north gate had no road. `GO OBJECTIVE` walked to the
nearest reachable tile, a dead end at (6, 1), and was blocked; `GO WARP`'s last tier took the
south gate, whose `GO OUT` is Route 2, whose `GO OBJECTIVE` is the gate.
- **The fact is `wStatusFlags7` bit 3, `BIT_TRAINER_BATTLE`**: set by `CheckFightingMapTrainers`
on the "!", cleared at `.battleOccurred` after every battle (before the blackout check). It
covers more than the five frames: the "!" bubble runs about sixty frames before `wJoyIgnore` is
set, and they read as the fly's overworld too -- about sixty-six free-looking frames per
engagement, measured. An overworld frame with the bit set is `Unknown` in the macros' own
scene, with no text box, so the pad is empty, no ground is recorded and no held entry is
decided on it. A trainer talked to by the fly never sets it; its `wCurOpponent` is written
inside the text.
- **On main the five frames are already row 59's** (12.24: a held push-back is written only after
thirty frames of overworld), and that alone keeps (1, 18) clear. This row is the cartridge-fact
layer under it: the pad is empty through the bubble as well, no ground is recorded, and it does
not depend on the gap staying under thirty frames.
- **The macros' reading only.** `controllable` and `scene::detect` are shared with the reward
adapter and do not change; `PokeState`'s `scene` and `scripted` read the bit beside them. In
macros mode the feed's `game.scene` is the palette's, so it reads `unknown` on those frames,
which is what the contract says of a frame the cartridge is driving.
- **The gates were modelled right.** Both forest gates are on the graph and `next_hop` answers
the forest from the south gate and Route 2 from the north one. The south gate's `GO OUT` is the
"a room has to be leavable" tier, a way back that is the fly's choice: with the corridor open,
the survey seed that walked into the gate twelve times still earned the badge, on both arms.
Nothing is ranked or pressed for the fly: frames that were never the fly's deal nothing. The
decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched.
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a ## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
## priority to visit the shop at least once per area; make shop macros item purchases. same ## priority to visit the shop at least once per area; make shop macros item purchases. same
## for the Pokécenter. heal should be a macro.") ## for the Pokécenter. heal should be a macro.")

View file

@ -177,6 +177,10 @@ achievements.
| Wild win | +0.1, +0.05, +0.0333 | at most three observed wild KOs per `(map,species,level)` | | Wild win | +0.1, +0.05, +0.0333 | at most three observed wild KOs per `(map,species,level)` |
| Badge | +3 | each newly set badge bit | | Badge | +3 | each newly set badge bit |
That is the prototype's catalog as it shipped, kept here as history. The live one is
`docs/rewards-learning.md`: `pokered-unique8-v7` adds `boundary` (v5), `catch` (v6), and `talk` and
`item` with no `boundary` payout indoors (v7, the operator's decision of 2026-09-23).
Every value is positive; there were no loss or blackout penalties. Values in a frame summed into Every value is positive; there were no loss or blackout penalties. Values in a frame summed into
`R`, then `m = tanh(R)`. PAM stimulation ran 80 to 400 ms depending on reward kind, with `R`, then `m = tanh(R)`. PAM stimulation ran 80 to 400 ms depending on reward kind, with
overlapping pulses taking their maximum. overlapping pulses taking their maximum.

View file

@ -8,7 +8,9 @@ the project."
1. The release watchdog (`infra/bin/fly-watchdog` check 10) flags a suspected loop: few distinct 1. The release watchdog (`infra/bin/fly-watchdog` check 10) flags a suspected loop: few distinct
macros, a short sequence repeating, no growth in places explored. It exports macros, a short sequence repeating, no growth in places explored. It exports
`fly_loop_suspected` and writes `/run/fly/wd/loop.json`. It never acts. `fly_loop_suspected` and writes `/run/fly/wd/loop.json`. It never acts. The separate
`fly-loop-recover.timer` unsticks a confirmed trap on its own: flysim restart, then milestone
resets (`infra/docs/loop-recovery.md`). That buys time; it does not replace checkpoint-based review.
2. The coordinator session (Fable) checks that marker on a schedule. On a flag it pulls the 2. The coordinator session (Fable) checks that marker on a schedule. On a flag it pulls the
live checkpoint read-only (`pct pull`, into `.local/checkpoints/`, never committed), and live checkpoint read-only (`pct pull`, into `.local/checkpoints/`, never committed), and
spawns a review agent with the trap brief: reproduce from the checkpoint with the real spawns a review agent with the trap brief: reproduce from the checkpoint with the real

View file

@ -1,6 +1,6 @@
# Rewards and learning # Rewards and learning
The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v6`. The code of record is The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v7`. The code of record is
`services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the `services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the
gates and the rules); this page says what each rule pays for and why it is allowed to. The gates and the rules); this page says what each rule pays for and why it is allowed to. The
prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules
@ -23,8 +23,10 @@ change what the fly can do.
| `trainer` | `trainer` | +0.5 | 200 ms | Each named `EVENT_BEAT_*` flag once, except the flags classified as story milestones | | `trainer` | `trainer` | +0.5 | 200 ms | Each named `EVENT_BEAT_*` flag once, except the flags classified as story milestones |
| `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` | | `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` |
| `badge` | `badge` | +3 | 400 ms | Each newly set badge bit | | `badge` | `badge` | +3 | 400 ms | Each newly set badge bit |
| `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger | | `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger. **Nothing on an indoor map** (since v7): the exit is still recorded, and pays 0 |
| `catch` | `wildwin` | +0.30, +0.10 | 150 ms | A wild Pokémon kept by a ball: +0.30 for a species this run had never owned, +0.10 for a repeat; at most three payouts per species for the lifetime of the ledger | | `catch` | `wildwin` | +0.30, +0.10 | 150 ms | A wild Pokémon kept by a ball: +0.30 for a species this run had never owned, +0.10 for a repeat; at most three payouts per species for the lifetime of the ledger |
| `talk` | `explore` | +0.10 | 100 ms | A conversation the fly opened with a person or a sign **indoors**, paid when its box closes; once per `(map, sprite slot or sign text id)` for the lifetime of the ledger |
| `item` | `explore` | +0.15 | 120 ms | An item ball or a hidden item picked up, on any map; once per item for the lifetime of the ledger |
Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")` Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")`
is `None` by test. The values in one frame sum into `R`, and the network reinforces once with is `None` by test. The values in one frame sum into `R`, and the network reinforces once with
@ -43,6 +45,18 @@ currently reads "wild win". The event's own label, `CAUGHT #<species>`, is what
log, `/status` and the checkpoint. Changing the ticker copy means opening the feed's closed kind log, `/status` and the checkpoint. Changing the ticker copy means opening the feed's closed kind
set, which this rule deliberately did not do. set, which this rule deliberately did not do.
`talk` and `item` publish on `explore`, for the same reason `boundary` does: each is the fly
finding what is in a place -- new ground, a door, a person or sign it opened, an item it picked up
-- at the same quiet scale (0.05 to 0.15). Not `area`, which counts maps and is a notable row; not
`story`, which is the plot; not `wildwin`, which is a battle. No feed kind was added, so
`docs/feed-protocol.md` and the stage's switch statements did not move. The Pokémon Red ticker's
`explore` row still says "new place" ("3 new places" collapsed) for all four: "new find" was
proposed and the operator kept "new place" (2026-09-23), so a conversation or an item reads as a
new place on screen. The event labels -- `TALKED TO #<slot> IN AREA <map>`, `READ SIGN #<id> IN
AREA <map>`, `FOUND ITEM #<item>`, `FOUND A HIDDEN ITEM` -- reach the event log, `/status` and the
checkpoint. Both also reset the stage's stall meter, which counts `explore`: engaging with a
building is progress in the sense the operator asked for.
## Catch rewards ## Catch rewards
The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not only for The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not only for
@ -82,6 +96,87 @@ well below a badge (3.0). A catch of a new species pays 0.80 across two kinds, w
a story flag and a badge -- deliberately, because it is the one event that is both a discovery and a story flag and a badge -- deliberately, because it is the one event that is both a discovery and
a thing the fly had to do on purpose. a thing the fly had to do on purpose.
## Engagement rewards
The operator's decision of 2026-09-23, recorded with the port decisions: reward the fly for
engaging *inside* buildings and stop paying it for leaving them. It was chosen over a pad rule and
over weighting the choice, and it is a catalog change -- an operator decision, like the catch
reward -- not a loop-review fix (`docs/loop-review.md`). It answers a shape the loop reviews kept
finding in Pewter: `GO OBJECTIVE` into a building and `GO OUT` straight back, paid for the door on
the way out and for nothing inside.
**Indoors** is two of the cartridge's own tables, and nothing hand-classified
(`pokemon_red/engage.rs`, `indoor`). `CheckIfInOutsideMap` (`home/overworld.asm`) is the game's
outdoor test -- tileset `OVERWORLD` or `PLATEAU` -- and `WarpFound2` labels its other branch
`.indoorMaps`; on its own that would call Viridian Forest and every cave indoors, and their exits
are how the fly gets anywhere. `BikeRidingTilesets` (`data/tilesets/bike_riding_tilesets.asm`) is
the list of places the bicycle may be ridden -- `OVERWORLD`, `FOREST`, `UNDERGROUND`, `SHIP_PORT`,
`CAVERN` -- and the bike is the one thing the cartridge refuses inside a building by rule. A map is
indoors when its `wCurMapTileset` is in neither: every house, mart, Pokémon Center, gym, gate, lab
and museum, the S.S. Anne, Silph Co., the Pokémon Tower, the Mansion, the Rocket Hideout and the
Indigo Plateau's rooms. Not the forest, a cave, the Underground Path or Vermilion's dock.
**`talk`, +0.10.** Paid on the sample the text box closes, for a conversation that
1. *the fly opened*: on the last sample before the font bit (`wFontLoaded` bit 0) rose, the fly had
the joypad -- no `wJoyIgnore`, no simulated input, no scripted movement -- was standing still
(`wWalkCounter` zero, the only state the overworld reads A in) and stood where it stands now. A
script's text usually opens with the joypad taken; one opened by a map script the frame after a
step ends can still look opened by the fly, and is then held to rule 2 (below);
2. *is with the thing in front of it*: `DisplayTextID` copies its argument into `wSpriteIndex` --
a sprite slot up to `wNumSprites`, or a text id -- and the sprite must stand on the tile the
player faces (or one further, across a counter, on a tileset that has counter tiles, which is
`IsSpriteOrSignInFrontOfPlayer`'s own long reach), or the text id must be the sign's on that
tile. The byte arrives about **twenty frames after** the font bit (measured on the cartridge:
`DisplayTextIDInit` loads the font's tiles first) and until then still names the previous
text's subject, so it is read once it has changed or 45 samples have passed, and only while the
bottom dialogue box is drawn -- the start menu draws its own box elsewhere. An item ball is a
sprite but not a person, and pays `item`;
3. *opened indoors*, on the map the box opened on;
4. *finished*: the box closed on the same map. A conversation that ends in a warp, a rollback or a
restore pays nothing.
The ledger is the adapter's lifetime `seen` set, keyed `talk:<map>:sprite:<slot>` or
`talk:<map>:sign:<text id>` -- the same "map and object index" the macros' `talked` ledger uses,
but **not** that ledger: the macros' ledger is session state and is thrown away on a restore; this
one is checkpointed and survives a rollback, so talking to the same person again, after a restore
or not, pays nothing. The trainer the fly speaks to before a battle is a person and pays once; the
nurse, a clerk and a sign each pay once per map.
**`item`, +0.15.** Read from the cartridge's own "this one has been taken" bits, on any map.
An item ball is one of the map's toggleable sprites (`wToggleableObjectList`, sprite slot and
global index) whose `wMapSpriteExtraData` is `(item id, 0)` -- the shape `LoadMapHeader` writes for
an `ITEM` `object_event` and for nothing else (a trainer's is `(class, number)` with numbers from
1, a person's two zeroes); `PickUpItem` sets its global bit in `wToggleableObjectFlags` through
`HideObject`, and only after `GiveItem` succeeded, so a full bag pays nothing. A hidden item is a
bit of `wObtainedHiddenItemsFlags`, set by `FoundHiddenItemText` after `GiveItem` and by nothing
else; hidden *coins* have their own bitset and are not items. Either pays when its bit rises
between two playable samples, keyed `item:<global index>` or `hidden:<index>`, once for the life
of the ledger. A gift item from a script (the Old Amber, a TM from a person) is not an item ball:
the conversation pays `talk`, and the item nothing.
**The seed.** The first playable sample that finds the key `items:seeded` absent -- a fresh
adapter, or a `v6` ledger restored under `v7` -- writes a key for every bit the game already shows
as taken and pays for none of them, so a rollback to a slot from before a `v6`-era pickup cannot
pay for taking it again. The two item balls a script *reveals* are left out of the seed, because
their bits are set from a new game until Giovanni's defeat clears them: the Rocket Hideout's Silph
Scope and Lift Key (`$87`, `$88`), the only `ITEM` entries `data/maps/toggleable_objects.asm` starts
`OFF`.
**`boundary` indoors.** Every exit on an indoor map is still written to the ledger, so
`exit_visited` answers exactly what it did and the macros see no change, but nothing is paid. A
town's doors, a route's edges, the forest's gates and a cave's ladders pay as before. One
consequence, measured on the cartridge (`tests/rom_engage.rs`): for the thirty-odd frames of
`PlayMapChangeSound` the cartridge has written the destination into `wCurMap` while the tileset and
the warp table are still the map being left, so the exit the fly is standing on is classified by
the map it belongs to. Walking into a building through a town door still pays that door's on-exit
half, 0.10, once, keyed under the building's id as it always was; walking out pays nothing.
**The scale.** A building's worth of engagement -- a few people, a sign, perhaps a ball -- is
0.3 to 0.6: more than the 0.15 its door paid for being left, less than a new Pokédex entry per
person, far below a badge. Everything is once per thing for the lifetime of the ledger, so no
building can be farmed.
## Gates ## Gates
Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other
@ -110,6 +205,10 @@ therefore replays none of it.
## Boundary rewards ## Boundary rewards
Since `pokered-unique8-v7` everything below holds **outdoors** -- towns, routes, the forest,
caves -- and on an indoor map the same keys are written and nothing is paid ("Engagement rewards"
above has the definition of indoors and the one transition frame worth knowing about).
`docs/design/room-escape.md` section 2. The rule pays 0.05 the first time the fly stands on a tile `docs/design/room-escape.md` section 2. The rule pays 0.05 the first time the fly stands on a tile
orthogonally adjacent to one of the current map's exits, and 0.10 the first time it stands on the orthogonally adjacent to one of the current map's exits, and 0.10 the first time it stands on the
exit tile itself. Both are keyed into the adapter's lifetime `seen` ledger as exit tile itself. Both are keyed into the adapter's lifetime `seen` ledger as
@ -177,7 +276,20 @@ body picks the macro; the descending neurons press the buttons.**
## Honesty ## Honesty
The catalog now includes catches. The honesty panel's copy is not data-driven from the catalog -- The catalog now includes conversations and items (v7). Paying for a conversation is the closest
the catalog has come to paying for a *button*: A is what opens one. It is still a reward, not a
press. Nothing in the adapter presses A, chooses when, or tells the fly who is there; the payout is
read out of WRAM after a conversation the fly's own buttons -- or the macro the mushroom body chose
-- opened and finished, and it is once per person or sign for the life of the run, so the thing
that is learned is "the people in a building are worth a visit", not "press A". It is also why the
rule demands evidence that the fly opened the box. That evidence is not proof: a map script runs
one frame after a step ends and may open text while the fly is still, controllable and on the same
tile. Such text pays only if it names the person or sign the fly is facing, once per key; in the
early game none can (checked: the museum ticket man, the Route 22 and Route 5 guards, Viridian
Mart, Oak's Lab), and a few late ones can once each (the Fighting Dojo master, the Elite Four
after their battles). Review of 2026-09-23.
The catalog also includes catches. The honesty panel's copy is not data-driven from the catalog --
`apps/stage/src/lib/schedule.ts`'s rotating card is four written lines and lists no kinds -- so `apps/stage/src/lib/schedule.ts`'s rotating card is four written lines and lists no kinds -- so
there was nothing to regenerate and the copy is unchanged. The sentences below are where the there was nothing to regenerate and the copy is unchanged. The sentences below are where the
argument lives. argument lives.

View file

@ -815,3 +815,79 @@ median; a two-fly transition near 10 to 12 ms at the median in every execution m
`stalled` and `zero-progress`, still never acting. The GO OBJECTIVE / GO OUT ring at the gym `stalled` and `zero-progress`, still never acting. The GO OBJECTIVE / GO OUT ring at the gym
door is row 58. Ethos check held. While it cooked the operator's new unstick rule kept the door is row 58. Ethos check held. While it cooked the operator's new unstick rule kept the
stream moving with flysim restarts (loop-review.md). stream moving with flysim restarts (loop-review.md).
- 2026-09-23 (v0.5.5, loop review, auto): row 58, the Pewter Gym door. GO OBJECTIVE walked in,
GO OUT walked out, for hours, with no reward; restarts bought twenty to forty minutes each.
Mechanism: the objective's people were read from the sprites the cartridge draws, and from the
doormat Brock and the Jr. Trainer are off screen, so with the guide talked the rung had nobody
and the gym dealt its ways out. Three moments the game holds the joypad (a warp's first frames,
a trainer's challenge before the battle screen, a trainer walking up) wrote the fly's ledgers.
Fix: the objective reads off-screen people by the cartridge's own availability rule; facing any
of them is arrival; a warp's tear deals an empty pad (at most 90 frames); `controllable` reads
`wCurOpponent`; ledger writes wait for the joypad to come back and drop if a battle starts.
ROM test: base 40 arrivals, 38 straight back out; branch reaches Brock. Route survey from the
live checkpoint, 33 brain minutes: GO OUT 1,118 -> 0, rung 10 -> 11, BOULDER BADGE. The trap
hunt's stub readout never walks the ring on either arm (flagged 17 -> 23, all one-tile windows
in the gym's dialogs and battles; tiles 329 -> 437), so the proof is the ROM test and the
survey: the ethos check held in spirit, recorded as a deviation from the hunt's letter. Check
10 gains `unrewarded` (100+ decisions, no reward, no new ground, two probes), still never
acting. Also shipped: EDGE-01, the feed over flybus behind FLY_FEED_VIA, off (`direct`)
everywhere. Next: row 59, Route 3's neighbours and Mt. Moon's doors in the geography table.
- 2026-09-23 (the operator's reset): the live run restarted from milestone 1, the bedroom, with the
brain as it was then (65 brain seconds), to watch the macros in the early game. The v5 archive
migrated to v6. It reached rung 9, Viridian Forest, 31 minutes later.
- 2026-09-23 (v0.6.0, the operator's decision, plus loop review row 60): the fly is paid for
engaging inside buildings and not for leaving them. New catalog kinds `talk` (+0.10, the first
conversation the fly opens with each person or sign on an indoor map, once per key for the run)
and `item` (+0.15, each item ball or hidden item, once); `boundary` pays nothing for an indoor
exit. Indoor is the cartridge's own tables (not CheckIfInOutsideMap's outdoor tilesets and not a
bike tileset). Both publish on `explore`; the ticker still reads "new place" (the operator kept
the wording). Adapter `pokered-unique8-v7`; a deploy with FLY_ACCEPT_ADAPTERS=pokered-unique8-v6
migrates the run and seeds every item the game already shows taken without paying it. Row 60:
from the fresh run, TAIL WHIP (MOVE 2) was dealt beside TACKLE after the cartridge had begun
refusing it ("Nothing happened!": the stage at -6 or the stat at 1), and the brain's MOVE 2
preference drew battles out until Squirtle fainted. A move the cartridge would refuse -- read
from its move table and the target's stages, status, types, Mist and substitute -- is not dealt
beside one that works, as a move out of PP is not. ROM test: 2,657 of 2,657 own-turn frames dealt
the refused move before, 0 of 568 after; battles won 0 -> 3 of 12 -> 14. The real-brain hunt did
not finish at this load; recorded as a deviation. Ethos check held.
- 2026-09-23 (v0.6.1, loop review, auto): row 59, Mt. Moon. With the Boulder Badge the live fly
reached rung 12 and rang on Route 4 (GO ROUTE 233, GO OBJECTIVE 122, GO OUT 111 per ten minutes,
one new tile), and restarts did not hold: the map graph was wrong, not a ledger. The geography
table is now the cartridge's own headers and warps: Route 3 connects north to Route 4, Mt.
Moon's doors are on Route 4 (1F at (18,5), B1F's exit at (24,5)); Routes 14/15 and 24/25 were
in the wrong compass columns and Routes 22/23 were missing; a map can be several pieces (Route
4 two, Mt. Moon B1F four chambers, B2F three), the fly's piece read from the decoded grid and a
warp landing in the piece that holds its destination; sea and fence connections (Pallet/21,
Cinnabar/20, 20/19, 22/23) are no longer exits. A trainer's challenge leaves five frames of
plain overworld before the battle, and a push-back is now written only after thirty frames of
the fly's own. From the live Route 4 checkpoint: crossings of the west doors 56 -> 13, into Mt.
Moon at frame 278; from the badge checkpoint the fly reaches Mt. Moon and rung 12 where the base
rang at Pewter. The reviewer checked all 36 connection rows and every piece against the
disassembly. The trap hunt was not run at this load (recorded deviation). Also shipped: FND-01,
one LegacyFrame for the service and every harness (no live behaviour change), FLY_TRACE and the
sugar journal. Ethos check held.
- 2026-09-24 (v0.6.2, loop review, auto): row 61, the Viridian Forest corridor. In the fresh run the
fly rang between the forest, its south gate and Route 2 (GO OBJECTIVE / GO OUT / GO ROUTE) for
twenty minutes at rung 9. A Bug Catcher stands on the forest's only corridor north; when he saw
the fly, the frames of his "!" bubble and the five frames between his text and the battle read as
the fly's own overworld, and a held push-back walled (1,18) for good, so GO OBJECTIVE had no road
north. The macros now read the cartridge's BIT_TRAINER_BATTLE (wStatusFlags7 bit 3, set at the
"!", cleared after every battle, a lost one too) and treat those frames as the cartridge's: no
pad, no ground recorded, no push decided. Row 59's 30-frame debounce already closed the five-frame
gap; this is the cartridge-fact layer under it. ROM test: base walls (1,18) and stays at rung 9;
branch reaches Pewter on frame 23,755, and offers no button on any of the 67 challenge frames
(67 of 67 before). Survey seed 7: rung 9 -> BOULDER BADGE. The trap hunt's stub cannot see this
trap; the ROM test and survey are the proof (recorded deviation). Ethos check held.
- 2026-09-28 (v0.6.4, infra + stage, auto): automatic loop recovery that climbs. The v0.6.3 timer
only restarted flysim, once an hour; a trap that outlives a restore (Mt. Moon, rung 12, GO
OBJECTIVE / GO WARP) was restarted 48 times in two days. `fly-loop-recover` now climbs a ladder
per confirmed trap that outlives the last step: restart flysim, reset to the current rung's
milestone, reset to the rung below the best (never lower); two resets a day, restarts three
hours apart once they are spent; it starts over at a new best rung or after six quiet hours, and
its state survives a reboot. A router model list confirms each step and can delay it three
probes, never deny it. Resets go through one root wrapper (`fly-loop-reset`) that runs only a
root-owned flysim identical to the running one, uses only archives this build can restore,
and pauses the watchdog for the reset. Every step is announced 60 s ahead on stream: the
recovery splash counts down over the stuck loop, covers the game while it acts, and reports the
result (`apps/stage/mockups/`). Independent review: BLOCK, then fixed; round 2 approved with
the watchdog wait fixed.

View file

@ -112,6 +112,11 @@ fi
require_pve_host require_pve_host
need pct need pct
# Who serves the feed (docs/design/flybus.md): refused here, before section 1 flips
# /opt/fly/current, rather than half way through the deploy or at flysim's boot.
FLY_FEED_VIA_EFFECTIVE="$(feed_via_normalize "${FLY_FEED_VIA:-}")" \
|| die "05-deploy: FLY_FEED_VIA must be 'direct' or 'bus', got '${FLY_FEED_VIA}'"
# CHROMIUM_PROFILE is validated here, not left to the launcher: a typo or a # CHROMIUM_PROFILE is validated here, not left to the launcher: a typo or a
# `vgl` on a container that never had VirtualGL installed would only show up as # `vgl` on a container that never had VirtualGL installed would only show up as
# flystage refusing to start, i.e. a black stream, minutes after the deploy # flystage refusing to start, i.e. a black stream, minutes after the deploy
@ -267,6 +272,21 @@ if [ -n "$RELEASE_TARBALL" ]; then
cpu_pin chown -R fly:fly "$release_path" cpu_pin chown -R fly:fly "$release_path"
fi fi
# A root-owned flysim for fly-loop-reset, which runs as root and so must never execute the
# fly-owned copy above (the fly account could replace it). Taken from the tarball on the host
# and checked against the tarball's own MANIFEST on every deploy, so a release installed by
# an earlier deploy is covered too.
log "05-deploy: installing a root-owned flysim at /opt/fly/sbin/flysim for fly-loop-reset"
root_flysim="$(mktemp)"
tar -xzOf "$RELEASE_TARBALL" ./flysim > "$root_flysim"
want_sha="$(tar -xzOf "$RELEASE_TARBALL" ./MANIFEST | awk '$2 == "flysim" || $2 == "./flysim" {print $1; exit}')"
[ -n "$want_sha" ] && [ "$(sha256sum "$root_flysim" | cut -d' ' -f1)" = "$want_sha" ] \
|| { rm -f "$root_flysim"; die "the tarball's flysim does not match its MANIFEST; not installing /opt/fly/sbin/flysim"; }
ct_exec "$CTID" -- install -d -o root -g root -m 0755 /opt/fly/sbin
ct_push_file "$CTID" "$root_flysim" /opt/fly/sbin/flysim.new 0755
ct_exec "$CTID" -- sh -c 'chown root:root /opt/fly/sbin/flysim.new && mv -f /opt/fly/sbin/flysim.new /opt/fly/sbin/flysim'
rm -f "$root_flysim"
log "05-deploy: overlaying infra-owned stage/serve.{mjs,sh} (docs/design/infra.md's 'own tiny static server' clarification)" log "05-deploy: overlaying infra-owned stage/serve.{mjs,sh} (docs/design/infra.md's 'own tiny static server' clarification)"
converge_file "$CTID" "$INFRA_DIR/config/serve.mjs" "${release_path}/stage/serve.mjs" 0644 fly:fly >/dev/null converge_file "$CTID" "$INFRA_DIR/config/serve.mjs" "${release_path}/stage/serve.mjs" 0644 fly:fly >/dev/null
converge_file "$CTID" "$INFRA_DIR/config/serve.sh" "${release_path}/stage/serve.sh" 0755 fly:fly >/dev/null converge_file "$CTID" "$INFRA_DIR/config/serve.sh" "${release_path}/stage/serve.sh" 0755 fly:fly >/dev/null
@ -295,8 +315,9 @@ if [ -n "$RELEASE_TARBALL" ]; then
# #
# FLY_ACCEPT_ADAPTERS is the *other* override, and the opposite one: it keeps # FLY_ACCEPT_ADAPTERS is the *other* override, and the opposite one: it keeps
# the run. It names adapter version strings whose checkpoints the new build # the run. It names adapter version strings whose checkpoints the new build
# may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 for the deploy # may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 for the deploy
# that adds the catch reward. It only applies when the adapter segment is the # that adds the engagement rewards (v7; v5 -> v6 was the catch reward's).
# It only applies when the adapter segment is the
# ONLY difference between the two strings and the new build's adapter says it # ONLY difference between the two strings and the new build's adapter says it
# can read that one; a dataset, kernel, emulator or state-format change is # can read that one; a dataset, kernel, emulator or state-format change is
# still a refusal, because none of those has a migration. The same variable is # still a refusal, because none of those has a migration. The same variable is
@ -349,7 +370,7 @@ The difference is usually an adapter/ladder or dataset version bump. Three ways
* deploy a build whose string matches (check out the commit the running release was built from), or * deploy a build whose string matches (check out the commit the running release was built from), or
* if the ADAPTER VERSION is the only segment that differs and the new build documents a * if the ADAPTER VERSION is the only segment that differs and the new build documents a
migration from the old one, re-run with FLY_ACCEPT_ADAPTERS set to the adapter id in the live migration from the old one, re-run with FLY_ACCEPT_ADAPTERS set to the adapter id in the live
string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5). The run is kept; flysim applies the same string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v6). The run is kept; flysim applies the same
rule at restore. See docs/design/flysim.md, \"Restoring across an adapter version\", or rule at restore. See docs/design/flysim.md, \"Restoring across an adapter version\", or
* accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which * accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which
archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and
@ -413,6 +434,7 @@ log "05-deploy: non-secret env files"
# never drift apart (see cpuset_partition's own header comment). They are # never drift apart (see cpuset_partition's own header comment). They are
# assigned in section 0b, which needs them earlier than this for the # assigned in section 0b, which needs them earlier than this for the
# deploy-time cpu pinning; nothing between here and there changes them. # deploy-time cpu pinning; nothing between here and there changes them.
tmp_fly_env="$(mktemp)" tmp_fly_env="$(mktemp)"
tmp_flypush_env="$(mktemp)" tmp_flypush_env="$(mktemp)"
trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT
@ -499,6 +521,16 @@ trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT
# "palette"/"plan" as "macros" with a warning, and refuses an unrecognised # "palette"/"plan" as "macros" with a warning, and refuses an unrecognised
# value outright. # value outright.
echo "FLY_MACRO_MODE=${FLY_MACRO_MODE:-raw}" echo "FLY_MACRO_MODE=${FLY_MACRO_MODE:-raw}"
# Who serves the feed WebSocket (docs/design/flybus.md, "Feed over the
# bus"). "direct" is the default and is flysim binding :7400 itself, as
# every release before this knob. "bus" makes flysim publish on its
# embedded feed bus and leave :7400 to flyedge.service, which this script
# never enables: see that unit's header for the switch. Written
# unconditionally, like FLY_MACRO_MODE, so one grep says which a box runs.
# Watchdog check 2 reads this line to know whose /metrics carries the
# feed counters (flysim's :9101, or flyedge's loopback :9102).
# Validated and lowercased above (feed_via_normalize).
echo "FLY_FEED_VIA=${FLY_FEED_VIA_EFFECTIVE}"
# How long a macro leaves a target alone after a walk to it aborted # How long a macro leaves a target alone after a walk to it aborted
# (macros.md section 12.1, the Viridian stall). Only written when it is set, # (macros.md section 12.1, the Viridian stall). Only written when it is set,
# because the default lives in the crate and a box that has not tuned it # because the default lives in the crate and a box that has not tuned it
@ -633,9 +665,11 @@ if [ -n "${CPUSET:-}" ]; then
"leaves cpuset.cpus.effective empty and the unit unstartable." "leaves cpuset.cpus.effective empty and the unit unstartable."
else else
read -r sim_cpus page_cpus encoder_cpus <<< "$(cpuset_partition "$CPUSET" "$RAYON_THREADS_EFFECTIVE" "$ENCODER_CORES_EFFECTIVE")" read -r sim_cpus page_cpus encoder_cpus <<< "$(cpuset_partition "$CPUSET" "$RAYON_THREADS_EFFECTIVE" "$ENCODER_CORES_EFFECTIVE")"
log "05-deploy: cpuset partition — flysim=$sim_cpus, xvfb/flystage/flystage-web/pulse/mediamtx=$page_cpus, flycast=$encoder_cpus" log "05-deploy: cpuset partition — flysim=$sim_cpus, xvfb/flystage/flystage-web/pulse/mediamtx/flyedge=$page_cpus, flycast=$encoder_cpus"
tmp_dropin="$(mktemp)" tmp_dropin="$(mktemp)"
for u in flysim xvfb flystage flystage-web flycast pulse mediamtx; do # flyedge is off by default, but its drop-in is written with the rest so that the day
# it is enabled it serves the page from the page's CPUs, never from flysim's.
for u in flysim xvfb flystage flystage-web flycast pulse mediamtx flyedge; do
case "$u" in case "$u" in
flysim) cpus="$sim_cpus" ;; flysim) cpus="$sim_cpus" ;;
flycast) cpus="$encoder_cpus" ;; flycast) cpus="$encoder_cpus" ;;
@ -666,10 +700,20 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "05-deploy: converging bin/ helpers to /opt/fly/bin" log "05-deploy: converging bin/ helpers to /opt/fly/bin"
ct_exec "$CTID" -- mkdir -p /opt/fly/bin ct_exec "$CTID" -- mkdir -p /opt/fly/bin
for name in fly-watchdog fly-recap fly-retention fly-reset-to-milestone flypush flystage-launch flycast-launch wait-for-x wait-for-stage wait-for-health; do for name in fly-watchdog fly-loop-recover fly-loop-reset fly-recap fly-retention fly-reset-to-milestone flypush flystage-launch flycast-launch wait-for-x wait-for-stage wait-for-health; do
converge_file "$CTID" "$INFRA_DIR/bin/$name" "/opt/fly/bin/$name" 0755 root:root >/dev/null converge_file "$CTID" "$INFRA_DIR/bin/$name" "/opt/fly/bin/$name" 0755 root:root >/dev/null
done done
# The NOPASSWD surface those helpers use (config/fly-sudoers). 02-base installs it at
# provision time; converging it here too means a release that adds a helper's line
# (fly-loop-reset, v0.6.4) does not need a re-provision. Validated before install.
log "05-deploy: converging config/fly-sudoers, validated before install"
TMP_SUDOERS="/tmp/fly-sudoers.$$"
ct_push_file "$CTID" "$INFRA_DIR/config/fly-sudoers" "$TMP_SUDOERS" 0440
ct_exec "$CTID" -- visudo -c -f "$TMP_SUDOERS" || die "config/fly-sudoers failed visudo -c, refusing to install it"
ct_exec "$CTID" -- install -o root -g root -m 0440 "$TMP_SUDOERS" /etc/sudoers.d/fly-watchdog
ct_exec "$CTID" -- rm -f "$TMP_SUDOERS"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5. reload if anything unit-shaped changed # 5. reload if anything unit-shaped changed
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -27,7 +27,7 @@ require_pve_host
need pct need pct
ALWAYS_ON_UNITS="xvfb.service pulse.service mediamtx.service flysim.service flystage-web.service flystage.service flycast.service flybridge.service" ALWAYS_ON_UNITS="xvfb.service pulse.service mediamtx.service flysim.service flystage-web.service flystage.service flycast.service flybridge.service"
ALWAYS_ON_TIMERS="fly-recap.timer fly-retention.timer fly-watchdog.timer" ALWAYS_ON_TIMERS="fly-recap.timer fly-retention.timer fly-watchdog.timer fly-loop-recover.timer"
log "07-enable: enabling app units (not yet starting)" log "07-enable: enabling app units (not yet starting)"
for u in $ALWAYS_ON_UNITS; do for u in $ALWAYS_ON_UNITS; do

301
infra/bin/fly-loop-recover Executable file
View file

@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""Out-of-process loop recovery with an escalation ladder; invoked after the watchdog probe.
Ladder (infra/docs/loop-recovery.md): restart flysim, then reset to the current rung's
milestone, then to the rung below. It climbs one step per confirmed trap that outlives
the previous step, and starts over once the fly reaches a new best rung or has been
quiet for QUIET seconds. A router model can delay a step, never deny it.
"""
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import time
from urllib.request import Request, urlopen
REPORT = Path(os.environ.get("FLY_LOOP_REPORT", "/run/fly/wd/loop.json"))
STATE = Path(os.environ.get("FLY_LOOP_RECOVERY_STATE", "/var/lib/fly-loop-recover/state.json"))
HISTORY = Path(os.environ.get("FLY_LOOP_RECOVERY_HISTORY", "/var/lib/fly-loop-recover/history.jsonl"))
NOTICE = Path(os.environ.get("FLY_RECOVERY_NOTICE", "/run/fly/wd/recovery-notice.json"))
MILESTONES = Path(os.environ.get("FLY_STATE_DIR", "/srv/fly/state"))
STATUS_URL = os.environ.get("FLY_STATUS_URL", "http://127.0.0.1:7401/status")
RESET_BIN = os.environ.get("FLY_LOOP_RESET_BIN", "/opt/fly/bin/fly-loop-reset")
INTERVAL = int(os.environ.get("WD_LOOP_INTERVAL", "300"))
COUNTDOWN = int(os.environ.get("FLY_LOOP_COUNTDOWN", "60"))
SETTLE = 1200 # a report inside this after an action may still hold the old window
QUIET = 6 * 3600 # this long without a suspected report starts the ladder over
HOLD = 3 * 3600 # restart-only spacing while the reset budget is spent
DAY = 24 * 3600
MAX_RESETS = 2 # milestone resets per DAY
VETO_LIMIT = 3 # model vetoes of one step before it goes ahead anyway
MODEL_BUDGET = 90 # seconds across all models in one decision
VERIFY_TIMEOUT = 240
LIST_TIMEOUT = 120
RESTART_TIMEOUT = 180
RESET_TIMEOUT = 480 # the wrapper waits up to 60 s for the watchdog, then stop, reset, start
# docs/design/ladder.md; mirrors RANK_LADDER in flybrain-gb's pokemon_red/mod.rs (a test pins it).
LADDER = [
"BOOT", "BEDROOM", "DOWNSTAIRS", "PALLET TOWN", "OAK'S LAB", "GOT A STARTER", "OAK'S PARCEL",
"POKEDEX", "VIRIDIAN CITY", "VIRIDIAN FOREST", "PEWTER CITY", "BOULDER BADGE", "MT. MOON",
"CERULEAN CITY", "CASCADE BADGE", "NUGGET BRIDGE", "MET BILL", "VERMILION CITY", "HM CUT",
"THUNDER BADGE", "ROCK TUNNEL", "LAVENDER TOWN", "CELADON CITY", "SILPH SCOPE", "RAINBOW BADGE",
"POKE FLUTE", "FUCHSIA CITY", "SOUL BADGE", "SILPH CO. FREED", "MARSH BADGE", "CINNABAR ISLAND",
"VOLCANO BADGE", "EARTH BADGE", "INDIGO PLATEAU", "BEAT LORELEI", "BEAT BRUNO", "BEAT AGATHA",
"CHAMPION",
]
PROMPT = (
"You check a Game Boy Pokemon Red run played by a simulated fly brain through macros "
"(GO OBJECTIVE, GO WARP, GO ROUTE, FIGHT, ...). A watchdog flagged a suspected loop over "
"a 10-minute brain window. Stuck means the same few macros repeat with no new places "
"(places.delta 0) and no rewards; healthy play finds new places, wins battles or earns "
"rewards. Answer with only the JSON object {\"stuck\": true} or {\"stuck\": false}. "
"No explanation, no reasoning, no other text."
)
def load(path, default):
try:
return json.loads(path.read_text())
except (OSError, ValueError):
return default
def write_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(value) + "\n")
os.chmod(tmp, 0o644)
tmp.replace(path)
def log(event):
try:
HISTORY.parent.mkdir(parents=True, exist_ok=True)
with HISTORY.open("a") as out:
out.write(json.dumps(event) + "\n")
except OSError as error:
print("history not written:", error, file=sys.stderr)
def models():
names = os.environ.get("FLY_LOOP_MODELS") or os.environ.get("FLY_LOOP_MODEL") or ""
return [name.strip() for name in names.split(",") if name.strip()]
def parse_stuck(content):
"""The stuck flag from a free model's reply, which may wrap the JSON in prose or fences."""
for match in re.finditer(r"\{[^{}]*\}", content or ""):
try:
decision = json.loads(match.group(0))
except ValueError:
continue
if type(decision) is dict and type(decision.get("stuck")) is bool:
return decision["stuck"]
return None
def verdict(report):
"""True/False from the first model that answers, None when no router or none answered."""
url = os.environ.get("FLY_LOOP_ROUTER_URL")
names = models()
if not url or not names:
return None, None
brief = {key: report.get(key) for key in ("reason", "sequence", "window", "places", "milestone", "dominant", "map")}
headers = {"Content-Type": "application/json"}
key = os.environ.get("FLY_LOOP_ROUTER_KEY")
if key:
headers["Authorization"] = "Bearer " + key
deadline = time.monotonic() + MODEL_BUDGET
for name in names:
left = deadline - time.monotonic()
if left < 5:
break
payload = {"model": name, "temperature": 0, "max_tokens": 400, "messages": [
{"role": "system", "content": PROMPT},
{"role": "user", "content": json.dumps(brief)},
]}
request = Request(url.rstrip("/") + "/chat/completions", json.dumps(payload).encode(), headers)
try:
with urlopen(request, timeout=min(30, left)) as response:
answer = json.load(response)
stuck = parse_stuck(answer["choices"][0]["message"]["content"])
except (OSError, ValueError, KeyError, IndexError, TypeError) as error:
print(f"model {name}: {type(error).__name__} {getattr(error, 'code', '')}".rstrip(), file=sys.stderr)
continue
if stuck is None:
print(f"model {name}: no stuck flag in reply", file=sys.stderr)
continue
return stuck, name
return None, None
def rungs():
found = []
for path in MILESTONES.glob("milestone-*.checkpoint"):
number = path.stem.removeprefix("milestone-")
if number.isdigit():
found.append(int(number))
return sorted(found)
def plan(level, rank, best, resets_left):
"""(action, target rung) for a ladder level; a spent budget or no restorable archive is a restart.
Level 1 resets to the current rung's archive; level 2 and beyond to the rung below the best,
and never lower, so repeated days of a trap cannot walk the run down the ladder.
"""
if level == 0 or not resets_left or rank is None:
return "restart", None
target = rank if level == 1 else max(best, rank) - 1
if target > rank or target not in restorable_rungs():
return "restart", None
return "reset", target
def restorable_rungs():
"""The rungs the running build can restore (fly-loop-reset --list), empty on any failure."""
try:
done = subprocess.run(["sudo", "-n", RESET_BIN, "--list"], check=False, timeout=LIST_TIMEOUT,
capture_output=True, text=True)
except (OSError, subprocess.SubprocessError) as error:
print("restorable rungs unknown:", type(error).__name__, file=sys.stderr)
return set()
if done.returncode != 0:
return set()
return {int(line) for line in done.stdout.split() if line.isdigit()}
def status():
try:
with urlopen(STATUS_URL, timeout=5) as response:
return json.load(response)
except (OSError, ValueError):
return None
def verify(target, deadline):
while time.monotonic() < deadline:
now = status()
if now and now.get("status") == "running":
rank = (now.get("milestone") or {}).get("rank")
if target is None or rank == target:
return True
time.sleep(5)
return False
def act(action, target):
if action == "restart":
command, limit = ["sudo", "-n", "systemctl", "restart", "flysim.service"], RESTART_TIMEOUT
else:
command, limit = ["sudo", "-n", RESET_BIN, str(target)], RESET_TIMEOUT
try:
done = subprocess.run(command, check=False, timeout=limit)
except (OSError, subprocess.SubprocessError) as error:
print(f"{action} did not complete: {type(error).__name__}", file=sys.stderr)
return False
if done.returncode != 0:
return False
return verify(target, time.monotonic() + VERIFY_TIMEOUT)
def notice(base, phase, now):
try:
write_json(NOTICE, dict(base, phase=phase, updatedAt=int(now)))
except OSError as error:
print("notice not written:", error, file=sys.stderr)
def run(now=None, sleep=time.sleep, clock=None):
clock = clock or (time.time if now is None else (lambda: now))
now = int(clock())
report = load(REPORT, None)
if type(report) is not dict:
return "no watchdog report"
state = load(STATE, {})
if type(state) is not dict:
state = {}
rank = (report.get("milestone") or {}).get("rank")
level = state.get("level", 0)
if type(rank) is int and rank > state.get("bestRank", 0):
if level:
log({"at": now, "event": "ladder-reset", "why": "new best rung", "rank": rank})
state.update(bestRank=rank, level=0)
level = 0
last = state.get("lastSuspectedAt")
if level and last and now - last > QUIET:
log({"at": now, "event": "ladder-reset", "why": "quiet", "rank": rank})
state["level"] = level = 0
state["resets"] = [at for at in state.get("resets", []) if now - at < DAY]
at = report.get("at")
if (report.get("suspected") != 1 or type(at) is not int
or not 0 <= now - at <= 600 or report.get("action") != "none"):
state.pop("observedAt", None)
state["vetoes"] = 0
write_json(STATE, state)
return "not a fresh suspected loop"
state["lastSuspectedAt"] = now
acted = state.get("actedAt", 0)
if at < acted + SETTLE:
write_json(STATE, state)
return f"settling after {state.get('lastAction')}"
observed = state.get("observedAt")
if not observed or observed < acted + SETTLE:
state["observedAt"] = at
write_json(STATE, state)
return "waiting for second probe"
if at - observed < INTERVAL - 30:
write_json(STATE, state)
return "waiting for next probe"
resets_left = MAX_RESETS - len(state["resets"])
action, target = plan(level, rank, state.get("bestRank", 0), resets_left)
if action == "restart" and level and now - acted < HOLD:
write_json(STATE, state)
return "holding: reset budget spent, next restart after the hold"
stuck, model = verdict(report)
if stuck is False and state.get("vetoes", 0) < VETO_LIMIT:
state["vetoes"] = state.get("vetoes", 0) + 1
write_json(STATE, state)
log({"at": now, "event": "veto", "model": model, "vetoes": state["vetoes"], "rank": rank})
return f"model {model} says not stuck ({state['vetoes']}/{VETO_LIMIT})"
why = {True: f"confirmed by {model}", False: "veto limit reached", None: "no model answer, watchdog alone"}[stuck]
base = {"v": 1, "id": f"{now}-{action}", "action": action,
"fromRung": rank, "fromLabel": (report.get("milestone") or {}).get("label"),
"reason": report.get("reason"), "loop": (report.get("sequence") or [])[:4],
"stuckSeconds": max(0, now - observed) + 600,
"announcedAt": now, "executeAt": now + COUNTDOWN}
if action == "reset":
base.update(toRung=target, toLabel=LADDER[target] if 0 <= target < len(LADDER) else None)
notice(base, "countdown", now)
sleep(COUNTDOWN)
started = int(clock())
# Recorded before acting, so a helper killed mid-step has still climbed and spent the reset.
state.update(level=level + 1, actedAt=started, lastAction=action, vetoes=0)
state.pop("observedAt", None)
if action == "reset":
state["resets"].append(started)
write_json(STATE, state)
notice(base, "acting", started)
ok = act(action, target)
finished = int(clock())
notice(base, "done" if ok else "failed", finished)
state["actedAt"] = finished
write_json(STATE, state)
log({"at": finished, "event": action, "target": target, "ok": ok, "why": why, "rank": rank,
"level": level, "sequence": report.get("sequence"), "map": report.get("map")})
outcome = "flysim restarted" if action == "restart" else f"reset to rung {target}"
return f"{outcome} ({why})" if ok else f"{action} FAILED ({why})"
if __name__ == "__main__":
print(run())

172
infra/bin/fly-loop-reset Executable file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env bash
# infra/bin/fly-loop-reset — the milestone step of fly-loop-recover's ladder, as root.
#
# fly-loop-recover runs as User=fly and reaches this through one NOPASSWD sudoers line
# (config/fly-sudoers). It does what infra/docs/runbook.md "Restart the run from a rung"
# does by hand: stop flysim, promote milestone-<N>.checkpoint with fly-reset-to-milestone,
# start flysim.
#
# Root never executes anything the fly account can write. The release tree under
# /opt/fly/releases is fly-owned, so the flysim run here is the root-owned copy 05-deploy
# installs at /opt/fly/sbin/flysim straight from the release tarball; without it every rung is
# refused and the ladder falls back to restarts. Paths are fixed (test overrides are honoured
# only when not root), and /etc/fly/fly.env is read as KEY=VALUE data, never sourced.
#
# fly-reset-to-milestone does not check that the build can restore the archive, and a flysim
# that refuses every checkpoint refuses to start: a black stream. So a rung is only used when
# its archive's compatibility string equals this build's, or differs only in an adapter id that
# FLY_ACCEPT_ADAPTERS names (05-deploy.sh's adapter_migration_accepted).
#
# fly-watchdog restarts a flysim whose /healthz fails, which during a reset would start it on a
# half-rewritten store; its timer is stopped for the reset and started again on every exit path.
#
# Usage: fly-loop-reset <rung> reset to that rung
# fly-loop-reset --list print the rungs this build can restore, one per line
set -euo pipefail
STATE_DIR=/srv/fly/state
ENV_FILE=/etc/fly/fly.env
FLYSIM=/opt/fly/sbin/flysim
RESET_BIN=/opt/fly/bin/fly-reset-to-milestone
if [ "$(id -u)" -ne 0 ]; then
STATE_DIR="${FLY_LOOP_RESET_TEST_STATE_DIR:-$STATE_DIR}"
ENV_FILE="${FLY_LOOP_RESET_TEST_ENV_FILE:-$ENV_FILE}"
FLYSIM="${FLY_LOOP_RESET_TEST_FLYSIM:-$FLYSIM}"
RESET_BIN="${FLY_LOOP_RESET_TEST_RESET_BIN:-$RESET_BIN}"
fi
SERVICE=flysim.service
WATCHDOG_TIMER=fly-watchdog.timer
WATCHDOG_SERVICE=fly-watchdog.service
readonly STATE_DIR ENV_FILE FLYSIM RESET_BIN SERVICE WATCHDOG_TIMER WATCHDOG_SERVICE
log() { echo "fly-loop-reset: $*" >&2; }
usage() { log "usage: fly-loop-reset <rung> | --list"; exit 2; }
LIST=0
RANK=""
[ "$#" -eq 1 ] || usage
if [ "$1" = --list ]; then
LIST=1
elif [[ "$1" =~ ^[0-9]{1,2}$ ]]; then
RANK="$1"
else
usage
fi
# FLY_* lines of fly.env as `env` arguments. Values may be quoted the systemd way; nothing
# is evaluated.
env_args=()
accepted=""
if [ -r "$ENV_FILE" ]; then
while IFS= read -r line || [ -n "$line" ]; do
[[ "$line" =~ ^(FLY_[A-Z0-9_]*)=(.*)$ ]] || continue
key="${BASH_REMATCH[1]}"
value="${BASH_REMATCH[2]}"
if [[ "$value" =~ ^\"(.*)\"$ ]] || [[ "$value" =~ ^\'(.*)\'$ ]]; then
value="${BASH_REMATCH[1]}"
fi
env_args+=("$key=$value")
if [ "$key" = FLY_ACCEPT_ADAPTERS ]; then
accepted="$value"
fi
done < "$ENV_FILE"
fi
clean_env() { env -i PATH=/usr/sbin:/usr/bin:/sbin:/bin HOME=/root "${env_args[@]}" "$@"; }
# The same rule as 05-deploy.sh: exactly one '/'-separated field differs, it is the adapter
# (index 1), and the archive's adapter id is listed.
migration_accepted() {
local old="$1" new="$2" i differing=0 index=-1 entry
local -a old_parts new_parts
IFS='/' read -r -a old_parts <<< "$old"
IFS='/' read -r -a new_parts <<< "$new"
[ "${#old_parts[@]}" -eq "${#new_parts[@]}" ] || return 1
for ((i = 0; i < ${#old_parts[@]}; i++)); do
if [ "${old_parts[$i]}" != "${new_parts[$i]}" ]; then
differing=$((differing + 1))
index=$i
fi
done
[ "$differing" -eq 1 ] && [ "$index" -eq 1 ] || return 1
for entry in ${accepted//,/ }; do
[ "$entry" = "${old_parts[1]}" ] && return 0
done
return 1
}
# The root copy must be the flysim that runs: a manual rollback of /opt/fly/current without a
# deploy would otherwise approve archives the running build refuses.
LIVE_FLYSIM=/opt/fly/current/flysim
[ "$(id -u)" -eq 0 ] || LIVE_FLYSIM="${FLY_LOOP_RESET_TEST_LIVE_FLYSIM:-$FLYSIM}"
same_build() {
[ -f "$LIVE_FLYSIM" ] \
&& [ "$(sha256sum < "$FLYSIM" | cut -d' ' -f1)" = "$(sha256sum < "$LIVE_FLYSIM" | cut -d' ' -f1)" ]
}
build_compat=""
if [ -x "$FLYSIM" ] && [ "$(stat -c %u "$FLYSIM")" = "$(id -u)" ] && same_build; then
build_compat="$(clean_env timeout 90 "$FLYSIM" --print-compatibility 2>/dev/null | tail -n1 || true)"
fi
if [ -z "$build_compat" ]; then
log "no compatibility string from $FLYSIM (missing, not owned by $(id -un), not the running build, or failed); no rung is restorable"
[ "$LIST" -eq 1 ] && exit 0
exit 3
fi
restorable() {
local archive="${STATE_DIR}/milestone-$1.checkpoint" compat
[ -f "$archive" ] || return 1
compat="$(head -c 262144 "$archive" 2>/dev/null | grep -a -o -m1 '"compatibility"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n1 | cut -d'"' -f4 || true)"
[ -n "$compat" ] || return 1
[ "$compat" = "$build_compat" ] || migration_accepted "$compat" "$build_compat"
}
if [ "$LIST" -eq 1 ]; then
for archive in "${STATE_DIR}"/milestone-*.checkpoint; do
[ -e "$archive" ] || continue
rung="${archive##*/milestone-}"
rung="${rung%.checkpoint}"
if [[ "$rung" =~ ^[0-9]{1,2}$ ]] && restorable "$rung"; then
echo "$rung"
fi
done | sort -n
exit 0
fi
if ! restorable "$RANK"; then
log "rung ${RANK}'s archive is missing or not restorable by this build (FLY_ACCEPT_ADAPTERS='${accepted}'); nothing touched"
exit 3
fi
# shellcheck disable=SC2317 # invoked by the EXIT trap
finish() {
local status=$?
systemctl start "$SERVICE" || log "starting ${SERVICE} failed"
systemctl start "$WATCHDOG_TIMER" || log "starting ${WATCHDOG_TIMER} failed"
exit "$status"
}
trap finish EXIT
trap 'exit 143' TERM INT HUP
log "pausing ${WATCHDOG_TIMER} and stopping ${SERVICE} to reset to rung ${RANK}"
systemctl stop "$WATCHDOG_TIMER"
# A oneshot probe mid-run is "activating", which `is-active` does not count; wait on ActiveState.
watchdog_idle() {
case "$(systemctl show -p ActiveState --value "$WATCHDOG_SERVICE")" in
inactive|failed) return 0 ;;
*) return 1 ;;
esac
}
for _ in $(seq 1 60); do
watchdog_idle && break
sleep 1
done
if ! watchdog_idle; then
log "${WATCHDOG_SERVICE} still running after 60 s; not resetting"
exit 4
fi
systemctl stop "$SERVICE"
status=0
clean_env FLY_BIN="$FLYSIM" "$RESET_BIN" "$RANK" || status=$?
[ "$status" -eq 0 ] || log "fly-reset-to-milestone ${RANK} failed (exit ${status}); starting ${SERVICE} on the state it left"
exit "$status"

View file

@ -32,6 +32,9 @@ log_info() {
: "${FLY_CONTROL_URL:=http://127.0.0.1:7401}" : "${FLY_CONTROL_URL:=http://127.0.0.1:7401}"
: "${FLY_METRICS_URL:=http://127.0.0.1:9101}" : "${FLY_METRICS_URL:=http://127.0.0.1:9101}"
# flyedge's loopback /metrics (units/flyedge.service), read by check 2 when
# fly.env says FLY_FEED_VIA=bus.
: "${FLY_EDGE_METRICS_URL:=http://127.0.0.1:9102}"
: "${FLY_STATE_HOT:=/run/fly/state}" : "${FLY_STATE_HOT:=/run/fly/state}"
: "${FLY_MEDIA_DIR:=/srv/fly/media}" : "${FLY_MEDIA_DIR:=/srv/fly/media}"
: "${MEDIAMTX_API:=http://127.0.0.1:9997}" : "${MEDIAMTX_API:=http://127.0.0.1:9997}"
@ -68,6 +71,7 @@ log_info() {
: "${WD_LOOP_DOMINANCE_PCT:=95}" # or one macro being this share of the window : "${WD_LOOP_DOMINANCE_PCT:=95}" # or one macro being this share of the window
: "${WD_LOOP_MIN_EVENTS:=20}" # floor under the dominance rule (see check 10) : "${WD_LOOP_MIN_EVENTS:=20}" # floor under the dominance rule (see check 10)
: "${WD_LOOP_STALL_PCT:=90}" # decisions that ended refused/blocked/timeout: this share is a stall (row 57) : "${WD_LOOP_STALL_PCT:=90}" # decisions that ended refused/blocked/timeout: this share is a stall (row 57)
: "${WD_LOOP_BUSY_MIN:=100}" # this many decisions with no reward and no new ground, two probes running, is busy going nowhere (row 58)
: "${WD_LOOP_REPORT:=${WD_RUN_DIR}/loop.json}" : "${WD_LOOP_REPORT:=${WD_RUN_DIR}/loop.json}"
mkdir -p "$WD_RUN_DIR" "$WD_STATE_DIR" mkdir -p "$WD_RUN_DIR" "$WD_STATE_DIR"
@ -212,6 +216,11 @@ write_textfile_metrics() {
echo "# HELP fly_loop_done Macros that ended done in the last check-10 window (-1 before the first probe)." echo "# HELP fly_loop_done Macros that ended done in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_done gauge" echo "# TYPE fly_loop_done gauge"
echo "fly_loop_done $(cat "${WD_RUN_DIR}/loop.done" 2>/dev/null || echo -1)" echo "fly_loop_done $(cat "${WD_RUN_DIR}/loop.done" 2>/dev/null || echo -1)"
# Row 58: an undo pair diluted by other macros completes everything and
# earns nothing; the reward events in the window are what say so.
echo "# HELP fly_loop_rewards Reward events in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_rewards gauge"
echo "fly_loop_rewards $(cat "${WD_RUN_DIR}/loop.rewards" 2>/dev/null || echo -1)"
echo "# HELP fly_places_delta Growth in game.uniqueLocations (the exploration count) since the previous check-10 probe. -1 when there is no previous probe to compare against." echo "# HELP fly_places_delta Growth in game.uniqueLocations (the exploration count) since the previous check-10 probe. -1 when there is no previous probe to compare against."
echo "# TYPE fly_places_delta gauge" echo "# TYPE fly_places_delta gauge"
echo "fly_places_delta $(cat "${WD_RUN_DIR}/loop.places_delta" 2>/dev/null || echo -1)" echo "fly_places_delta $(cat "${WD_RUN_DIR}/loop.places_delta" 2>/dev/null || echo -1)"
@ -335,10 +344,32 @@ check_flysim() {
# read-only metrics listener (infra.md section 5; not superseded by the # read-only metrics listener (infra.md section 5; not superseded by the
# feed/control contracts). Flat frames counter across two passes, or zero # feed/control contracts). Flat frames counter across two passes, or zero
# clients, means the page is dead/frozen even though Chromium is alive. # clients, means the page is dead/frozen even though Chromium is alive.
#
# The two counters belong to whoever serves the feed: flysim itself, or with
# FLY_FEED_VIA=bus in fly.env, flyedge (docs/design/flybus.md, "Feed over the
# bus"), which exports them under the same names. FLY_FEED_METRICS_URL in the
# watchdog's own environment overrides both.
# ============================================================================ # ============================================================================
feed_metrics_url() {
if [ -n "${FLY_FEED_METRICS_URL:-}" ]; then
echo "$FLY_FEED_METRICS_URL"
return
fi
local via=""
if [ -f "$FLY_ENV_FILE" ]; then
# Lowercased: flysim reads the value case-insensitively, so `Bus` is bus mode.
via="$(awk -F= '/^FLY_FEED_VIA=/ { print $2; exit }' "$FLY_ENV_FILE" 2>/dev/null | tr -d ' \r"' | tr '[:upper:]' '[:lower:]' || true)"
fi
if [ "$via" = "bus" ]; then
echo "$FLY_EDGE_METRICS_URL"
else
echo "$FLY_METRICS_URL"
fi
}
check_flystage() { check_flystage() {
local metrics frames clients ok=1 local metrics frames clients ok=1
metrics="$(curl -fsS "${FLY_METRICS_URL}/metrics" 2>/dev/null || true)" metrics="$(curl -fsS "$(feed_metrics_url)/metrics" 2>/dev/null || true)"
if [ -z "$metrics" ]; then if [ -z "$metrics" ]; then
ok=0 ok=0
else else
@ -766,6 +797,16 @@ check_capture_freeze() {
# this probe AND the previous one (two probes, so a single # this probe AND the previous one (two probes, so a single
# unlucky window never flags). # unlucky window never flags).
# #
# Row 58: `GO OBJECTIVE` into the Pewter Gym, `GO OUT` straight back out, for
# 25 minutes, with GO ITEM / GO FRONTIER / YES / NO mixed in — ten distinct
# names, every macro `done`, so neither the sequence rule (four names at
# most) nor zero-progress could fire, and the exploration count sat still.
# What the window did not have was a single reward event. So the stream also
# carries the `reward` events, and one more rule reads them, behind the same
# gate:
# unrewarded — WD_LOOP_BUSY_MIN+ decisions and no reward event in the
# window, on this probe AND the previous one.
#
# The tile rule is what separates a loop from a legitimately repeating # The tile rule is what separates a loop from a legitimately repeating
# explorer (macros-traps.md: `GO FRONTIER` x19 over 93 tiles is a walk longer # explorer (macros-traps.md: `GO FRONTIER` x19 over 93 tiles is a walk longer
# than the frame cap, not a trap), and the watchdog has no tile counter. The # than the frame cap, not a trap), and the watchdog has no tile counter. The
@ -816,7 +857,7 @@ loop_status_fields() {
loop_macro_stream() { loop_macro_stream() {
[ -f "$FLY_EVENT_LOG" ] || return 0 [ -f "$FLY_EVENT_LOG" ] || return 0
tail -n "$WD_LOOP_TAIL_LINES" "$FLY_EVENT_LOG" 2>/dev/null \ tail -n "$WD_LOOP_TAIL_LINES" "$FLY_EVENT_LOG" 2>/dev/null \
| jq -r 'fromjson? // empty | select(.kind == "macro") | "\(.brainMs)\t\(.label)"' -R 2>/dev/null \ | jq -r 'fromjson? // empty | select(.kind == "macro" or .kind == "reward") | "\(.brainMs)\t\(.label)\t\(.kind)"' -R 2>/dev/null \
|| true || true
} }
@ -824,23 +865,28 @@ loop_macro_stream() {
# #
# total US distinct US topCount US period US repeats US windowFrom US # total US distinct US topCount US period US repeats US windowFrom US
# windowTo US topName US block US tail US decisions US refused US blocked US # windowTo US topName US block US tail US decisions US refused US blocked US
# timeout US done # timeout US done US rewards
# #
# `total` counts start events inside the window, `decisions` starts plus # `total` counts start events inside the window, `decisions` starts plus
# refusals (the sequence, `distinct` and `topCount` are over decisions), and # refusals (the sequence, `distinct` and `topCount` are over decisions), and
# the last four count each outcome in the window. `period`/`repeats` describe # the next four count each outcome in the window, and `rewards` the reward
# events in it (row 58). `period`/`repeats` describe
# the shortest repeating block at the END of the sequence (0/0 when nothing # the shortest repeating block at the END of the sequence (0/0 when nothing
# repeats), `block` is that block comma-joined, and `tail` is the last 12 # repeats), `block` is that block comma-joined, and `tail` is the last 12
# names for context. The window ends at the newest macro event's own brain # names for context. The window ends at the newest macro event's own brain
# clock, not at `now`: brain time is the only clock the event log carries. # clock, not at `now`: brain time is the only clock the event log carries.
loop_analyze() { loop_analyze() {
awk -F'\t' -v win="$WD_LOOP_WINDOW_MS" -v maxp="$WD_LOOP_MAX_PERIOD" ' awk -F'\t' -v win="$WD_LOOP_WINDOW_MS" -v maxp="$WD_LOOP_MAX_PERIOD" '
{ ms[NR] = $1 + 0; lbl[NR] = $2; n = NR } # Reward lines are counted and nothing else: the window still ends at the
# newest macro event, as it always has.
$3 == "reward" { rms[++nr] = $1 + 0; next }
{ ms[++n] = $1 + 0; lbl[n] = $2 }
END { END {
if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\0370\0370\0370\0370\0370\n"; exit } if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\0370\0370\0370\0370\0370\0370\n"; exit }
to = ms[n]; from = to - win to = ms[n]; from = to - win
k = 0; distinct = 0; topn = 0; top = "" k = 0; distinct = 0; topn = 0; top = ""
starts = 0; refused = 0; blocked = 0; timeout = 0; done = 0 starts = 0; refused = 0; blocked = 0; timeout = 0; done = 0; rewards = 0
for (q = 1; q <= nr; q++) if (rms[q] >= from) rewards++
for (i = 1; i <= n; i++) { for (i = 1; i <= n; i++) {
if (ms[i] < from) continue if (ms[i] < from) continue
if (lbl[i] ~ / blocked$/) { blocked++; continue } if (lbl[i] ~ / blocked$/) { blocked++; continue }
@ -873,9 +919,9 @@ loop_analyze() {
tailstr = "" tailstr = ""
start = k - 11; if (start < 1) start = 1 start = k - 11; if (start < 1) start = 1
for (j = start; j <= k; j++) tailstr = tailstr (tailstr == "" ? "" : ", ") seq[j] for (j = start; j <= k; j++) tailstr = tailstr (tailstr == "" ? "" : ", ") seq[j]
printf "%d\037%d\037%d\037%d\037%d\037%d\037%d\037%s\037%s\037%s\037%d\037%d\037%d\037%d\037%d\n", \ printf "%d\037%d\037%d\037%d\037%d\037%d\037%d\037%s\037%s\037%s\037%d\037%d\037%d\037%d\037%d\037%d\n", \
starts, distinct, topn, period, repeats, from, to, top, block, tailstr, \ starts, distinct, topn, period, repeats, from, to, top, block, tailstr, \
k, refused, blocked, timeout, done k, refused, blocked, timeout, done, rewards
}' }'
} }
@ -933,20 +979,21 @@ check_loop() {
echo "$delta" > "${WD_RUN_DIR}/loop.places_delta" echo "$delta" > "${WD_RUN_DIR}/loop.places_delta"
local analysis total distinct topn period repeats win_from win_to top block tailstr local analysis total distinct topn period repeats win_from win_to top block tailstr
local decisions refused blocked timeouts completed local decisions refused blocked timeouts completed rewards
analysis="$(loop_macro_stream | loop_analyze)" analysis="$(loop_macro_stream | loop_analyze)"
IFS=$'\037' read -r total distinct topn period repeats win_from win_to top block tailstr \ IFS=$'\037' read -r total distinct topn period repeats win_from win_to top block tailstr \
decisions refused blocked timeouts completed <<< "$analysis" decisions refused blocked timeouts completed rewards <<< "$analysis"
total="${total:-0}"; distinct="${distinct:-0}"; topn="${topn:-0}" total="${total:-0}"; distinct="${distinct:-0}"; topn="${topn:-0}"
period="${period:-0}"; repeats="${repeats:-0}" period="${period:-0}"; repeats="${repeats:-0}"
decisions="${decisions:-0}"; refused="${refused:-0}"; blocked="${blocked:-0}" decisions="${decisions:-0}"; refused="${refused:-0}"; blocked="${blocked:-0}"
timeouts="${timeouts:-0}"; completed="${completed:-0}" timeouts="${timeouts:-0}"; completed="${completed:-0}"; rewards="${rewards:-0}"
echo "$distinct" > "${WD_RUN_DIR}/loop.distinct" echo "$distinct" > "${WD_RUN_DIR}/loop.distinct"
echo "$period" > "${WD_RUN_DIR}/loop.period" echo "$period" > "${WD_RUN_DIR}/loop.period"
echo "$repeats" > "${WD_RUN_DIR}/loop.repeats" echo "$repeats" > "${WD_RUN_DIR}/loop.repeats"
echo "$refused" > "${WD_RUN_DIR}/loop.refused" echo "$refused" > "${WD_RUN_DIR}/loop.refused"
echo "$(( blocked + timeouts ))" > "${WD_RUN_DIR}/loop.blocked" echo "$(( blocked + timeouts ))" > "${WD_RUN_DIR}/loop.blocked"
echo "$completed" > "${WD_RUN_DIR}/loop.done" echo "$completed" > "${WD_RUN_DIR}/loop.done"
echo "$rewards" > "${WD_RUN_DIR}/loop.rewards"
local prev_suspected=0 local prev_suspected=0
[ -f "${WD_RUN_DIR}/loop.suspected" ] && prev_suspected="$(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)" [ -f "${WD_RUN_DIR}/loop.suspected" ] && prev_suspected="$(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)"
@ -967,6 +1014,14 @@ check_loop() {
[ "$decisions" -gt 0 ] && [ "$completed" -eq 0 ] && [ "$grown" -eq 0 ] && idle=1 [ "$decisions" -gt 0 ] && [ "$completed" -eq 0 ] && [ "$grown" -eq 0 ] && idle=1
echo "$idle" > "$idle_file" echo "$idle" > "$idle_file"
# Busy going nowhere (row 58), judged over two probes like zero progress:
# many decisions, not one reward event, no new ground.
local busy_file="${WD_RUN_DIR}/loop.prev_unrewarded" prev_busy=0 busy=0
[ -f "$busy_file" ] && prev_busy="$(cat "$busy_file" 2>/dev/null || echo 0)"
case "${prev_busy:-}" in ''|*[!0-9]*) prev_busy=0 ;; esac
[ "$decisions" -ge "$WD_LOOP_BUSY_MIN" ] && [ "$rewards" -eq 0 ] && [ "$grown" -eq 0 ] && busy=1
echo "$busy" > "$busy_file"
local failed=$(( refused + blocked + timeouts )) local failed=$(( refused + blocked + timeouts ))
local suspected=0 reason="" local suspected=0 reason=""
if [ "$decisions" -gt 0 ] && [ "$grown" -eq 0 ]; then if [ "$decisions" -gt 0 ] && [ "$grown" -eq 0 ]; then
@ -991,6 +1046,11 @@ check_loop() {
# which is silence (above), not a loop. # which is silence (above), not a loop.
suspected=1 suspected=1
reason="dominant" reason="dominant"
elif [ "$busy" -eq 1 ] && [ "$prev_busy" -eq 1 ]; then
# Row 58: an undo pair diluted by other macros. Every one of them
# completes, none of them earns anything, and the ground stays put.
suspected=1
reason="unrewarded"
fi fi
fi fi
echo "$suspected" > "${WD_RUN_DIR}/loop.suspected" echo "$suspected" > "${WD_RUN_DIR}/loop.suspected"
@ -1022,6 +1082,7 @@ check_loop() {
--argjson blocked "$blocked" \ --argjson blocked "$blocked" \
--argjson timeouts "$timeouts" \ --argjson timeouts "$timeouts" \
--argjson completed "$completed" \ --argjson completed "$completed" \
--argjson rewards "$rewards" \
--argjson windowFrom "${win_from:-0}" \ --argjson windowFrom "${win_from:-0}" \
--argjson windowTo "${win_to:-0}" \ --argjson windowTo "${win_to:-0}" \
--argjson windowMs "$WD_LOOP_WINDOW_MS" \ --argjson windowMs "$WD_LOOP_WINDOW_MS" \
@ -1050,6 +1111,7 @@ check_loop() {
macroStarts: $total, macroStarts: $total,
decisions: $decisions, decisions: $decisions,
outcomes: { done: $completed, blocked: $blocked, timeout: $timeouts, refused: $refused }, outcomes: { done: $completed, blocked: $blocked, timeout: $timeouts, refused: $refused },
rewards: $rewards,
tail: (if $tail == "" then [] else ($tail | split(", ")) end) tail: (if $tail == "" then [] else ($tail | split(", ")) end)
}, },
dominant: { name: (if $dominant == "" then null else $dominant end), dominant: { name: (if $dominant == "" then null else $dominant end),
@ -1073,7 +1135,9 @@ check_loop() {
local mins=$(( WD_LOOP_WINDOW_MS / 60000 )) local mins=$(( WD_LOOP_WINDOW_MS / 60000 ))
if [ "$suspected" -eq 1 ]; then if [ "$suspected" -eq 1 ]; then
if [ "$reason" = "stalled" ] || [ "$reason" = "zero-progress" ]; then if [ "$reason" = "unrewarded" ]; then
log_err "loop suspected (unrewarded): ${decisions} decisions and no reward event in the last ${mins} brain minutes, two probes running — ${completed} done, top [${top}] x${topn}, ${distinct} distinct name(s), tail [${tailstr}], exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — macros that complete and earn nothing are an undo pair or a ring. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
elif [ "$reason" = "stalled" ] || [ "$reason" = "zero-progress" ]; then
log_err "loop suspected (${reason}): [${shown}] — ${decisions} decisions in the last ${mins} brain minutes, ${total} started, ${completed} done, ${refused} refused, $(( blocked + timeouts )) blocked or timed out, exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a pad whose buttons cannot run is a macro bug. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'." log_err "loop suspected (${reason}): [${shown}] — ${decisions} decisions in the last ${mins} brain minutes, ${total} started, ${completed} done, ${refused} refused, $(( blocked + timeouts )) blocked or timed out, exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a pad whose buttons cannot run is a macro bug. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
elif [ "$reason" = "sequence" ]; then elif [ "$reason" = "sequence" ]; then
log_err "loop suspected: [${shown}] x${repeats} (period ${period}) in the last ${mins} brain minutes — ${total} macro starts, ${distinct} distinct name(s), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a loop is a macro target-choice bug and a bounce would only restore the same loop. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'." log_err "loop suspected: [${shown}] x${repeats} (period ${period}) in the last ${mins} brain minutes — ${total} macro starts, ${distinct} distinct name(s), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a loop is a macro target-choice bug and a bounce would only restore the same loop. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."

View file

@ -79,6 +79,12 @@ log "building in $crate_dir for target-cpu=haswell (the host is E5-2660 v3, Hasw
( (
cd "$crate_dir" cd "$crate_dir"
RUSTFLAGS="-C target-cpu=haswell" cargo build --release --target "$CARGO_TARGET" --bin flysim "${features_args[@]}" RUSTFLAGS="-C target-cpu=haswell" cargo build --release --target "$CARGO_TARGET" --bin flysim "${features_args[@]}"
# fly-edge (FLY_FEED_VIA=bus, docs/design/flybus.md): the feed WebSocket
# served from flysim's feed bus. Small, and no cargo features of its own;
# built every time so a release can switch a container onto the bus
# without a rebuild. It lands next to OUT_PATH, where package-release.sh
# looks for it.
RUSTFLAGS="-C target-cpu=haswell" cargo build --release --target "$CARGO_TARGET" --bin fly-edge
) )
built="${crate_dir}/target/${CARGO_TARGET}/release/flysim" built="${crate_dir}/target/${CARGO_TARGET}/release/flysim"
@ -106,4 +112,10 @@ fi
cp "$built" "$OUT_PATH" cp "$built" "$OUT_PATH"
chmod 0755 "$OUT_PATH" chmod 0755 "$OUT_PATH"
log "built $OUT_PATH ($(du -h "$OUT_PATH" | cut -f1))" log "built $OUT_PATH ($(du -h "$OUT_PATH" | cut -f1))"
edge_built="${crate_dir}/target/${CARGO_TARGET}/release/fly-edge"
[ -x "$edge_built" ] || die "expected binary not found after build: $edge_built"
edge_out="$(dirname "$OUT_PATH")/fly-edge"
cp "$edge_built" "$edge_out"
chmod 0755 "$edge_out"
log "built $edge_out ($(du -h "$edge_out" | cut -f1))"
log "next: infra/build/package-release.sh VERSION $OUT_PATH <stage-dir> <bridge-dir> <out-dir>" log "next: infra/build/package-release.sh VERSION $OUT_PATH <stage-dir> <bridge-dir> <out-dir>"

View file

@ -11,6 +11,9 @@
# #
# Output: OUT_DIR/flybrain-<version>.tar.gz, laid out as # Output: OUT_DIR/flybrain-<version>.tar.gz, laid out as
# flysim (the binary, mode 0755) # flysim (the binary, mode 0755)
# fly-edge (the feed-bus edge, mode 0755, when build-flysim.sh
# left one beside FLYSIM_BIN; flyedge.service stays
# inactive on a release without it)
# stage/... (apps/stage's build output) # stage/... (apps/stage's build output)
# bridge/... (services/bridge + node_modules) # bridge/... (services/bridge + node_modules)
# data/fafb-v783/... (the connectome, from the repo; FLY_DATASET points here) # data/fafb-v783/... (the connectome, from the repo; FLY_DATASET points here)
@ -72,6 +75,13 @@ mkdir -p "$release_dir"
cp "$FLYSIM_BIN" "${release_dir}/flysim" cp "$FLYSIM_BIN" "${release_dir}/flysim"
chmod 0755 "${release_dir}/flysim" chmod 0755 "${release_dir}/flysim"
EDGE_BIN="$(dirname "$FLYSIM_BIN")/fly-edge"
if [ -x "$EDGE_BIN" ]; then
cp "$EDGE_BIN" "${release_dir}/fly-edge"
chmod 0755 "${release_dir}/fly-edge"
else
log "no fly-edge beside $FLYSIM_BIN; packaging without it (FLY_FEED_VIA=bus unavailable in this release)"
fi
cp -a "$STAGE_DIR" "${release_dir}/stage" cp -a "$STAGE_DIR" "${release_dir}/stage"
cp -a "$BRIDGE_DIR" "${release_dir}/bridge" cp -a "$BRIDGE_DIR" "${release_dir}/bridge"

View file

@ -16,3 +16,7 @@ fly ALL=(root) NOPASSWD: /usr/bin/systemctl restart flycast.service
fly ALL=(root) NOPASSWD: /usr/bin/systemctl restart mediamtx.service fly ALL=(root) NOPASSWD: /usr/bin/systemctl restart mediamtx.service
fly ALL=(root) NOPASSWD: /usr/bin/systemctl restart flypush.service fly ALL=(root) NOPASSWD: /usr/bin/systemctl restart flypush.service
fly ALL=(root) NOPASSWD: /usr/sbin/reboot fly ALL=(root) NOPASSWD: /usr/sbin/reboot
# fly-loop-recover.service (also User=fly): its ladder restarts flysim (granted above) and
# resets to a milestone archive through this one root wrapper, which validates the rung,
# stops/resets/starts flysim and nothing else (infra/docs/loop-recovery.md).
fly ALL=(root) NOPASSWD: /opt/fly/bin/fly-loop-reset

View file

@ -6,6 +6,9 @@ d /run/fly 0750 fly fly -
d /run/fly/pulse 0750 fly fly - d /run/fly/pulse 0750 fly fly -
d /run/fly/state 0750 fly fly - d /run/fly/state 0750 fly fly -
d /run/fly/wd 0750 fly fly - d /run/fly/wd 0750 fly fly -
# ADDED: the feed bus (FLY_FEED_VIA=bus, docs/design/flybus.md): flysim's
# router socket and artifact store. flysim also creates it, 0700, on start.
d /run/fly/bus 0700 fly fly -
d /var/lib/fly 0750 fly fly - d /var/lib/fly 0750 fly fly -
d /var/lib/fly/chrome 0700 fly fly - d /var/lib/fly/chrome 0700 fly fly -
d /srv/fly/state 0750 fly fly - d /srv/fly/state 0750 fly fly -

View file

@ -9,6 +9,14 @@
// on flystage-web.service). Deliberately not a general-purpose server: // on flystage-web.service). Deliberately not a general-purpose server:
// no directory listing, no symlink following outside root, no range // no directory listing, no symlink following outside root, no range
// requests (the page is a handful of static assets, not video). // requests (the page is a handful of static assets, not video).
//
// One route is not a file under root: GET /recovery-notice.json returns the
// auto-recovery helper's notice (infra/bin/fly-loop-recover writes it to
// /run/fly/wd/recovery-notice.json, FLY_RECOVERY_NOTICE overrides), which the
// page's recovery splash polls once a second. It is served from here and not
// from flysim because flysim is the thing being restarted. 200 with the file's
// bytes, or 204 when there is no (readable, small, regular) file; the page does
// all of the validation (apps/stage/src/lib/recovery.ts).
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises'; import { readFile, stat } from 'node:fs/promises';
import { join, normalize, extname } from 'node:path'; import { join, normalize, extname } from 'node:path';
@ -17,6 +25,27 @@ const root = process.argv[2] ?? new URL('.', import.meta.url).pathname;
const host = process.argv[3] ?? '127.0.0.1'; const host = process.argv[3] ?? '127.0.0.1';
const port = Number(process.argv[4] ?? 7402); const port = Number(process.argv[4] ?? 7402);
const RECOVERY_ROUTE = '/recovery-notice.json';
const RECOVERY_PATH = process.env.FLY_RECOVERY_NOTICE || '/run/fly/wd/recovery-notice.json';
const RECOVERY_MAX_BYTES = 16384;
async function serveRecoveryNotice(res) {
const headers = { 'cache-control': 'no-store' };
const st = await stat(RECOVERY_PATH).catch(() => null);
if (!st?.isFile() || st.size === 0 || st.size > RECOVERY_MAX_BYTES) {
res.writeHead(204, headers).end();
return;
}
const body = await readFile(RECOVERY_PATH).catch(() => null);
if (!body) { res.writeHead(204, headers).end(); return; }
res.writeHead(200, {
...headers,
'content-type': 'application/json; charset=utf-8',
'content-length': body.length,
});
res.end(body);
}
const TYPES = { const TYPES = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
@ -27,6 +56,7 @@ const TYPES = {
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
try { try {
if (req.url.split('?')[0] === RECOVERY_ROUTE) { await serveRecoveryNotice(res); return; }
const urlPath = normalize(decodeURIComponent(req.url.split('?')[0])); const urlPath = normalize(decodeURIComponent(req.url.split('?')[0]));
if (urlPath.includes('..')) { res.writeHead(400).end('bad path'); return; } if (urlPath.includes('..')) { res.writeHead(400).end('bad path'); return; }
let path = join(root, urlPath === '/' ? 'index.html' : urlPath); let path = join(root, urlPath === '/' ? 'index.html' : urlPath);
@ -47,5 +77,5 @@ const server = createServer(async (req, res) => {
}); });
server.listen(port, host, () => { server.listen(port, host, () => {
console.log(`flystage-web: serving ${root} on http://${host}:${port}/`); console.log(`flystage-web: serving ${root} on http://${host}:${server.address().port}/`);
}); });

109
infra/docs/loop-recovery.md Normal file
View file

@ -0,0 +1,109 @@
# Automated loop recovery
`fly-watchdog` remains report-only. The separate `fly-loop-recover.timer` reads its
`/run/fly/wd/loop.json` every five minutes and unsticks a confirmed trap on its own, climbing
a ladder one step per trap that outlives the previous step:
| level | step | cost |
|---|---|---|
| 0 | `systemctl restart flysim.service` | none: the restore keeps the rung and learned state, clears session macro ledgers |
| 1 | reset to the current rung's milestone archive | progress since the rung was first reached |
| 2+ | reset to the archive below the best rung (never lower) | one visible rung |
- **Confirmed** means two fresh suspected watchdog reports (at most 10 minutes old, `action: none`)
from separate probes about 5 minutes apart. A clear report in between starts the count again.
- **Outlives** means the reports that confirm it again are at least 20 minutes after the step, so
their 10-brain-minute window lies after it. The helper waits that long after every step.
- **Budget:** at most two milestone resets per 24 hours. With the budget spent the step is a
restart, at most every three hours, until a reset is free again. The same three-hour spacing applies
when a reset level finds no restorable rung and restarts instead.
- **Starting over:** the ladder returns to level 0 when the fly reaches a new best rung, or after
six hours with no suspected report.
A milestone step only uses an archive the running build can restore. `fly-loop-reset --list`
compares each archive's compatibility string with `flysim --print-compatibility` and accepts an
adapter-only difference that `FLY_ACCEPT_ADAPTERS` in `/etc/fly/fly.env` names (the rule
`05-deploy.sh` applies). A flysim that refuses every checkpoint refuses to start, so a step whose
rung is not restorable is a restart instead, never a lower rung. Keep `FLY_ACCEPT_ADAPTERS` in the
release env file so a deploy does not drop it.
The step runs `/opt/fly/bin/fly-loop-reset <rung>` through sudo (the one line in
`config/fly-sudoers`). As root it:
- runs only the root-owned `/opt/fly/sbin/flysim`, and only while it is byte-identical to the
flysim `/opt/fly/current` points to, that `05-deploy.sh` installs from the release
tarball after checking it against the tarball's MANIFEST (never the fly-owned release tree).
Without that copy (an infra-only deploy, or a manual rollback) no rung is restorable and the
ladder only restarts; after a release deploy, check as `fly` that
`sudo -n /opt/fly/bin/fly-loop-reset --list` names the current rung;
- reads `fly.env` as `KEY=VALUE` data, never sources it, and ignores the caller's environment;
- stops `fly-watchdog.timer` and waits for a running probe to finish, so the watchdog cannot
start flysim on a half-rewritten store; stops flysim; runs `fly-reset-to-milestone`; and on every
exit path starts flysim and the watchdog timer again.
The reset copies both stores to `/srv/fly/state.reset-<UTC>` first, as in the runbook, and
clears milestone archives above the rung. After each step the helper waits up to four minutes for
`/status` to report `running` (and the target rank for a reset). Otherwise the step is recorded
as failed and the ladder still climbs. The step is recorded before it runs, so a helper killed
mid-step has still climbed and spent the reset. Do not stop `fly-loop-recover.service` during a
step: that kills the reset too, and the wrapper's exit trap starts flysim on whatever state is
left.
## On stream
Every step is announced 60 seconds ahead (`FLY_LOOP_COUNTDOWN`) in
`/run/fly/wd/recovery-notice.json` (`FLY_RECOVERY_NOTICE`), which the stage's recovery splash
reads. It holds the contract below, written atomically, mode 0644:
```json
{"v": 1, "id": "1790629095-reset", "phase": "countdown|acting|done|failed",
"action": "restart|reset", "fromRung": 12, "fromLabel": "MT. MOON",
"toRung": 11, "toLabel": "BOULDER BADGE", "reason": "unrewarded",
"loop": ["GO OBJECTIVE", "GO WARP"], "stuckSeconds": 900,
"announcedAt": 1790629095, "executeAt": 1790629155, "updatedAt": 1790629160}
```
`toRung`/`toLabel` are present for a reset only; every write refreshes `updatedAt`, and the
helper never deletes the file. `flystage-web` serves it at `/recovery-notice.json` (204 when
absent) and the page's recovery splash polls it once a second (`apps/stage/README.md`, "Recovery
splash"). The page ignores a notice more than 15 minutes old, shows `done` for 8 s and `failed`
for 20 s, and stops covering the game 10 minutes after an `acting` write that nothing followed.
A milestone step pauses the watchdog, so Chromium is not restarted under the splash; a plain
restart is short enough that a watchdog pass rarely lands in it.
## Model confirmation
An OpenAI-compatible router can be asked before each step. The model may only answer
`{"stuck": true|false}` (the reply may wrap it in prose; the first such object counts). It cannot
choose commands, rungs or buttons. `false` delays the step by one probe, at most three times in a
row; then the step goes ahead. If no model answers (unset, rate-limited, down, malformed), the
watchdog's confirmation stands alone. A model can delay recovery by about 15 minutes, never deny
it.
Provision a root-managed `/etc/fly/loop-recovery.env`, mode `0640 root:fly`, outside this public
checkout:
```
FLY_LOOP_ROUTER_URL=<base URL ending in /v1>
FLY_LOOP_ROUTER_KEY=<router key>
FLY_LOOP_MODELS=<model>,<fallback model>,...
```
Models are tried in order within a 90-second budget. Prefer fast free-tier chat models, and put
providers with generous free limits first: free OpenRouter models share a small daily quota and
the router's circuit breaker can close the whole provider for a while. Check each model against
a stuck and a healthy report before listing it. `FLY_LOOP_MODEL` (one model) is still read when
`FLY_LOOP_MODELS` is unset.
## Operating it
- Decisions: `journalctl -u fly-loop-recover.service`. Every step, veto and ladder restart is also
appended to `/var/lib/fly-loop-recover/history.jsonl`.
- Ladder state: `/var/lib/fly-loop-recover/state.json` (the unit's `StateDirectory`, so it
survives a reboot). Deleting it starts the ladder over.
- Stop automatic recovery: `systemctl disable --now fly-loop-recover.timer`.
- Record automatic steps you find in the journal in the host claim log when you next claim the
container; the unit has no access to that log.
This is an unstick mechanism, not a macro bug fix. A recurring trap still needs the
checkpoint-based loop review in `docs/loop-review.md`.

View file

@ -20,7 +20,8 @@ FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
cargo run --release -p flysim --example palette_bench cargo run --release -p flysim --example palette_bench
``` ```
Both arms are the sim loop's own frame order over the real connectome (`data/fafb-v783`), the real Both arms are the sim loop's own frame order (`flysim::frame::LegacyFrame` since 2026-09-23;
before that `NeuralAgent::tick`'s, one frame behind the stream) over the real connectome (`data/fafb-v783`), the real
Game Boy readout preset with nothing overridden, the real Pokémon adapter paying the real reward Game Boy readout preset with nothing overridden, the real Pokémon adapter paying the real reward
catalog, and the real ratchet on the adapter's own recovery policy. The only difference between catalog, and the real ratchet on the adapter's own recovery policy. The only difference between
them is `flysim::macros::MacroLayer`, built from the configuration the way `Sim::boot` builds it, them is `flysim::macros::MacroLayer`, built from the configuration the way `Sim::boot` builds it,

View file

@ -151,6 +151,15 @@ window every 15 brain seconds, so a loop is caught wherever it starts) in which
A window with no macro in it is not flagged: silence waits, and that is the doctrine working. A window with no macro in it is not flagged: silence waits, and that is the doctrine working.
**2026-09-23, FND-01.** The hunt now runs `flysim::frame::LegacyFrame`, the frame the service
runs, restored the way the service restores (no held channel, no location, the blocked window at
brain time 0). Before that it ticked the brain through `NeuralAgent::tick`, one frame behind the
stream: each frame and its rewards reached the brain after the next ticks, the ratchet was
observed without the objective signal, and a rollback did not re-observe the scene. Hunts from
before and after the change are not comparable number for number; compare two arms built from
the same side of it. `FLY_TRACE=<path>` writes the run in the service's own per-frame trace
format (`flysim::trace`), so a hunt can be diffed against the service from the same checkpoint.
```sh ```sh
FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \ FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
FLY_TRAP_CHECKPOINT=.local/checkpoints/release-viridian-loop.checkpoint \ FLY_TRAP_CHECKPOINT=.local/checkpoints/release-viridian-loop.checkpoint \
@ -2655,3 +2664,390 @@ the fly is no longer held in the pocket.
- `infra/tests/lint.sh`: ALL CHECKS PASSED, the two new check-10 cases and the de-PII guard included. - `infra/tests/lint.sh`: ALL CHECKS PASSED, the two new check-10 cases and the de-PII guard included.
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`** -- byte-identical to the - `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`** -- byte-identical to the
base `d5d9249`. Decoder, reward catalog, adapter version and roles untouched. base `d5d9249`. Decoder, reward catalog, adapter version and roles untouched.
## 2026-09-23, row 58: the gym's door, in and out
### What was live
Map 2 (Pewter City) and map 54 (Pewter Gym), rank 10, v0.5.3, for twenty-five minutes: `GO OBJECTIVE`
`done` into the gym, `GO OUT` `done` straight back out, `GO ITEM` / `GO FRONTIER` / `YES` / `NO`
mixed in. Per ten brain minutes about 93 `GO OUT`, 47 `GO OBJECTIVE`, 200 starts, 0-5 refused or
blocked, **no reward event of any kind**, `uniqueLocations` frozen at 1,892. `GO OUT` often started
and finished inside 0.05-0.17 s. Check 10 saw ten distinct names and never flagged.
### The survey: the room on the Nth arrival
`FLY_PROBE_CATCH=route` drives the real palette from the checkpoint; `FLY_PROBE_CATCH_MAP=54`
stops it forty frames into the fly's Nth arrival on the gym and dumps the room with every person's
ledger entries. From the bare checkpoint, preferring `GO OBJECTIVE`, the base walks the pair
itself inside seven brain minutes: **`GO OUT` 1,118, `GO OBJECTIVE` 583 in 33 brain minutes**, the
gym pad `["GO OUT"]`, and on the doormat:
- `objective_targets` empty, `person_targets` = the guide at (7, 10), talked;
- BROCK at (4, 1) and the Jr. Trainer at (3, 6) **absent**: `CheckSpriteAvailability` had written
`$ff` into their image index because they are outside the window of (4, 13);
- pushed tiles `(54, (16, 17))` and `(2, (5, 13))` -- Pewter's gym door and the gym's doormat,
recorded under the *other* map's id.
### Why nothing inside the gym was offered, and why the door was
| # | trap | trigger | test | fix, or why it is left |
| --- | --- | --- | --- | --- |
| 58 | the rung's list of people is read from the sprites the cartridge draws, so a person off the screen is not in the room; with the one drawn person talked to, `GO OBJECTIVE` has nothing inside, 12.5 lets `GO OUT` onto the pad, and outside `GO OBJECTIVE` aims at the door | any rung earned by a person who is more than four rows or five columns from where the fly arrives; the Pewter Gym from its doormat | `the_rungs_people_are_in_the_room_when_the_screen_does_not_show_them`, `only_the_rung_reads_people_off_the_screen`, `a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map`, `the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint` (ROM) | **fixed**: `state::offscreen_npcs` reports a `$ff` sprite whose coordinates fall outside `CheckSpriteAvailability`'s own window (movement byte `WALK` or above), and only the rung's list reads it. `docs/design/macros.md` 12.22, `macros-wram.md` section 12 |
| 58b | in front of one of the rung's people, `GO OBJECTIVE` still walks to another | a room with more than one of them: the gym's leader and trainer | `facing_one_of_the_rungs_people_is_the_arrival` | **fixed**: facing any of them is the arrival, and `TALK` is the press |
| 58c | a warp's tear -- `wCurMap` changed, header, coordinates and warp table not yet -- reads as a controllable overworld for thirty-two frames; a pad is dealt, a walk plans over the wrong map, and its target and tile go into the ledgers under the new map's id | every warp; live, `GO OUT` started and finished in 0.05 s | `a_warps_tear_deals_no_pad`, `a_teleport_pad_is_not_a_tear` | **fixed** in the driver: the map byte changed and the fly still stands on a loaded warp into the map the byte names (a doormat's `LAST_MAP` under a town's id included) is `Unknown` with an empty pad, bounded at ninety frames. Teleport pads never change the map byte |
| 58d | the 219 frames of a battle transition read as a controllable overworld: a walk toward the leader presses into the animation and blocks him for ten brain minutes, and the trainer's conversation reads as over, so a trainer the fly then loses to is "talked to" for the session | every trainer battle, and every wild one | `a_battle_decided_and_not_yet_begun_is_the_cartridges` | **fixed**: `controllable` reads `wCurOpponent`, set when a battle is decided and cleared by `EndOfBattle`; derived as `wBattleType - 1`, both neighbours asserted |
| 58e | a trainer walking up to the fly is read as the cartridge refusing the step (12.4): the target is blocked and the tile pushed on the spot | every trainer's line of sight a walk crosses | `a_trainer_walking_up_teaches_the_ledgers_nothing`, `a_scripted_push_back_records_the_tile_it_happened_on`, `a_walk_the_cartridge_pushes_back_excludes_what_it_was_walking_to` | **fixed**: the entries wait for the cartridge to give the joypad back; the overworld is a refusal, written as before, and a battle writes nothing |
| 58f | check 10 cannot see an undo pair diluted by other names | ten distinct names, every macro `done`, 211 decisions in ten brain minutes | `lint.sh` check 10 cases 7 and 8 | **fixed**: `unrewarded`, 100+ decisions and no reward event on two probes with no new ground. Against the live row-58 log it flags; the rules before it did not |
### Before and after
The route survey, same checkpoint, 120,000 frames (33 brain minutes), base `main` at `174dc7e`
(row 57 merged) against this branch:
| measure | base | branch, `GO OBJECTIVE` preferred | branch, `TALK` preferred |
| --- | ---: | ---: | ---: |
| `GO OUT` done | **1,118** | **0** | **0** |
| `GO OBJECTIVE` done on the gym | **583** (all maps) | **4** | **4** |
| rung at the end | 10 | **11, BOULDER BADGE** | **11, BOULDER BADGE** |
| pushed tiles under the wrong map's id | `(54, (16, 17))`, `(2, (5, 13))` and four more doormats | none | none |
The ROM-gated run, `the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint`, 30.1
brain minutes on the stub rotation: the base goes through the door once, is back out in 309
frames and never above row 11 (fails); the branch goes through four times, walks straight back out
once, spends 30,879 frames in the gym and stands on row 2 beside BROCK (passes). Row 56's
`the_fly_leaves_the_pewter_gym_guides_ring_from_the_rung_ten_checkpoint` now reaches rung 11 at
8.45 brain minutes, which it never did; row 57's pocket test passes, and its part one no longer
walls `(54, (16, 17))`, the tear's tile.
The trap hunt, 30 brain minutes each from the same checkpoint and seed, the stub rotation
(`FLY_TRAP_STUB=1`; the brain is stepped and the readout replaced), base `174dc7e` against this
branch:
| measure | base | branch |
| --- | ---: | ---: |
| distinct (map, tile) | 329 | **437** |
| windows flagged | 17 | 23 |
| macros started / done | 139 / 137 | 131 / 129 |
| `GO OUT` done | 3 | 0 |
| frames between battle turns | 51,328 | 53,336 |
| rung reached | 10 | 10 |
**The stub does not walk the ring on either arm** -- it spends half of both runs in battles and
goes through the gym's door once -- so the hunt says little about this row either way, and the
flagged-window count rises (17 -> 23) on battle time, the same judgement as rows 50 and 56. The
route survey above is the reproduction; the hunt is reported, not smoothed.
### Residuals, named rather than worked around
- **After the badge, a new pair at the Pewter/Route 3 edge.** With the rung earned, the objective
is Mt. Moon (map 59), and on Route 3 `GO OBJECTIVE` has nothing to aim at: `geography` carries
Route 3's neighbour as Route 4 to the **east** and a Mt. Moon door **on Route 3**, while the
disassembly (`data/maps/headers/Route3.asm`, `objects/Route4.asm`) and the cartridge
(`wCurMapConnections` north and west, row 54b) say Route 4 is **north** and Mt. Moon's doors
are **on Route 4**, whose ground is in two pieces like Route 2's. The survey that prefers
`GO OBJECTIVE` walks `GO OBJECTIVE` east into Route 3 and `GO ROUTE` back west 538 times from
21 brain minutes. It is row 54b's residual and it needs a `SPLIT` row for Route 4; the next
brief. Check 10's new `unrewarded` rule sees it.
- **A toggleable object switched off reads as present from outside the window.** Only the rung's
own list reads the off-screen people; among the ladder's person places, only Oak's lab (behind
this run) and Viridian Gym carry toggleable people.
- **The tear is read stateful and bounded**: an arrival onto a warp into the map it arrived on,
under a map byte that changed, is a tear for at most ninety frames.
- **The real-brain hunt was not run to the end on this branch**: two 30-minute arms were started
and stopped at a quarter done when row 57 merged and the branch was rebased; the box was loaded
at twelve. The stub arms above are the hunt.
### Gates
- `cargo test --workspace` with `FLY_ROM` and `FLY_DATASET`: 1,267 passed, 1 failed --
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
the known boot-time failure, identical on the base.
- `cargo clippy --all-targets`: **0 warnings**.
- `npm test` 663 passed; `npm run typecheck` clean.
- `infra/tests/lint.sh`: ALL CHECKS PASSED, check 10's two new cases and the de-PII guard included.
- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had:
648 bytes, sha256 `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68`
on `main` after the engagement rewards (adapter v7). Decoder, reward catalog, adapter version
and roles untouched.
## 2026-09-23, row 60: TAIL WHIP after it stopped working
### What was live
Map 12 (Route 1), rank 9, v0.5.5, after the operator reset the run to milestone 1: Squirtle L5
(TACKLE, TAIL WHIP) in wild battles with Pidgey, "Nothing happened!" on the screen. Since the reset
`MOVE 2` 183 starts and `MOVE 1` one; the last reward 25 brain minutes before the checkpoint,
1,600 decisions since, one wild win in the whole run. Check 10 flagged `unrewarded` -- correctly:
100+ decisions, no reward event, no new ground on two probes. It is unchanged.
### The survey: the pad on the fly's own turn
The route probe from the checkpoint (`FLY_PROBE_CATCH=route`, `FLY_PROBE_PREFER="MOVE 2"`, the
live readout's favourite; 72,000 frames) now prints the battle bytes on every pad change. The
checkpoint is the frame Squirtle fainted: Pidgey L3, DEFENSE stage 1 (-6), DEFENSE 2, TAIL WHIP's
row `$27`: effect `$13` power 0. In the next battle DEFENSE reached **1 at stage -5**, which
`StatModifierDownEffect` refuses as well. From then on the own-turn pad was `MOVE 1, MOVE 2, RUN`
over the menu and `BACK, MOVE 1, MOVE 2` over the list: **every own-turn pad with TAIL WHIP
refused dealt `MOVE 2`** (72 of 72; a 73rd frame was a list with no placeable cursor, `NEXT`
alone), TACKLE beside it every time. `MOVE 1` was never missing; `MOVE 2` was a
button that could change nothing.
| # | trap | trigger | test | fix, or why it is left |
| --- | --- | --- | --- | --- |
| 60 | `MOVE n` is bound by PP alone, so a move the cartridge refuses ("Nothing happened!", "didn't affect") stays on the pad beside one that works | any stat move at its stage or stat limit, any status move at a target it cannot affect; Route 1, TAIL WHIP at a Pidgey's DEFENSE 1, every battle lost | `a_move_the_cartridge_answers_with_nothing_is_off_the_pad_beside_one_it_does_not`, `a_spent_move_and_a_move_without_effect_leave_the_one_that_works`, `with_no_move_that_does_anything_the_moves_stay_as_pp_deals_them`, six `state` tests, `tail_whip_at_its_limit_is_not_dealt_and_a_route_one_battle_is_won` (ROM) | **fixed**: `state::move_data` reads the move's row of `Moves` (`$0E:$4000`) from the cartridge image; `state::move_without_effect` answers the refusals decided before the roll (stage 1/13, stat 1/999, Mist or substitute, a statused, Poison or Ground target); the palette treats such a move as it treats a spent one. `docs/design/macros.md` 12.23, `macros-wram.md` section 13 |
### Before and after
The ROM-gated run, 72,000 frames (20.1 brain minutes) from the checkpoint, the real palette
driven with `MOVE 2` preferred and a uniform choice otherwise; base is this branch with the
palette commit reverted:
| measure | base | branch |
| --- | ---: | ---: |
| own-turn frames with a refused move beside a useful one | 2,657 | 568 |
| ... of them dealing the refused move | **2,657** | **0** |
| battles ended / won | 12 / **0** | 14 / **3** |
| battles ended with the fly's Pokémon fainted | 12 | 8 |
| battle length, frames, median / max | 4,970 / 8,994 | 3,594 / 4,793 |
| `MOVE 2` / `MOVE 1` / `RUN` starts | 94 / 0 / 0 | 56 / 5 / 4 |
The route survey, same driver and checkpoint: base never leaves Pallet Town, Red's house and
Route 1 (637 tiles at the end); the branch reaches Viridian City, its Pokémon Center and mart and
Route 2 (748).
The survey itself, same driver: own-turn pads dealing a refused TAIL WHIP 72 -> **0** (of 11 at
the limit); `MOVE 2` / `MOVE 1` / `RUN` done 94 / 0 / 0 -> 56 / 4 / 4; maps with a macro done 4 -> 10.
**The real-brain trap hunt was not run to the end.** Both 20-minute arms (`trap_hunt` now reports
payouts by kind, battle lengths and wins, and `MOVE n` starts on a move without effect) were
started from the checkpoint and stopped after 1 h 48 min wall at about 19 CPU-minutes each: the box
sat at load 25-40 and a stub arm ticks the same brain. The ROM-gated run and the survey above are
the before/after; they drive the real palette with the live readout's measured preference instead
of the brain, which is the deviation.
### Residuals, named rather than worked around
- **The fly still spends TAIL WHIP while it works.** Six presses at stage 7 to 1 are the fly's
choice, and a Squirtle at 8/20 can faint doing it; eight of fourteen battles on the branch
still ended that way. What changed is that the seventh is not on the pad.
- **Not covered, same kind, nothing early reaches it:** Confuse Ray and Supersonic on a confused
target, Leech Seed on a seeded or Grass target, Focus Energy, Mist, Reflect and Light Screen
already up, Disable on a disabled target, and a damaging move the type chart makes "doesn't
affect" (the chart is another ROM table). `macros-wram.md` section 13.
- **With no move that would do anything, the moves stay as PP deals them.** Taking them away would
leave an open list with `BACK` alone (12.11); a turn that ends on "Nothing happened!" still ends.
### Gates
- `cargo test --release -p flybrain-gb` with `FLY_ROM`: 426 + 27 passed, 0 failed.
- `cargo test --release -p flysim --no-fail-fast` with `FLY_ROM` and the row-60 checkpoint:
all passed but `integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
the known load failure (`total` 1 against 38, or the feed at 14-18 Hz, on a box at load 30),
failing the same two ways on the base.
- `cargo clippy --workspace --all-targets -- -D warnings`: clean.
- `npm test` 663 passed; `npm run typecheck` clean; `infra/tests/lint.sh` ALL CHECKS PASSED.
- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had:
648 bytes, sha256 `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68`
on `main` after the engagement rewards (adapter v7). Decoder, reward catalog, adapter version
and roles untouched.
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`**, byte-identical to the
base. Decoder, reward catalog, adapter version and roles untouched.
## 2026-09-23, row 59: the road to Mt. Moon, and Route 4 in two
### What was coming, and what came
Opened pre-emptively. The row-58 review carried the route survey past the Boulder Badge (v0.5.5's fix):
from about frame 68,000, Pewter City (39, 17) and Route 3 (0, 9) in a ring, `GO OBJECTIVE` done
on Route 3 538 times, `GO ROUTE` done on Pewter 537. Route 3's pad was `GO ROUTE` alone, because
`GO OBJECTIVE` had nothing on Route 3 to aim at. The live fly was due there with the badge.
It got through Route 3 and met the other half on v0.6.0 at 22:20 UTC: rank 12 (MT. MOON), map 15,
per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103, five distinct macros, two new
tiles; the coordinator restarted flysim. The live checkpoint from inside the ring is v7. From it the
route survey on `main` (`4d82f7d`, 72,000 frames) walks between Route 4 and the Pokécenter (map
68): 930 map changes, `GO OBJECTIVE` done on Route 4 465 and `GO ROUTE` done on the Pokécenter
462 with `GO OBJECTIVE` preferred; with a uniform choice 706 changes, `GO ROUTE` 350,
`GO OBJECTIVE` 183, `GO OUT` 170, the live shape. That is 59c.
### Reproduction
`examples/scene_probe.rs` gains `FLY_PROBE_SAVE_RANK`: the route survey writes the first safe
overworld frame at a rung as a checkpoint. From the row-58 checkpoint, `FLY_PROBE_CATCH=route
FLY_PROBE_PREFER="GO OBJECTIVE,TALK" FLY_PROBE_FRAMES=120000`, the survey is byte-identical to the
review's and writes rank 11 on frame 19,620, in the gym beside BROCK
(`.local/checkpoints/survey-rank11-row59.checkpoint`, untracked). From that checkpoint, with empty
ledgers, the base walks the ring again: `GO OBJECTIVE` done on Route 3 509, `GO ROUTE` done on
Pewter 508, never on Route 4.
### The audit: every row of the map graph against the disassembly
`geography.rs` against `data/maps/headers/*.asm` and `data/maps/objects/*.asm` at `0cd19d3`, all
outdoor rows and every `LINKS` pair; then every map on the graph flooded tile by tile (blocks,
blockset, collision list, `TilePairCollisionsLand`, `LedgeTiles`, lower-left quadrant as section
15 reads it) for pieces, and every connection for a strip that lands on land.
| # | trap | trigger | test | fix, or why it is left |
| --- | --- | --- | --- | --- |
| 59 | Route 3 / Route 4 in the wrong columns (`[-,-,PEWTER,ROUTE_4]` / `[-,-,ROUTE_3,CERULEAN]`; the headers say Route 4 is **north** of Route 3), and Mt. Moon 1F linked to **Route 3**, which has no warps; B1F's exit to Route 4 missing | any objective past Pewter: Route 3's north edge named no map, so it was nobody's hop | `the_rows_the_disassembly_corrected_say_what_its_headers_say`, `the_road_to_mt_moon_is_not_a_ring_at_the_pewter_end_from_the_badge_checkpoint` (ROM) | **fixed**: rows as `Route3.asm` / `Route4.asm`; `(MT_MOON_1F, ROUTE_4)`, `(MT_MOON_B1F, ROUTE_4)` per `Route4.asm`'s warps (18, 5) and (24, 5) and B1F's (27, 3) |
| 59b | Route 14 / 15 and Route 24 / 25 in the wrong columns (south/north for west/east) | Nugget Bridge's far end (rung 16) named no map; Route 14's west edge likewise | the same unit test | **fixed**: `Route14.asm`, `Route15.asm`, `Route24.asm`, `Route25.asm` |
| 59c | Route 4 is one node, though Mt. Moon cuts it in two: the cave mouth's side (Pokécenter (11, 5), 1F (18, 5), Route 3) and Cerulean's side (B1F's exit (24, 5), Cerulean) | every hop across Route 4 | `route_4s_sides_are_told_apart_by_the_doors_where_the_ground_cannot`, `route_4s_doors_and_sides_are_the_cartridges_from_the_badge_checkpoint` (ROM) | **fixed**: a `SPLIT` row |
| 59d | Mt. Moon B1F and B2F are one node each, though B1F is four chambers of two ladders and B2F three pieces; 1F has three ladders to B1F and only (5, 5) is the road, so a map-level hop sends the fly up dead ends and back | rung 13 (Cerulean) from the first floor | `the_road_from_pewter_to_cerulean_is_through_mt_moon_one_chamber_at_a_time`, `a_door_or_an_edge_lands_in_the_piece_it_opens_onto` | **fixed**: `SPLIT` rows; a piece lists its warps by index, a warp lands in the piece its destination warp is in (`wWarpEntries` byte 2), and the hop is a piece. The fly's own piece is what its walk reaches on the decoded grid, the nearest door where the grid (no ledges) reaches none; flooded over all four maps' ground, exact for every tile |
| 59e | four header connections have no tile where both sides are land -- Pallet / Route 21, Cinnabar / Route 20, Route 20 / 19 (sea), Route 22 / 23 (the League's fence) -- and the graph routed along them: from Pallet the road to Cerulean was by sea | whiting out in Mt. Moon, which the survey's fly did: `GO ROUTE` walked into Pallet's shore every 108 frames | `a_connection_nobody_can_walk_across_is_named_and_is_not_a_road` | **fixed**: `NO_CROSSING`: named, no exit, no hop. Route 22 / 23 go back into the table from the same list |
| 59f | a trainer's challenge closes onto five frames of plain overworld before `StartTrainerBattle` decides the battle; row 58's pending push-back was written on the first of them | Route 3's first trainer: (11, 6), the one gap between the road's west end and the rest of it, walled for the session | `a_challenge_closing_onto_a_few_frames_of_overworld_is_still_a_challenge` | **fixed**: a push-back is a refusal only after thirty frames running of the fly's overworld; a battle inside them drops it |
Measured and not the same kind, so left: Cerulean's south and east (Route 5, Route 9, the trashed
house's back door) are reached from the town only through the trashed house, which is off the
graph, and back over a ledge -- rung 17's road, with the Saffron gates and the Underground Path
behind it. Routes 5-8, 10-12, 15, 16, 18 and 23 are pieces joined by gate buildings off the graph,
and Rock Tunnel 1F is four pieces (rung 20). And many edges have walkable tiles past the strip
that lands on land: a walk aimed at
one is blocked and rests the whole edge for the window. On the road through rung 16 these are
Route 3's (62, 0) and (63, 0), Route 4's (6, 17) and Cerulean's (0, 13) and (25, 0), all beside the
landing tiles a walk from the road reaches first.
### Before and after
Route survey from the rank-11 checkpoint, 120,000 frames, `GO OBJECTIVE` and `TALK` preferred:
| measure | base `7784a9d` | branch |
| --- | ---: | ---: |
| `GO OBJECTIVE` done on Route 3 | **509** | 9 |
| `GO ROUTE` done on Pewter | **508** | 0 |
| Route 4 / Mt. Moon first | never / never | frame 70,356 / 70,707 |
| rung at the end | 11 | **12, MT. MOON** |
| pushed tiles at the end | (11, 6), (14, 6), (14, 9) on Route 3 | none |
From the live Route 4 checkpoint, 72,000 frames, rebased on `main` `4d82f7d` (v7):
| measure | base `4d82f7d` | branch |
| --- | ---: | ---: |
| map changes, `GO OBJECTIVE` preferred / uniform | **930 / 706** | 29 / 50 |
| `GO OBJECTIVE` done on Route 4, preferred / uniform | **465 / 183** | 3 / 2 |
| `GO ROUTE` done on the Pokécenter, preferred / uniform | **462 / 350** | 0 / 0 |
| into Mt. Moon, preferred / uniform | frame 541 / 826, and back out | frame 279 / 1,910 |
| rung at the end | 12 | 12 |
`the_fly_goes_into_mt_moon_from_the_live_route_4_checkpoint`, twenty brain minutes on the stub
rotation: the base crosses Route 4's west doors 56 times, 18 through the Pokécenter's and 38
through the cave's (fails); the branch 13, over three whiteouts and walks back (passes), in the
cave on frame 278.
The same survey from the live rank-11 checkpoint of 2026-09-22 (Pewter, the badge won):
the base stays on Route 3 with five pushed tiles fencing it, rank 11; the branch reaches Route 4 at
frame 118,621 and Mt. Moon at 118,972, rank 12, nothing pushed.
The ROM-gated run, `the_road_to_mt_moon_is_not_a_ring_at_the_pewter_end_from_the_badge_checkpoint`,
on the stub rotation: the base crosses between Pewter City and Route 3 **3,391** times in 80.4 brain
minutes and never stands on Route 4 (fails); the branch crosses 4 times, whites out twice on the
way, and is on Route 4 at frame 202,734 and at Mt. Moon's door at 203,232, 56.7 brain minutes
(passes). `route_4s_doors_and_sides_are_the_cartridges_from_the_badge_checkpoint` reads Route 4's
warp table off the cartridge -- (11, 5) to `$44`, (18, 5) to `$3b`, (24, 5) to `$3c`, the table's
doors in its order -- and the decoded grid puts the fly, arrived from Route 3 at (9, 17), on the cave
mouth's side.
**No trap hunt numbers.** Two 30-brain-minute stub arms were started from the badge checkpoint on
v0.5.5 with the box at load 35-40, had written nothing after two hours, and were stopped when the
live fly reached Route 4 and this row became the live priority; FND-01 has since changed the
hunt's frame, so hunts across the rebase would not compare (`9301e39`). The proof is the route
survey and the three ROM tests, which is a deviation from the ethos check's letter, recorded.
### Gates
- `cargo test --workspace --no-fail-fast` in release with `FLY_ROM`, `FLY_DATASET` and
`FLY_BADGE_CHECKPOINT`: 1,285 passed, 1 failed --
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
the known load-sensitive test (feed at 11.28 Hz with the box at load 35-40); the base fails it
too at the same load (the boot-time `total` assertion).
- `cargo clippy --workspace --all-targets`: **0 warnings**.
- `npm test` 663 passed; `npm run typecheck` clean.
- `infra/tests/lint.sh`: ALL CHECKS PASSED, the de-PII guard included.
- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had:
648 bytes, `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68` on
`4d82f7d` (v7). Decoder, reward catalog, adapter version and roles untouched.
## 2026-09-23, row 61: the forest's south gate, in and out
### What was live
Map 50 (`VIRIDIAN_FOREST_SOUTH_GATE`, `$32`) and Route 2's south half, rung 9, v0.5.5, about 20:45
to 21:05 UTC: `GO OBJECTIVE` into the gate (0.3 s), `GO OUT` back onto Route 2 (0.2 s), `GO WARP`
back out of the forest, `GO OBJECTIVE blocked` in the forest, `GO ROUTE` refused now and then; no
reward and about eight new tiles in twenty minutes. The checkpoint is the fly at (5, 1) in the
gate, under the forest's doorway, objective Pewter City; `next_hop` answers the forest, correctly.
### The survey: what walled the road
The route survey from the checkpoint (`FLY_PROBE_CATCH=route`, uniform choice per hold,
`FLY_PROBE_RNG=7`, 72,000 frames) walks the live ring and never leaves rung 9. Caught at the
refusal (`FLY_PROBE_CATCH_FRAME`, `FLY_PROBE_WHOLE=1`): the forest's only road to the north gate
is the corridor at x = 1-2 from row 22 up to row 0; the Bug Catcher of
`EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2` stands on (2, 18) facing west, and **(1, 18) was in the
pushed ledger**. It was written at frame 16,389: a `GO ITEM` walk stepped onto (1, 18), the trainer
took the joypad, and his text closed onto five frames with no box, no script bit and
`wCurOpponent` zero (`StartTrainerBattle` runs after `DisplayTextID`'s close-down). Row 58's held
entry was decided on the first of them. The "!" bubble before it, about sixty frames before
`wJoyIgnore` is set, reads the same way: about sixty-six free-looking frames per engagement. The pushed ledger has no window, so the north gate had no
road for the session; `GO OBJECTIVE` walked to the nearest reachable tile, (6, 1), a dead end, and
was blocked, and the last tiers walked the fly back to the south gate and Route 2. Every base
survey arm walls (1, 18); only a fly that has to come back up the corridor is trapped by it.
| # | trap | trigger | test | fix, or why it is left |
| --- | --- | --- | --- | --- |
| 61 | the "!" bubble and the frames between a sighted trainer's challenge text and `StartTrainerBattle` (about 66 per engagement) read as the fly's overworld: a pad is dealt, ground recorded, and row 58's held push-back written, walling the tile the trainer fired on for the session | any trainer whose line of sight a walk crosses; Viridian Forest's (1, 18), the one free tile of the corridor to the north gate | `a_trainers_challenge_is_the_cartridges_until_its_battle_is_over`, `the_frames_between_a_trainers_text_and_its_battle_deal_no_pad_and_record_no_ground`, `a_trainers_challenge_does_not_wall_the_road_to_the_forests_north_gate` (ROM) | **fixed** in the macro seam: `state::trainer_engaged` reads `wStatusFlags7` bit 3 (`BIT_TRAINER_BATTLE`, set by `CheckFightingMapTrainers`, cleared at `.battleOccurred`); `PokeState`'s `scene` is `Unknown` on an overworld frame with it set and `scripted` is true. `controllable` and `scene::detect` unchanged; in macros mode `game.scene` reads `unknown` on those frames. `docs/design/macros.md` 12.25 |
| 61b | the south gate deals `GO OUT` (back to Route 2) beside the forest door the objective's road takes | the "a room has to be leavable" tier in a gate whose way on is a passage | -- | **left**: a way back is the fly's choice; with the corridor open no survey stays on it |
### Before and after
The route survey, 72,000 frames (20.1 brain minutes), base `main` vs branch:
| driver | base | branch |
| --- | --- | --- |
| uniform, seed 7 (the live ring) | rung 9, 1,202 tiles, north gate never, (1, 18) walled | **BOULDER BADGE**, 1,647 tiles, north gate f20273, Pewter f23756 |
| uniform, default seed | rung 10, 1,356 | rung 11, 1,476 |
| `GO OBJECTIVE` preferred | rung 11, 1,529 | rung 12, 1,492 |
| `GO OUT`, `GO OBJECTIVE` preferred | rung 11, 1,376 | rung 11, 1,446 |
| uniform, seed 12345 | rung 11, 1,765 | identical |
(1, 18) is walled on every base arm and on no branch arm. The ROM-gated run (the seed-7 driver,
40,000 frames): base walled at frame 16,389, rung 9, **fails**; branch: north gate f20272, Route 2
f21209, Pewter f23755, passes.
The stub-readout trap hunt (30 brain minutes, service frame since FND-01; `main` `4d82f7d` vs
branch): both arms rung 10, 362 distinct tiles, 147 macros all done, 11 of 113 windows flagged.
**The stub does not walk the ring**: it passes the corridor northward and never comes back, so
the only difference is 134 overworld frames the branch reads `Unknown`, the trainer gaps. The
survey and the ROM test are the reproduction.
### Other gates
The north gate's `GO OUT` is toward Pewter (tier 2) and its forest door is withheld; the Route 2
gate, Diglett's Cave's Route 2 house and the Route 22 gate are off the graph (every door `LAST_MAP`
but Diglett's passage). The Route 22 gate is the one building with `LAST_MAP` on both sides of two
different maps (its script sets `wLastMap` by row: under 4 is Route 23); `outdoor_of` cannot name
both and will need a per-door answer when a rung routes through it. The trainer gap is not a gate
fact: every sighted trainer has it.
### Overlap
Row 59 (59f, merged in v0.6.1) found the same five frames on Route 3 and holds the push until
thirty frames of overworld; on main that alone keeps (1, 18) clear. This row is the cartridge-fact
layer under it: the pad is empty through the bubble too, no ground is recorded, and it does not
depend on the gap staying under thirty frames. The ROM test asserts no pad on an overworld frame
with the bit set, which row 59 alone does not meet.
### Gates
On `main` `510727c` (v0.6.1, row 59 merged):
- `cargo test --release -p flybrain-gb` with `FLY_ROM`: 461 passed, 0 failed.
- `rom_macros_mode` with the row-61 and row-59 checkpoints: row 61's test and row 59's three pass.
Row 61's test on the branch with `trainer_engaged` neutered (main's behaviour, row 59's settle
alone): the wall stays clear, and a pad is dealt on 67 of 67 challenge frames -- **fails**; on
the branch 0 of 67 -- passes.
- `cargo clippy --workspace --all-targets -- -D warnings`: clean.
- `npm test` 663 passed; `npm run typecheck` clean; `infra/tests/lint.sh` ALL CHECKS PASSED.
- `flysim --print-compatibility`, raw and macros: 648 bytes, sha256 `8ce67b97...a8f68`, the same
as `main`. Decoder, reward catalog, adapter version and roles untouched.

View file

@ -263,7 +263,7 @@ pct exec $CTID -- /opt/fly/bin/fly-reset-to-milestone $N
# (a) the running release already wrote that checkpoint -> nothing to deploy, skip to 5. # (a) the running release already wrote that checkpoint -> nothing to deploy, skip to 5.
# (b) the new build bumps the ADAPTER VERSION and nothing else -> name the checkpoint's # (b) the new build bumps the ADAPTER VERSION and nothing else -> name the checkpoint's
# adapter so the gate and flysim both migrate instead of refusing: # adapter so the gate and flysim both migrate instead of refusing:
FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 infra/05-deploy.sh <release-env> <release-tarball> FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 infra/05-deploy.sh <release-env> <release-tarball>
# The gate logs "the adapter version is the only difference, and it is named; the run is KEPT # The gate logs "the adapter version is the only difference, and it is named; the run is KEPT
# and migrated", and writes FLY_ACCEPT_ADAPTERS into /etc/fly/fly.env so flysim applies the # and migrated", and writes FLY_ACCEPT_ADAPTERS into /etc/fly/fly.env so flysim applies the
# same rule at restore. Anything else about the string differing is still a refusal. # same rule at restore. Anything else about the string differing is still a refusal.
@ -602,11 +602,14 @@ pct exec <ctid> -- cat /run/fly/wd/loop.json | jq .
| `fly_loop_refused` | macro presses refused in the window: a bound button pressed, nothing run | | `fly_loop_refused` | macro presses refused in the window: a bound button pressed, nothing run |
| `fly_loop_blocked` | macros that ended `blocked` or `timeout` in the window | | `fly_loop_blocked` | macros that ended `blocked` or `timeout` in the window |
| `fly_loop_done` | macros that ended `done` in the window | | `fly_loop_done` | macros that ended `done` in the window |
| `fly_loop_rewards` | reward events in the window |
The flag needs **both** halves: at most 3 distinct macro names with the block repeating 20+ The flag needs **both** halves: at most 3 distinct macro names with the block repeating 20+
times, one macro at 95%+ of the window's decisions, 90%+ of 20+ decisions ending refused, times, one macro at 95%+ of the window's decisions, 90%+ of 20+ decisions ending refused,
blocked or timed out (`stalled`), or decisions with no `done` among them on two probes in a row blocked or timed out (`stalled`), or decisions with no `done` among them on two probes in a row
(`zero-progress`) — **and** no growth in the exploration count. A decision is a `start` or a (`zero-progress`), or 100+ decisions with no reward event among them on two probes in a row
(`unrewarded`, row 58: `GO OBJECTIVE` in and `GO OUT` out of one door, diluted by eight other
names, every macro `done`) — **and** no growth in the exploration count. A decision is a `start` or a
`refused`: a refused press starts nothing, which is why counting starts alone read row 57's `refused`: a refused press starts nothing, which is why counting starts alone read row 57's
pad (`GO ROUTE refused` ~740 times in ten brain minutes, `macros-traps.md`) as one start and pad (`GO ROUTE refused` ~740 times in ten brain minutes, `macros-traps.md`) as one start and
one name. A one name. A

12
infra/env/example.env vendored
View file

@ -312,6 +312,13 @@ CHAT_DENY_LIST=/srv/fly/chat-deny.txt
# "palette" and "plan" are the two modes section 12 replaced; flysim still reads # "palette" and "plan" are the two modes section 12 replaced; flysim still reads
# either as "macros", with a warning, for one release. # either as "macros", with a warning, for one release.
FLY_MACRO_MODE=raw FLY_MACRO_MODE=raw
# --- feed path ----------------------------------------------------------------
# Who serves ws://127.0.0.1:7400/feed (docs/design/flybus.md, "Feed over the
# bus"). direct: flysim binds it, as always. bus: flysim publishes every
# snapshot on its embedded feed bus (/run/fly/bus) and flyedge.service serves
# the same bytes; enable that unit by hand (its header has the steps).
# Watchdog check 2 follows this setting to the edge's counters by itself.
FLY_FEED_VIA=direct
# How long a macro leaves a target alone after a walk to it aborted "blocked" or # How long a macro leaves a target alone after a walk to it aborted "blocked" or
# "timeout" (macros.md section 12.1). Session state, so a restart offers every # "timeout" (macros.md section 12.1). Session state, so a restart offers every
# target once more. Unset means the default, 10. # target once more. Unset means the default, 10.
@ -328,8 +335,9 @@ FLY_MACRO_MODE=raw
# two strings AND the new build's adapter declares a migration from that one. A # two strings AND the new build's adapter declares a migration from that one. A
# dataset, kernel, plasticity, emulator or state-format difference is still a # dataset, kernel, plasticity, emulator or state-format difference is still a
# refusal. Set it for the one deploy that needs it and leave it out afterwards; # refusal. Set it for the one deploy that needs it and leave it out afterwards;
# 05-deploy writes it into /etc/fly/fly.env only while it is set. # 05-deploy writes it into /etc/fly/fly.env only while it is set. The v0.6.0
# FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 # deploy (pokered-unique8-v7, the engagement rewards) is the one that needs:
# FLY_ACCEPT_ADAPTERS=pokered-unique8-v6
# --- push mode -------------------------------------------------------------- # --- push mode --------------------------------------------------------------
# local: flypush.service stays disabled, everything else identical to prod. # local: flypush.service stays disabled, everything else identical to prod.

View file

@ -154,6 +154,20 @@ require_release_tag() {
# (An earlier, eight-cpu version of this same live hotfix — CPUSET= # (An earlier, eight-cpu version of this same live hotfix — CPUSET=
# 1,3,5,7,9,11,13,15, ENCODER_CORES=2 (the default) — gave flysim=1,3,5,7, # 1,3,5,7,9,11,13,15, ENCODER_CORES=2 (the default) — gave flysim=1,3,5,7,
# page=9,11, flycast=13,15; infra/tests/lint.sh checks both shapes.) # page=9,11, flycast=13,15; infra/tests/lint.sh checks both shapes.)
# feed_via_normalize VALUE — print FLY_FEED_VIA lowercased (empty means "direct"), or
# return 1 for anything but direct|bus. flysim itself reads the value case-insensitively
# and refuses anything else at boot, which on a container is a restart loop; 05-deploy.sh
# refuses it at deploy instead and writes the lowercased word, so watchdog check 2 and
# flysim can never read the same line two ways (docs/design/flybus.md).
feed_via_normalize() {
local via
via="$(printf '%s' "${1:-direct}" | tr '[:upper:]' '[:lower:]')"
case "$via" in
direct|bus) printf '%s\n' "$via" ;;
*) return 1 ;;
esac
}
cpuset_partition() { cpuset_partition() {
local cpuset="$1" rayon_threads="$2" encoder_cores="${3:-2}" local cpuset="$1" rayon_threads="$2" encoder_cores="${3:-2}"
local sim_cpus remainder remainder_count page_count page_cpus encoder_cpus local sim_cpus remainder remainder_count page_count page_cpus encoder_cpus

View file

@ -429,6 +429,140 @@ else
fi fi
rm -rf "$lint_tmp" rm -rf "$lint_tmp"
# ---------------------------------------------------------------------------
# 3b2. The feed bus edge (docs/design/flybus.md, "Feed over the bus").
#
# flyedge.service is off unless the operator switches a container to
# FLY_FEED_VIA=bus by hand, and when it is on it must follow flysim, which
# owns the router. What would break that is statically visible: the unit
# ending up in fly.target or 07-enable's list, losing its ordering on
# flysim, or the deploy no longer writing the default. Watchdog check 2's
# choice of /metrics is driven for real against a fixture fly.env.
# ---------------------------------------------------------------------------
echo "--- flyedge.service: off by default, after and bound to flysim ---"
EDGE_UNIT="$INFRA_DIR/units/flyedge.service"
if [ ! -f "$EDGE_UNIT" ]; then
fail "units/flyedge.service is missing"
else
grep -qE '^After=.*\bflysim\.service\b' "$EDGE_UNIT" \
&& pass "flyedge.service orders itself After=flysim.service" \
|| fail "flyedge.service must be After=flysim.service: flysim owns the feed router"
grep -qE '^Requires=.*\bflysim\.service\b' "$EDGE_UNIT" \
&& pass "flyedge.service Requires=flysim.service" \
|| fail "flyedge.service must Require flysim.service, so a stop or restart of flysim takes the edge with it"
grep -qE '^ExecStart=/opt/fly/current/fly-edge$' "$EDGE_UNIT" \
&& pass "flyedge.service runs the release's fly-edge" \
|| fail "flyedge.service ExecStart must be /opt/fly/current/fly-edge"
grep -qE '^ConditionPathExists=/opt/fly/current/fly-edge$' "$EDGE_UNIT" \
&& pass "flyedge.service stays inactive on a release without fly-edge" \
|| fail "flyedge.service needs ConditionPathExists=/opt/fly/current/fly-edge (a release before it has none)"
grep -qE '^Environment=FLY_EDGE_METRICS_ADDR=127\.0\.0\.1:' "$EDGE_UNIT" \
&& pass "flyedge.service keeps its metrics on loopback" \
|| fail "flyedge.service FLY_EDGE_METRICS_ADDR must be a 127.0.0.1 address"
fi
# Every unit a target's Wants=/Requires= names, with backslash continuations joined and
# comments dropped: fly.target spreads both lists over several physical lines, and the
# continuation line is exactly where a new unit would be added.
target_pulls() {
awk '
/^[[:space:]]*[#;]/ { next }
{
line = $0
cont = sub(/\\[[:space:]]*$/, "", line)
buf = buf line
if (cont) next
if (buf ~ /^[[:space:]]*(Wants|Requires)=/) { sub(/^[^=]*=/, "", buf); print buf }
buf = ""
}
' "$1" | tr -s ' \t' '\n' | grep -v '^$' || true
}
if target_pulls "$INFRA_DIR/units/fly.target" | grep -qx 'flyedge.service'; then
fail "fly.target pulls flyedge.service in; it must stay off until the operator enables it"
else
pass "fly.target does not pull flyedge.service in"
fi
# The parser itself: a unit named only on a continuation line must be found, a commented one
# must not, and the real fly.target must still yield flysim.service.
tp_fixture="$(mktemp "${TMPDIR:-/tmp}/fly-lint-target.XXXXXX")"
cat > "$tp_fixture" <<'TPTARGET'
[Unit]
Wants=network-online.target xvfb.service \
flysim.service flyedge.service
# Requires=commented.service
Requires=xvfb.service \
pulse.service
TPTARGET
tp_units="$(target_pulls "$tp_fixture")"
if printf '%s\n' "$tp_units" | grep -qx 'flyedge.service' \
&& printf '%s\n' "$tp_units" | grep -qx 'pulse.service' \
&& ! printf '%s\n' "$tp_units" | grep -qx 'commented.service' \
&& target_pulls "$INFRA_DIR/units/fly.target" | grep -qx 'flysim.service'; then
pass "target_pulls reads continuation lines and skips comments (fixture + fly.target)"
else
fail "target_pulls missed a continuation line or read a comment: $(echo "$tp_units" | tr '\n' ' ')"
fi
rm -f "$tp_fixture"
if grep -E '^(ALWAYS_ON_UNITS|APP_UNITS)=' "$INFRA_DIR/07-enable.sh" "$INFRA_DIR/verify.sh" | grep -q 'flyedge'; then
fail "07-enable.sh or verify.sh lists flyedge.service as always-on"
else
pass "07-enable.sh and verify.sh leave flyedge.service alone"
fi
if grep -qF 'FLY_FEED_VIA_EFFECTIVE="$(feed_via_normalize "${FLY_FEED_VIA:-}")"' "$INFRA_DIR/05-deploy.sh" \
&& grep -qF 'echo "FLY_FEED_VIA=${FLY_FEED_VIA_EFFECTIVE}"' "$INFRA_DIR/05-deploy.sh"; then
pass "05-deploy.sh validates FLY_FEED_VIA and writes the normalized value"
else
fail "05-deploy.sh must run FLY_FEED_VIA through feed_via_normalize and write FLY_FEED_VIA_EFFECTIVE"
fi
# shellcheck source=../lib/common.sh
fv_out="$(bash -c '. "$1/lib/common.sh"
for v in "" direct DIRECT bus Bus BUS; do printf "%s=%s " "${v:-empty}" "$(feed_via_normalize "$v")"; done
for v in buss "bus " direct,bus; do feed_via_normalize "$v" >/dev/null && printf "ACCEPTED:%s " "$v"; done; true' _ "$INFRA_DIR" 2>&1)"
if [ "$fv_out" = "empty=direct direct=direct DIRECT=direct bus=bus Bus=bus BUS=bus " ]; then
pass "feed_via_normalize: direct|bus in any case, empty is direct, anything else refused"
else
fail "feed_via_normalize: got '$fv_out'"
fi
if grep -qE '^[[:space:]]*for u in flysim .*\bflyedge\b.*; do$' "$INFRA_DIR/05-deploy.sh"; then
pass "05-deploy.sh writes a cpuset drop-in for flyedge.service"
else
fail "05-deploy.sh cpuset loop must include flyedge (the page's CPUs, never flysim's)"
fi
if grep -qE '^Environment=FLY_FEED_VIA' "$INFRA_DIR/units/flysim.service"; then
fail "flysim.service pins FLY_FEED_VIA; it belongs to fly.env so a box can be switched by deploy"
else
pass "flysim.service leaves FLY_FEED_VIA to fly.env"
fi
echo "--- fly-watchdog check 2: the feed counters follow FLY_FEED_VIA ---"
if ! tail -n1 "$INFRA_DIR/bin/fly-watchdog" | grep -qE '^main "\$@"$'; then
fail "fly-watchdog: expected the last line to be 'main \"\$@\"' — the check-2 fixture strips it"
else
fe_fixture="$(mktemp -d "${TMPDIR:-/tmp}/fly-lint-edge.XXXXXX")"
sed '$d' "$INFRA_DIR/bin/fly-watchdog" > "$fe_fixture/wd.sh"
feed_url_case() {
local label="$1" env_line="$2" override="$3" want="$4" got
printf '%s\n' "$env_line" > "$fe_fixture/fly.env"
got="$(FLY_ENV_FILE="$fe_fixture/fly.env" FLY_FEED_METRICS_URL="$override" \
FLY_METRICS_URL=http://sim FLY_EDGE_METRICS_URL=http://edge \
WD_RUN_DIR="$fe_fixture/run" WD_STATE_DIR="$fe_fixture/state" \
TEXTFILE_DIR="$fe_fixture/textfile" \
bash -c "source '$fe_fixture/wd.sh'; feed_metrics_url" 2>&1 || true)"
if [ "$got" = "$want" ]; then
pass "check 2 feed metrics: $label -> $got"
else
fail "check 2 feed metrics: $label: got '$got', want '$want'"
fi
}
feed_url_case "direct" "FLY_FEED_VIA=direct" "" "http://sim"
feed_url_case "no FLY_FEED_VIA line (a fly.env before it)" "FLY_GAME=pokemon-red" "" "http://sim"
feed_url_case "bus" "FLY_FEED_VIA=bus" "" "http://edge"
feed_url_case "Bus (flysim lowercases)" "FLY_FEED_VIA=Bus" "" "http://edge"
feed_url_case "BUS" "FLY_FEED_VIA=BUS" "" "http://edge"
feed_url_case "quoted bus" 'FLY_FEED_VIA="bus"' "" "http://edge"
feed_url_case "explicit override wins" "FLY_FEED_VIA=bus" "http://other" "http://other"
rm -rf "$fe_fixture"
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 3c. lib/common.sh cpuset_partition — the three-way cpuset split used by # 3c. lib/common.sh cpuset_partition — the three-way cpuset split used by
# 05-deploy.sh section 3b (flysim / page-capture / flycast). Run as its own # 05-deploy.sh section 3b (flysim / page-capture / flycast). Run as its own
@ -1065,10 +1199,51 @@ LPCAT
fail "check 10: the zero-progress case gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected), journal: $(cat "$lp_fixture/journal.log")" fail "check 10: the zero-progress case gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected), journal: $(cat "$lp_fixture/journal.log")"
fi fi
# (7) Row 58: the gym door, in and out. GO OBJECTIVE / GO OUT diluted by
# eight other names, every macro `done`, no reward event, no new ground --
# neither the four-name sequence rule, dominance nor zero-progress fires.
# Two probes of it flag; the same window with one reward in it does not.
lp_reset
lp_cycle 20 "GO OBJECTIVE" "GO OUT" "GO OUT" "GO ITEM" "GO OBJECTIVE" "GO OUT" \
"GO FRONTIER" "YES" "NO" "GO ROUTE" "NEXT" "TALK" "GO SHOP" \
| lp_outcomes "$lp_fixture/events.jsonl" "done"
lp_status "$lp_fixture/status.json" 1892
lp_pass
lp_pass
lp_first="$(lp_metric fly_loop_suspected)"
lp_pass
if [ "$lp_first" = "0" ] && [ "$(lp_metric fly_loop_suspected)" = "1" ] \
&& [ "$(lp_metric fly_loop_rewards)" = "0" ] \
&& [ "$(lp_metric fly_loop_distinct_macros)" = "10" ] \
&& grep -q 'loop suspected (unrewarded): 260 decisions and no reward event' "$lp_fixture/journal.log"; then
pass "check 10: an undo pair diluted by eight other names, all done, no reward over two probes flags as unrewarded"
else
fail "check 10: the row-58 log gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected) rewards=$(lp_metric fly_loop_rewards) distinct=$(lp_metric fly_loop_distinct_macros), journal: $(cat "$lp_fixture/journal.log")"
fi
if lp_report="$(jq -e -r '[.reason, (.window.decisions|tostring), (.window.rewards|tostring), .action] | join(" ")' "$lp_fixture/run/loop.json" 2>/dev/null)" \
&& [ "$lp_report" = "unrewarded 260 0 none" ]; then
pass "check 10: loop.json carries the reward events in the window"
else
fail "check 10: loop.json read back as '${lp_report:-UNREADABLE}' — expected 'unrewarded 260 0 none'"
fi
lp_reset
{ cat "$lp_fixture/events.jsonl"
printf '{"id":999998,"wallMs":1758000999998,"brainMs":150000,"kind":"reward","label":"WILD KO 54:1:1","value":0.1,"rewardKind":"wildwin"}\n'; } \
> "$lp_fixture/events-rewarded.jsonl"
mv -f "$lp_fixture/events-rewarded.jsonl" "$lp_fixture/events.jsonl"
lp_pass
lp_pass
lp_pass
if [ "$(lp_metric fly_loop_suspected)" = "0" ] && [ "$(lp_metric fly_loop_rewards)" = "1" ]; then
pass "check 10: the same busy window with one reward in it does not flag"
else
fail "check 10: a rewarded busy window gave suspected=$(lp_metric fly_loop_suspected) rewards=$(lp_metric fly_loop_rewards)"
fi
# The ethos, asserted rather than reviewed: over every case above, check 10 # The ethos, asserted rather than reviewed: over every case above, check 10
# restarted nothing. It reports; a human or a review agent decides. # restarted nothing. It reports; a human or a review agent decides.
if [ ! -s "$lp_fixture/systemctl.log" ]; then if [ ! -s "$lp_fixture/systemctl.log" ]; then
pass "check 10: never acts — no unit was restarted across any of the six cases" pass "check 10: never acts — no unit was restarted across any of the eight cases"
else else
fail "check 10 ACTED, which it must never do: $(cat "$lp_fixture/systemctl.log")" fail "check 10 ACTED, which it must never do: $(cat "$lp_fixture/systemctl.log")"
fi fi
@ -1308,6 +1483,13 @@ else
echo " mention is genuinely legitimate, add it to infra/tests/de-pii-allow.txt with a reason." >&2 echo " mention is genuinely legitimate, add it to infra/tests/de-pii-allow.txt with a reason." >&2
fi fi
echo "--- loop recovery tests ---"
if python3 -m unittest discover -s "$REPO_ROOT/infra/tests" -p 'test_loop_recover.py' >/dev/null 2>&1; then
pass "loop recovery: ladder, budget, reboot-safe state, model delay and fallback, splash notice"
else
fail "loop recovery tests failed; run python3 -m unittest discover -s infra/tests -p test_loop_recover.py -v"
fi
echo "===" echo "==="
if [ "$FAILED" -eq 0 ]; then if [ "$FAILED" -eq 0 ]; then
echo "lint.sh: ALL CHECKS PASSED" echo "lint.sh: ALL CHECKS PASSED"

View file

@ -0,0 +1,356 @@
import importlib.machinery
import importlib.util
import json
from pathlib import Path
import re
import tempfile
import unittest
from unittest.mock import patch
repo = Path(__file__).resolve().parents[2]
script = repo / "infra/bin/fly-loop-recover"
spec = importlib.util.spec_from_loader("recover", importlib.machinery.SourceFileLoader("recover", str(script)))
recover = importlib.util.module_from_spec(spec)
spec.loader.exec_module(recover)
RESTORABLE_RUNGS = recover.restorable_rungs
WRAPPER = repo / "infra/bin/fly-loop-reset"
T0 = 1_790_000_000
class RecoveryTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
root = Path(self.temp.name)
self.root = root
for name, value in (("REPORT", "loop.json"), ("STATE", "var/state.json"),
("HISTORY", "var/history.jsonl"), ("NOTICE", "run/notice.json")):
patcher = patch.object(recover, name, root / value)
patcher.start()
self.addCleanup(patcher.stop)
milestones = root / "state"
milestones.mkdir()
for rung in (1, 9, 10, 11, 12):
(milestones / f"milestone-{rung}.checkpoint").write_text("x")
patcher = patch.object(recover, "MILESTONES", milestones)
patcher.start()
self.addCleanup(patcher.stop)
self.env = patch.dict("os.environ", {}, clear=False)
self.env.start()
self.addCleanup(self.env.stop)
for key in ("FLY_LOOP_ROUTER_URL", "FLY_LOOP_MODELS", "FLY_LOOP_MODEL", "FLY_LOOP_ROUTER_KEY"):
recover.os.environ.pop(key, None)
self.acts = []
self.unrestorable = set()
restorable = patch.object(recover, "restorable_rungs", side_effect=lambda: {1, 9, 10, 11, 12} - self.unrestorable)
restorable.start()
self.addCleanup(restorable.stop)
self.act = patch.object(recover, "act", side_effect=lambda action, target: self.acts.append((action, target)) or True)
self.act.start()
self.addCleanup(self.act.stop)
self.report(suspected=1, at=T0)
def report(self, **fields):
base = {"suspected": 1, "action": "none", "reason": "unrewarded", "sequence": ["GO WARP"],
"milestone": {"rank": 12, "label": "MT. MOON"}, "map": 61}
base.update(fields)
if "rank" in fields:
base["milestone"] = {"rank": base.pop("rank"), "label": "X"}
recover.REPORT.write_text(json.dumps(base))
def tick(self, at, **fields):
"""One watchdog probe at `at` and the timer running right after it."""
self.report(at=at, **fields)
return recover.run(at + 5, sleep=lambda seconds: None)
def confirm(self, start, **fields):
"""Two probes ~5 min apart after `start`; returns the second decision."""
self.assertIn("second probe", self.tick(start, **fields))
return self.tick(start + 300, **fields)
def test_ladder_restart_then_current_rung_then_rung_below(self):
self.assertIn("flysim restarted", self.confirm(T0))
self.assertEqual(self.acts, [("restart", None)])
self.assertIn("settling", self.tick(T0 + 600))
self.assertIn("reset to rung 12", self.confirm(T0 + 300 + recover.SETTLE + 10))
start = T0 + 2 * (300 + recover.SETTLE + 10)
self.assertIn("reset to rung 11", self.confirm(start, rank=12))
self.assertEqual([a for a in self.acts], [("restart", None), ("reset", 12), ("reset", 11)])
def test_spent_reset_budget_holds_to_restarts_three_hours_apart(self):
at = T0
for _ in range(3):
self.confirm(at)
at += 300 + recover.SETTLE + 10
self.assertEqual(len(self.acts), 3)
self.assertIn("holding", self.confirm(at))
# the trap never cleared while holding, so the first probe after the hold acts
self.assertIn("flysim restarted", self.tick(at + recover.HOLD))
self.assertEqual(self.acts[-1], ("restart", None))
def test_deeper_resets_never_go_below_the_rung_under_the_best(self):
state = {"level": 5, "bestRank": 12, "resets": [], "actedAt": 0}
recover.write_json(recover.STATE, state)
self.confirm(T0, rank=11)
self.assertEqual(self.acts, [("reset", 11)])
def test_an_unrestorable_rung_below_the_best_means_a_restart_never_a_lower_rung(self):
self.unrestorable = {11}
recover.write_json(recover.STATE, {"level": 2, "bestRank": 12, "resets": [], "actedAt": 0})
self.confirm(T0)
self.assertEqual(self.acts, [("restart", None)])
def test_a_fly_already_below_the_rung_under_its_best_is_restarted_not_reset(self):
recover.write_json(recover.STATE, {"level": 2, "bestRank": 12, "resets": [], "actedAt": 0})
self.confirm(T0, rank=10)
self.assertEqual(self.acts, [("restart", None)])
def test_no_restorable_archive_means_a_restart(self):
self.unrestorable = {1, 9, 10, 11, 12}
recover.write_json(recover.STATE, {"level": 1, "bestRank": 12, "resets": [], "actedAt": 0})
self.confirm(T0)
self.assertEqual(self.acts, [("restart", None)])
def test_new_best_rung_starts_the_ladder_over(self):
recover.write_json(recover.STATE, {"level": 2, "bestRank": 12, "resets": [], "actedAt": 0})
self.confirm(T0, rank=13)
self.assertEqual(self.acts, [("restart", None)])
def test_quiet_hours_start_the_ladder_over(self):
recover.write_json(recover.STATE, {"level": 2, "bestRank": 12, "actedAt": 0,
"lastSuspectedAt": T0 - recover.QUIET - 1})
self.confirm(T0)
self.assertEqual(self.acts, [("restart", None)])
def test_stale_clear_or_acted_reports_never_act(self):
self.report(at=T0 - 700)
self.assertIn("not a fresh", recover.run(T0))
self.report(suspected=0, at=T0)
self.assertIn("not a fresh", recover.run(T0 + 1))
self.report(at=T0, action="restart")
self.assertIn("not a fresh", recover.run(T0 + 2))
self.assertEqual(self.acts, [])
def test_a_clear_probe_breaks_the_streak(self):
self.tick(T0)
self.tick(T0 + 300, suspected=0)
self.assertIn("second probe", self.tick(T0 + 600))
self.assertEqual(self.acts, [])
def test_state_survives_a_reboot_and_ignores_a_missing_or_corrupt_file(self):
self.confirm(T0)
self.assertTrue(recover.STATE.exists())
self.assertEqual(json.loads(recover.STATE.read_text())["level"], 1)
recover.STATE.write_text("{nope")
self.assertIn("second probe", self.tick(T0 + 5000))
def test_model_vetoes_delay_a_step_but_never_deny_it(self):
with patch.object(recover, "verdict", return_value=(False, "m")):
self.tick(T0)
for n in range(1, recover.VETO_LIMIT + 1):
self.assertIn(f"({n}/{recover.VETO_LIMIT})", self.tick(T0 + 300 * n))
self.assertIn("veto limit reached", self.tick(T0 + 300 * (recover.VETO_LIMIT + 1)))
self.assertEqual(self.acts, [("restart", None)])
def test_no_model_answer_falls_back_to_the_watchdog(self):
with patch.object(recover, "verdict", return_value=(None, None)):
self.assertIn("watchdog alone", self.confirm(T0))
def test_models_are_tried_in_order_until_one_answers(self):
recover.os.environ.update(FLY_LOOP_ROUTER_URL="http://router/v1", FLY_LOOP_MODELS="a, b ,c")
replies = iter([OSError("429"), {"choices": [{"message": {"content": "thinking...\n```json\n{\"stuck\": true}\n```"}}]}])
asked = []
class Reply:
def __init__(self, body):
self.body = body
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return json.dumps(self.body).encode()
def fake(request, timeout):
asked.append(json.loads(request.data)["model"])
reply = next(replies)
if isinstance(reply, Exception):
raise reply
return Reply(reply)
with patch.object(recover, "urlopen", side_effect=fake):
self.assertEqual(recover.verdict({"reason": "unrewarded"}), (True, "b"))
self.assertEqual(asked, ["a", "b"])
def test_parse_stuck_tolerates_prose_and_rejects_non_booleans(self):
self.assertIs(recover.parse_stuck('{"stuck":false}'), False)
self.assertIs(recover.parse_stuck('Answer: {"stuck": true} done'), True)
self.assertIsNone(recover.parse_stuck('{"stuck": "yes"}'))
self.assertIsNone(recover.parse_stuck(""))
self.assertIsNone(recover.parse_stuck(None))
def test_notice_goes_countdown_acting_done_for_the_splash(self):
phases = []
real = recover.notice
with patch.object(recover, "notice", side_effect=lambda base, phase, now: phases.append(phase) or real(base, phase, now)):
self.confirm(T0 + 300 + recover.SETTLE)
recover.write_json(recover.STATE, dict(json.loads(recover.STATE.read_text()), actedAt=0))
self.confirm(T0 + 2 * (300 + recover.SETTLE))
notice = json.loads(recover.NOTICE.read_text())
self.assertEqual(phases, ["countdown", "acting", "done"] * 2)
self.assertEqual((notice["v"], notice["action"], notice["toRung"], notice["toLabel"], notice["fromRung"]),
(1, "reset", 12, "MT. MOON", 12))
self.assertEqual(notice["executeAt"] - notice["announcedAt"], recover.COUNTDOWN)
def test_failed_action_is_reported_and_still_climbs(self):
self.act.stop()
with patch.object(recover, "act", return_value=False):
self.assertIn("FAILED", self.confirm(T0))
self.act.start()
self.assertEqual(json.loads(recover.NOTICE.read_text())["phase"], "failed")
self.assertEqual(json.loads(recover.STATE.read_text())["level"], 1)
def test_a_step_that_raises_or_times_out_is_a_failed_step_with_state_saved(self):
self.act.stop()
with patch.object(recover.subprocess, "run", side_effect=recover.subprocess.TimeoutExpired("sudo", 1)):
self.assertIn("FAILED", self.confirm(T0))
self.act.start()
self.assertEqual(json.loads(recover.NOTICE.read_text())["phase"], "failed")
self.assertEqual(json.loads(recover.STATE.read_text())["level"], 1)
def test_state_is_saved_before_the_step_runs(self):
seen = []
self.act.stop()
with patch.object(recover, "act", side_effect=lambda a, t: seen.append(json.loads(recover.STATE.read_text())) or True):
self.confirm(T0)
self.act.start()
self.assertEqual(seen[0]["level"], 1)
self.assertNotIn("observedAt", seen[0])
def test_restorable_rungs_parses_the_list_and_is_empty_on_any_failure(self):
ok = recover.subprocess.CompletedProcess([], 0, stdout="11\n12\n", stderr="")
with patch.object(recover.subprocess, "run", return_value=ok):
self.assertEqual(RESTORABLE_RUNGS(), {11, 12})
with patch.object(recover.subprocess, "run", side_effect=OSError("no sudo")):
self.assertEqual(RESTORABLE_RUNGS(), set())
with patch.object(recover.subprocess, "run", return_value=recover.subprocess.CompletedProcess([], 1, "12", "")):
self.assertEqual(RESTORABLE_RUNGS(), set())
def test_history_records_every_step(self):
self.confirm(T0)
lines = [json.loads(line) for line in recover.HISTORY.read_text().splitlines()]
self.assertEqual(lines[-1]["event"], "restart")
self.assertTrue(lines[-1]["ok"])
def test_ladder_labels_match_the_rust_table(self):
source = (repo / "services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs").read_text()
table = re.search(r"RANK_LADDER: \[&str; \d+\] = \[(.*?)\];", source, re.S).group(1)
self.assertEqual(recover.LADDER, re.findall(r'"([^"]*)"', table))
BUILD = "lif-1ms-f64-v2/pokered-unique8-v7/abc/fly-kc-mbon-rstdp-v2"
@unittest.skipIf(recover.os.geteuid() == 0, "the wrapper ignores test overrides as root")
class WrapperTests(unittest.TestCase):
"""infra/bin/fly-loop-reset against a fake flysim, systemctl, reset tool and archives."""
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
root = Path(self.temp.name)
self.root = root
self.calls = root / "calls.log"
(root / "state").mkdir()
(root / "bin").mkdir()
# The watchdog probe is mid-run ("activating") for the first two looks.
self.script(root / "bin/systemctl", f'''echo "systemctl $*" >> {self.calls}
if [ "$1" = show ]; then
n=$(grep -c "^systemctl show" {self.calls})
if [ "$n" -le 2 ]; then echo activating; else echo inactive; fi
fi''')
self.script(root / "flysim", f'[ "$1" = --print-compatibility ] && echo "{BUILD}"')
self.script(root / "reset", f'echo "reset $* FLY_BIN=$FLY_BIN" >> {self.calls}')
self.env_file = root / "fly.env"
self.env_file.write_text('FLY_MACRO_MODE=macros\nGAME_TITLE=Pokemon (Red) $(touch pwned)\n')
self.archive(12, BUILD)
self.archive(11, BUILD.replace("-v7", "-v6"))
self.archive(1, BUILD.replace("-v7", "-v5"))
def script(self, path, body):
path.write_text("#!/bin/sh\n" + body + "\n")
path.chmod(0o755)
def archive(self, rung, compat):
(self.root / f"state/milestone-{rung}.checkpoint").write_bytes(
b"FLYSIM01" + json.dumps({"generation": 1, "compatibility": compat}).encode() + b"\x00" * 64)
def run_wrapper(self, *args):
env = {"PATH": f"{self.root}/bin:/usr/bin:/bin", "FLY_LOOP_RESET_TEST_STATE_DIR": str(self.root / "state"),
"FLY_LOOP_RESET_TEST_ENV_FILE": str(self.env_file), "FLY_LOOP_RESET_TEST_FLYSIM": str(self.root / "flysim"),
"FLY_LOOP_RESET_TEST_RESET_BIN": str(self.root / "reset")}
env.update(getattr(self, "extra_env", {}))
return recover.subprocess.run([str(WRAPPER), *args], env=env, capture_output=True, text=True, timeout=60)
def log(self):
return self.calls.read_text() if self.calls.exists() else ""
def test_list_names_only_rungs_this_build_restores(self):
self.assertEqual(self.run_wrapper("--list").stdout.split(), ["12"])
self.env_file.write_text("FLY_ACCEPT_ADAPTERS=pokered-unique8-v6\n")
self.assertEqual(self.run_wrapper("--list").stdout.split(), ["11", "12"])
self.assertFalse((self.root / "pwned").exists())
def test_reset_pauses_the_watchdog_and_always_starts_flysim_again(self):
done = self.run_wrapper("12")
self.assertEqual(done.returncode, 0, done.stderr)
self.assertEqual([line.split()[:3] for line in self.log().splitlines() if not line.startswith("systemctl is-active")], [
["systemctl", "stop", "fly-watchdog.timer"],
["systemctl", "show", "-p"], ["systemctl", "show", "-p"], ["systemctl", "show", "-p"], ["systemctl", "show", "-p"],
["systemctl", "stop", "flysim.service"],
["reset", "12", f"FLY_BIN={self.root}/flysim"],
["systemctl", "start", "flysim.service"], ["systemctl", "start", "fly-watchdog.timer"]])
def test_a_failed_reset_still_starts_flysim_and_the_watchdog(self):
self.script(self.root / "reset", f'echo "reset $*" >> {self.calls}; exit 7')
self.assertEqual(self.run_wrapper("12").returncode, 7)
self.assertIn("systemctl start flysim.service", self.log())
self.assertIn("systemctl start fly-watchdog.timer", self.log())
def test_an_unrestorable_or_missing_rung_touches_nothing(self):
for rung in ("11", "5"):
self.assertEqual(self.run_wrapper(rung).returncode, 3)
self.assertEqual(self.log(), "")
def test_no_build_compatibility_means_nothing_is_restorable(self):
(self.root / "flysim").unlink()
self.assertEqual(self.run_wrapper("--list").stdout, "")
self.assertEqual(self.run_wrapper("12").returncode, 3)
self.assertEqual(self.log(), "")
def test_a_root_copy_that_is_not_the_running_build_restores_nothing(self):
other = self.root / "live-flysim"
self.script(other, "echo other build")
self.extra_env = {"FLY_LOOP_RESET_TEST_LIVE_FLYSIM": str(other)}
self.assertEqual(self.run_wrapper("--list").stdout, "")
self.assertEqual(self.run_wrapper("12").returncode, 3)
def test_a_watchdog_probe_that_never_finishes_blocks_the_reset(self):
self.script(self.root / "bin/systemctl", f'echo "systemctl $*" >> {self.calls}; [ "$1" != show ] || echo activating')
self.script(self.root / "bin/sleep", "exit 0")
self.assertEqual(self.run_wrapper("12").returncode, 4)
self.assertNotIn("systemctl stop flysim.service", self.log())
self.assertIn("systemctl start fly-watchdog.timer", self.log())
def test_bad_arguments_are_refused(self):
for args in ((), ("--check", "12"), ("12", "13"), ("../12",), ("123",), ("-1",)):
self.assertEqual(self.run_wrapper(*args).returncode, 2, args)
self.assertEqual(self.log(), "")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,13 @@
[Unit]
Description=Recover confirmed macro loops: flysim restart, then milestone resets
[Service]
Type=oneshot
User=fly
EnvironmentFile=-/etc/fly/loop-recovery.env
# The ladder's state and history outlive a reboot (/run does not).
StateDirectory=fly-loop-recover
# A step: the rung list (<=2 min), up to 90 s of model calls, a 60 s on-stream countdown,
# the reset (<=8 min) and 4 min to verify. systemd must never kill the wrapper mid-reset.
TimeoutStartSec=25min
ExecStart=/opt/fly/bin/fly-loop-recover

View file

@ -0,0 +1,11 @@
[Unit]
Description=Check for repeated macro loops after watchdog probes
[Timer]
OnBootSec=7min
OnUnitActiveSec=5min
AccuracySec=10s
Unit=fly-loop-recover.service
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,56 @@
# infra/units/flyedge.service — pushed to /etc/systemd/system/flyedge.service.
#
# The feed WebSocket served from flysim's feed bus (docs/design/flybus.md,
# "Feed over the bus"; services/flysim/crates/fly-edge). DISABLED BY DEFAULT:
# it is in no target's Wants=/Requires= and 07-enable.sh does not enable it.
# With FLY_FEED_VIA=direct (the default, written into /etc/fly/fly.env by
# 05-deploy.sh) flysim binds 127.0.0.1:7400 itself and this unit has nothing
# to do. To move the feed onto the bus on one container:
#
# 1. FLY_FEED_VIA=bus in the env file, then 05-deploy.sh (rewrites fly.env);
# 2. systemctl enable --now flyedge.service; systemctl restart flysim.service
# (flysim stops binding :7400, the edge binds it once the first snapshot
# is on the bus);
# 3. nothing for the watchdog: check 2 reads FLY_FEED_VIA from fly.env and
# follows the feed counters to this unit's loopback /metrics.
#
# Back: FLY_FEED_VIA=direct, deploy, systemctl disable --now flyedge.service,
# restart flysim.
#
# Ordering (docs/design/flybus.md, amendment "Feed store lifecycle"): flysim
# owns the router and its store under /run/fly/bus, so it starts first and
# the edge follows it. Requires= makes an explicit stop or restart of flysim
# (the unstick rule's `systemctl restart flysim.service` included) stop or
# restart the edge with it. A crash-restart of flysim is covered by the edge
# itself: it drops its clients, unbinds :7400 and reconnects every 500 ms,
# so nothing here has to be restarted by hand. The edge holds no state; the
# store is flysim's and a new router removes the previous one's directory.
[Unit]
Description=flyedge: the feed WebSocket served from flysim's feed bus
After=flysim.service
Requires=flysim.service
# A release that predates fly-edge has no binary; stay cleanly inactive
# rather than restart-looping (the flybridge.service header explains why a
# Condition, not a start limit).
ConditionPathExists=/opt/fly/current/fly-edge
[Service]
Type=simple
User=fly
# FLY_FEED_VIA, FLY_BUS_DIR and the rest of flysim's configuration: the edge
# reads the same file so the two cannot disagree about the port or the bus.
EnvironmentFile=/etc/fly/fly.env
Environment=FLY_FEED_BIND=127.0.0.1:7400
Environment=FLY_BUS_DIR=/run/fly/bus
# Its own read-only /metrics and /healthz, for watchdog check 2 in bus mode.
# Loopback only: nothing off the container needs the edge's counters.
Environment=FLY_EDGE_METRICS_ADDR=127.0.0.1:9102
ExecStart=/opt/fly/current/fly-edge
Restart=always
RestartSec=2
# A few snapshots in flight and a WebSocket per client; the store itself is
# flysim's (tmpfs, bounded by feedbus::limits at 32 MiB).
MemoryMax=256M
[Install]
WantedBy=fly.target

View file

@ -30,6 +30,10 @@ WatchdogSec=30
User=fly User=fly
EnvironmentFile=/etc/fly/fly.env EnvironmentFile=/etc/fly/fly.env
Environment=FLY_FEED_BIND=127.0.0.1:7400 Environment=FLY_FEED_BIND=127.0.0.1:7400
# Used only with FLY_FEED_VIA=bus (fly.env; default direct): the embedded
# feed router's socket and artifact store, on tmpfs. flyedge.service names
# the same directory. docs/design/flybus.md, "Feed over the bus".
Environment=FLY_BUS_DIR=/run/fly/bus
Environment=FLY_CONTROL_BIND=127.0.0.1:7401 Environment=FLY_CONTROL_BIND=127.0.0.1:7401
Environment=FLY_METRICS_ADDR=0.0.0.0:9101 Environment=FLY_METRICS_ADDR=0.0.0.0:9101
Environment=FLY_STATE_HOT=/run/fly/state Environment=FLY_STATE_HOT=/run/fly/state

View file

@ -416,6 +416,25 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "fly-edge"
version = "0.1.1"
dependencies = [
"anyhow",
"axum",
"clap",
"flate2",
"flybus",
"flysim",
"futures-util",
"serde_json",
"tempfile",
"tokio",
"tokio-tungstenite",
"tracing",
"tracing-subscriber",
]
[[package]] [[package]]
name = "fly-session" name = "fly-session"
version = "0.1.1" version = "0.1.1"
@ -484,10 +503,12 @@ dependencies = [
"fly-session-types", "fly-session-types",
"flybrain-core", "flybrain-core",
"flybrain-gb", "flybrain-gb",
"flybus",
"futures-util", "futures-util",
"jsonschema", "jsonschema",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",

View file

@ -1,6 +1,7 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = [ members = [
"crates/fly-edge",
"crates/fly-session", "crates/fly-session",
"crates/fly-session-types", "crates/fly-session-types",
"crates/flybrain-core", "crates/flybrain-core",

View file

@ -0,0 +1,36 @@
[package]
name = "fly-edge"
version.workspace = true
edition = "2024"
rust-version.workspace = true
license.workspace = true
publish = false
description = "The feed WebSocket (:7400) served from flysim's feed bus (FLY_FEED_VIA=bus)."
[lib]
name = "fly_edge"
path = "src/lib.rs"
[[bin]]
name = "fly-edge"
path = "src/main.rs"
[dependencies]
# The feed server and the bus encoding are flysim's own modules (`feed`, `feedbus`,
# `snapshot`), so the edge writes the WebSocket bytes with the code flysim uses in direct mode.
flysim = { path = "../flysim" }
flybus = { path = "../flybus" }
anyhow = "1.0"
axum = { version = "0.8", features = ["ws"] }
clap = { version = "4.5", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "time", "signal", "macros"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
flate2 = { workspace = true }
futures-util = "0.3"
serde_json = { workspace = true }
tempfile = "3"
tokio-tungstenite = "0.29"

View file

@ -0,0 +1,344 @@
//! `fly-edge`: the feed WebSocket, served from flysim's feed bus.
//!
//! With `FLY_FEED_VIA=bus` flysim does not bind the feed port. It publishes every snapshot on an
//! embedded flybus router (`flysim::feedbus`), and this process subscribes and serves
//! `ws://<feed.bind>/feed` to the stage, the bridge and tests. The contract is still
//! `docs/feed-protocol.md`, byte for byte: the snapshots come off the bus as the same
//! `flysim::snapshot::Snapshot` values and are written by the same `flysim::feed` server, so the
//! per-client `hello`, `wants`, drop-oldest and idle cadence are flysim's own code.
//!
//! Lifecycle (`docs/design/flybus.md`, amendment "Feed store lifecycle"):
//!
//! - the feed port is bound only once the first snapshot has arrived, so before that a client
//! is refused exactly as it would be by a flysim that has not started;
//! - when the bus goes away (flysim stopped or restarted) the edge drops every client and unbinds
//! the port, again exactly what a stopped flysim looks like to the stage, then reconnects every
//! `retry` until a router answers. It never serves a stale snapshot as if it were live.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::get;
use flybus::{Client, ClientConfig, SubscriptionConfig};
use flysim::feed::{self, FeedState};
use flysim::feedbus;
use flysim::metrics::{Metrics, metric};
use tokio::sync::{oneshot, watch};
/// What the edge needs to know. Built from flysim's own configuration, so both processes read
/// one environment file and cannot disagree about the port, the bus directory or the cadence.
#[derive(Debug, Clone)]
pub struct EdgeConfig {
/// `feed.bus_dir`: the router's socket and store root.
pub bus_dir: PathBuf,
/// `feed.bind`, the port flysim leaves alone in bus mode.
pub feed_bind: SocketAddr,
/// `1 / loop.idle_snapshot_hz`, the protocol's header-only cadence.
pub idle_period: Duration,
/// `FLY_EDGE_METRICS_ADDR`: `/metrics` and `/healthz` for the watchdog, when set.
pub metrics_bind: Option<SocketAddr>,
/// Delay between attempts to reach the bus.
pub retry: Duration,
}
impl EdgeConfig {
pub fn from_flysim(config: &flysim::config::Config, metrics_bind: Option<SocketAddr>) -> Self {
Self {
bus_dir: config.feed.bus_dir.clone(),
feed_bind: config.feed.bind,
idle_period: config.publish_periods().1,
metrics_bind,
retry: Duration::from_millis(500),
}
}
}
/// The edge's counters. `feed` is the same `Metrics` type flysim uses, so
/// `fly_frames_sent_total` and `fly_feed_clients` mean exactly what they mean there.
#[derive(Debug, Default)]
pub struct EdgeMetrics {
pub feed: Arc<Metrics>,
/// Snapshots taken off the bus and handed to the feed server.
pub snapshots: AtomicU64,
/// 1 while subscribed and serving.
pub connected: AtomicU64,
/// Times a serving session ended because the bus went away.
pub bus_lost: AtomicU64,
/// Publications that could not be turned back into a snapshot.
pub decode_failures: AtomicU64,
/// Sessions that reached the bus but could not bind the feed port.
pub bind_failures: AtomicU64,
}
impl EdgeMetrics {
pub fn render(&self) -> String {
let mut out = String::with_capacity(1_024);
let feed = &self.feed;
metric(
&mut out,
"fly_frames_sent_total",
"counter",
"Feed snapshots written to a client socket.",
Metrics::get(&feed.frames_sent),
);
metric(
&mut out,
"fly_feed_clients",
"gauge",
"Feed clients currently subscribed.",
feed.clients(),
);
metric(
&mut out,
"fly_feed_dropped_total",
"counter",
"Snapshots superseded before a slow client could be sent them.",
Metrics::get(&feed.feed_dropped),
);
metric(
&mut out,
"fly_edge_snapshots_total",
"counter",
"Snapshots taken off the feed bus.",
self.snapshots.load(Ordering::Relaxed),
);
metric(
&mut out,
"fly_edge_bus_connected",
"gauge",
"1 while the edge is subscribed to the feed bus and serving.",
self.connected.load(Ordering::Relaxed),
);
metric(
&mut out,
"fly_edge_bus_lost_total",
"counter",
"Serving sessions ended by the feed bus going away.",
self.bus_lost.load(Ordering::Relaxed),
);
metric(
&mut out,
"fly_edge_decode_failures_total",
"counter",
"Feed bus publications that did not decode to a snapshot.",
self.decode_failures.load(Ordering::Relaxed),
);
metric(
&mut out,
"fly_edge_bind_failures_total",
"counter",
"Times the bus was reachable but the feed port could not be bound.",
self.bind_failures.load(Ordering::Relaxed),
);
out
}
}
/// Serve until the process is stopped. Only a metrics listener that cannot bind is fatal;
/// everything about the bus is retried.
pub async fn run(config: EdgeConfig, metrics: Arc<EdgeMetrics>) -> Result<()> {
if let Some(addr) = config.metrics_bind {
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("binding the edge metrics listener on {addr}"))?;
let app = axum::Router::new()
.route("/metrics", get(prometheus))
.route("/healthz", get(healthz))
.with_state(Arc::clone(&metrics));
tokio::spawn(async move {
if let Err(error) = axum::serve(listener, app).await {
tracing::error!(%error, "the edge metrics listener stopped");
}
});
}
// One line per outage of each kind, not one per retry.
let mut last: Option<&'static str> = None;
loop {
let end = session(&config, &metrics).await;
match &end {
SessionEnd::BusLost => {
tracing::warn!("the feed bus went away; clients dropped, reconnecting");
}
SessionEnd::Unreachable(error) if last != Some(end.kind()) => {
tracing::info!(error = format!("{error:#}"), "waiting for the feed bus");
}
SessionEnd::BindFailed(error) if last != Some(end.kind()) => {
// The bus is fine; the port is not ours. Most likely flysim is still in direct
// mode and holds it (FLY_FEED_VIA is not bus), or another process does.
tracing::warn!(
error = format!("{error:#}"),
"the bus is up but the feed port cannot be bound; retrying"
);
}
_ => {}
}
last = match end {
SessionEnd::BusLost => None,
other => Some(other.kind()),
};
tokio::time::sleep(config.retry).await;
}
}
/// Why a [`session`] ended.
enum SessionEnd {
/// No router answered, or it closed before the first snapshot. Nothing was served.
Unreachable(anyhow::Error),
/// Subscribed and holding a snapshot, but the feed port could not be bound.
BindFailed(anyhow::Error),
/// A session that served has ended because the bus went away.
BusLost,
}
impl SessionEnd {
fn kind(&self) -> &'static str {
match self {
Self::Unreachable(_) => "unreachable",
Self::BindFailed(_) => "bind",
Self::BusLost => "lost",
}
}
}
async fn prometheus(State(metrics): State<Arc<EdgeMetrics>>) -> impl IntoResponse {
(
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4",
)],
metrics.render(),
)
}
async fn healthz(State(metrics): State<Arc<EdgeMetrics>>) -> impl IntoResponse {
if metrics.connected.load(Ordering::Relaxed) == 1 {
(StatusCode::OK, "ok")
} else {
(StatusCode::SERVICE_UNAVAILABLE, "waiting for the feed bus")
}
}
/// One subscription's lifetime.
async fn session(config: &EdgeConfig, metrics: &EdgeMetrics) -> SessionEnd {
let (subscription, client, first) = match subscribe(config, metrics).await {
Ok(subscribed) => subscribed,
Err(error) => return SessionEnd::Unreachable(error),
};
let listener = match tokio::net::TcpListener::bind(config.feed_bind).await {
Ok(listener) => listener,
Err(error) => {
metrics.bind_failures.fetch_add(1, Ordering::Relaxed);
return SessionEnd::BindFailed(
anyhow::Error::new(error)
.context(format!("binding the feed listener on {}", config.feed_bind)),
);
}
};
serve(config, metrics, client, subscription, first, listener).await;
SessionEnd::BusLost
}
/// Connect, subscribe and wait for the first snapshot that decodes.
async fn subscribe(
config: &EdgeConfig,
metrics: &EdgeMetrics,
) -> Result<(flybus::Subscription, Client, flysim::snapshot::Snapshot)> {
let client = Client::connect_unix(
feedbus::socket_path(&config.bus_dir),
ClientConfig::new(feedbus::EDGE, feedbus::store_root(&config.bus_dir)),
)
.await
.map_err(|error| anyhow!("connecting to the feed bus: {error}"))?;
// One in flight: while a snapshot is being copied out, the next one waits in the single
// latest slot and anything newer replaces it. The edge is never more than one behind.
let mut subscription = client
.subscribe(
feedbus::TOPIC,
SubscriptionConfig::latest().in_flight(1).replay(true),
)
.await
.map_err(|error| anyhow!("subscribing to {}: {error}", feedbus::TOPIC))?;
let first = loop {
let message = subscription
.next()
.await
.ok_or_else(|| anyhow!("the feed bus closed before the first snapshot"))?;
match feedbus::receive(&message).await {
Ok(snapshot) => break snapshot,
Err(error) => {
metrics.decode_failures.fetch_add(1, Ordering::Relaxed);
tracing::warn!(%error, "a feed bus publication did not decode");
}
}
};
Ok((subscription, client, first))
}
/// Serve `listener` from `subscription` until the bus goes away.
async fn serve(
config: &EdgeConfig,
metrics: &EdgeMetrics,
client: Client,
mut subscription: flybus::Subscription,
first: flysim::snapshot::Snapshot,
listener: tokio::net::TcpListener,
) {
let (snapshots, receiver) = watch::channel(Arc::new(first));
metrics.snapshots.fetch_add(1, Ordering::Relaxed);
tracing::info!(feed = %config.feed_bind, bus = %config.bus_dir.display(), "serving the feed from the bus");
metrics.connected.store(1, Ordering::Relaxed);
let state = FeedState {
snapshots: receiver,
metrics: Arc::clone(&metrics.feed),
idle_period: config.idle_period,
};
let (stop, stopped) = oneshot::channel::<()>();
let server = tokio::spawn(async move {
let result = axum::serve(listener, feed::router(state))
.with_graceful_shutdown(async move {
let _ = stopped.await;
})
.await;
if let Err(error) = result {
tracing::error!(%error, "the feed listener stopped");
}
});
while let Some(message) = subscription.next().await {
match feedbus::receive(&message).await {
Ok(snapshot) => {
drop(message);
snapshots.send_replace(Arc::new(snapshot));
metrics.snapshots.fetch_add(1, Ordering::Relaxed);
}
Err(error) => {
metrics.decode_failures.fetch_add(1, Ordering::Relaxed);
tracing::warn!(%error, "a feed bus publication did not decode");
if client.closed().is_some() {
break;
}
}
}
}
// The bus is gone. Dropping the sender ends every client's pump (a closed stream, as when
// flysim itself stops), and the graceful shutdown unbinds the port.
metrics.connected.store(0, Ordering::Relaxed);
metrics.bus_lost.fetch_add(1, Ordering::Relaxed);
drop(snapshots);
let _ = stop.send(());
if tokio::time::timeout(Duration::from_secs(5), server)
.await
.is_err()
{
tracing::warn!("the feed listener took more than 5 s to stop");
}
}

View file

@ -0,0 +1,81 @@
//! `fly-edge`: serve the feed WebSocket from flysim's feed bus.
//!
//! ```sh
//! FLY_FEED_VIA=bus flysim &
//! fly-edge
//! ```
//!
//! Configured through the same environment as flysim (`FLY_FEED_BIND`, `FLY_BUS_DIR`,
//! `FLYSIM_LOOP_IDLE_SNAPSHOT_HZ`, or `--config flysim.toml`), plus `FLY_EDGE_METRICS_ADDR` for
//! its own `/metrics` and `/healthz`. `infra/units/flyedge.service` runs it with no arguments.
use std::sync::Arc;
use anyhow::{Context, Result};
use clap::Parser;
use fly_edge::{EdgeConfig, EdgeMetrics};
#[derive(Debug, Parser)]
#[command(
name = "fly-edge",
about = "The feed WebSocket, served from flysim's feed bus.",
version
)]
struct Args {
/// Path to `flysim.toml`, read for `[feed]` and `[loop]`. Environment overrides apply as
/// they do for flysim.
#[arg(long, value_name = "PATH")]
config: Option<std::path::PathBuf>,
}
fn main() -> Result<()> {
let args = Args::parse();
init_tracing();
let config = flysim::config::Config::load(args.config.as_deref())?;
let metrics_bind = match std::env::var("FLY_EDGE_METRICS_ADDR") {
Ok(value) if !value.is_empty() => Some(value.parse().with_context(|| {
format!("FLY_EDGE_METRICS_ADDR: {value:?} is not a host:port address")
})?),
_ => None,
};
let edge = EdgeConfig::from_flysim(&config, metrics_bind);
if config.feed.via != flysim::config::FeedVia::Bus {
tracing::warn!(
"FLY_FEED_VIA is not \"bus\": flysim serves the feed itself and binds {}; \
this edge will wait for a bus that is not there",
edge.feed_bind
);
}
tracing::info!(config = ?edge, "fly-edge starting");
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("fly-edge")
.enable_all()
.build()
.context("building the tokio runtime")?;
runtime.block_on(async move {
let metrics = Arc::new(EdgeMetrics::default());
let mut terminate =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("installing the SIGTERM handler")?;
tokio::select! {
result = fly_edge::run(edge, metrics) => result,
_ = tokio::signal::ctrl_c() => { tracing::info!("SIGINT: shutting down"); Ok(()) }
_ = terminate.recv() => { tracing::info!("SIGTERM: shutting down"); Ok(()) }
}
})
}
/// Logs to stderr, like flysim, under `FLY_EDGE_LOG` (or `RUST_LOG`).
fn init_tracing() {
use tracing_subscriber::EnvFilter;
let filter = EnvFilter::try_from_env("FLY_EDGE_LOG")
.or_else(|_| EnvFilter::try_from_default_env())
.unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_target(false)
.init();
}

View file

@ -0,0 +1,246 @@
#![allow(dead_code)]
//! Shared pieces of the edge tests: the committed `.flyfeed` fixtures as snapshots, a feed
//! client, the two serving paths side by side, and a `.flyfeed` writer.
use std::io::Read as _;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use fly_edge::{EdgeConfig, EdgeMetrics};
use flysim::feed::FeedState;
use flysim::feedbus;
use flysim::metrics::Metrics;
use flysim::snapshot::{AttachmentKind, FeedHeader, Snapshot};
use futures_util::{SinkExt as _, StreamExt as _};
use tokio::sync::watch;
use tokio_tungstenite::tungstenite::Message as WsMessage;
pub fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../..")
.canonicalize()
.expect("the repository root is above services/flysim/crates/fly-edge")
}
/// `u32 LE headerLength | header JSON | (u32 LE length | bytes)*`, split.
pub fn split(message: &[u8]) -> (&[u8], Vec<&[u8]>) {
let read = |at: usize| u32::from_le_bytes(message[at..at + 4].try_into().unwrap()) as usize;
let header_len = read(0);
let header = &message[4..4 + header_len];
let mut at = 4 + header_len;
let mut attachments = Vec::new();
while at < message.len() {
let len = read(at);
attachments.push(&message[at + 4..at + 4 + len]);
at += 4 + len;
}
(header, attachments)
}
/// A wire message back into the snapshot that produced it, or `None` when its header predates
/// fields the Rust producer always writes (the three oldest fixtures lack `game.scene`).
pub fn snapshot_of(message: &[u8]) -> Option<Snapshot> {
let (header, attachments) = split(message);
let header: FeedHeader = serde_json::from_slice(header).ok()?;
let mut snapshot = Snapshot {
header,
frame: Arc::new(Vec::new()),
audio: Arc::new(Vec::new()),
spikes: Arc::new(Vec::new()),
};
for (kind, bytes) in snapshot
.header
.attachments
.clone()
.into_iter()
.zip(attachments)
{
let bytes = Arc::new(bytes.to_vec());
match kind {
AttachmentKind::Frame => snapshot.frame = bytes,
AttachmentKind::Audio => snapshot.audio = bytes,
AttachmentKind::Spikes => snapshot.spikes = bytes,
}
}
Some(snapshot)
}
/// Every record of `apps/stage/public/fixtures/<name>.flyfeed.gz`, as wire messages.
pub fn fixture_messages(name: &str) -> Vec<Vec<u8>> {
let path = repo_root().join(format!("apps/stage/public/fixtures/{name}.flyfeed.gz"));
let gz = std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()));
let mut bytes = Vec::new();
flate2::read::GzDecoder::new(gz.as_slice())
.read_to_end(&mut bytes)
.unwrap();
assert_eq!(&bytes[..8], b"FLYFEED\0", "{name}");
let read = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
assert_eq!(read(8), 1, "{name}: container version");
let mut at = 16 + read(12);
let mut out = Vec::new();
while at < bytes.len() {
let len = read(at);
out.push(bytes[at + 4..at + 4 + len].to_vec());
at += 4 + len;
}
out
}
/// A `.flyfeed` file of `messages` (`packages/feed/src/fixture.ts`).
pub fn encode_flyfeed(name: &str, source: &str, messages: &[Vec<u8>]) -> Vec<u8> {
let wall = |message: &Vec<u8>| -> u64 {
let header: serde_json::Value = serde_json::from_slice(split(message).0).unwrap();
header["wallMs"].as_u64().unwrap_or(0)
};
let duration = match (messages.first(), messages.last()) {
(Some(first), Some(last)) => wall(last).saturating_sub(wall(first)),
_ => 0,
};
let manifest = serde_json::json!({
"name": name,
"protocol": 1,
"recordedAt": "1970-01-01T00:00:00.000Z",
"source": source,
"snapshotCount": messages.len(),
"durationMs": duration,
"hz": 30,
"attachmentPolicy": {
"frame": { "stride": 1 },
"audio": { "stride": 1 },
"spikes": { "stride": 1 }
},
});
let manifest = serde_json::to_vec(&manifest).unwrap();
let mut out = b"FLYFEED\0".to_vec();
out.extend_from_slice(&1u32.to_le_bytes());
out.extend_from_slice(&(manifest.len() as u32).to_le_bytes());
out.extend_from_slice(&manifest);
for message in messages {
out.extend_from_slice(&(message.len() as u32).to_le_bytes());
out.extend_from_slice(message);
}
out
}
pub fn free_port() -> SocketAddr {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
}
pub type Ws =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
/// Connect and say `hello`, retrying while the port is not bound yet (the edge binds only once
/// its first snapshot has arrived).
pub async fn connect(addr: SocketAddr, wants: &[&str]) -> Ws {
let url = format!("ws://{addr}/feed");
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
loop {
match tokio_tungstenite::connect_async(&url).await {
Ok((mut ws, _)) => {
let hello = serde_json::json!({ "protocol": 1, "client": "test", "wants": wants });
ws.send(WsMessage::Text(hello.to_string().into()))
.await
.unwrap();
return ws;
}
Err(error) => {
assert!(tokio::time::Instant::now() < deadline, "{url}: {error}");
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}
}
/// The next binary message, within `within`.
pub async fn next_binary(ws: &mut Ws, within: Duration) -> Vec<u8> {
let deadline = tokio::time::Instant::now() + within;
loop {
let message = tokio::time::timeout_at(deadline, ws.next())
.await
.expect("a snapshot in time")
.expect("the feed stays open")
.expect("a well-formed frame");
if let WsMessage::Binary(bytes) = message {
return bytes.to_vec();
}
}
}
pub fn seq_of(message: &[u8]) -> u64 {
let header: serde_json::Value = serde_json::from_slice(split(message).0).unwrap();
header["seq"].as_u64().unwrap()
}
/// The same watch slot served both ways at once: flysim's direct server on `direct`, and the
/// bus (router, publisher, edge) on `edge`. Owns its runtime-side tasks through the handles.
pub struct Paths {
pub snapshots: watch::Sender<Arc<Snapshot>>,
pub direct: SocketAddr,
pub edge: SocketAddr,
pub publisher_metrics: Arc<Metrics>,
pub edge_metrics: Arc<EdgeMetrics>,
pub bus_dir: tempfile::TempDir,
pub bus: feedbus::BusFeed,
}
/// Idle cadence long enough that no test sees a header repeated for idleness.
pub const NO_IDLE: Duration = Duration::from_secs(3_600);
/// Start both paths over `first`. `edge` false leaves the edge out (a test then plays its part).
pub async fn start(first: Snapshot, with_edge: bool) -> Paths {
let bus_dir = tempfile::tempdir().unwrap();
let (snapshots, receiver) = watch::channel(Arc::new(first));
let bus = feedbus::start_router(bus_dir.path()).await.unwrap();
let publisher_metrics = Arc::new(Metrics::default());
tokio::spawn(feedbus::run_publisher(
bus.router.clone(),
receiver.clone(),
Arc::clone(&publisher_metrics),
));
let direct = free_port();
let listener = tokio::net::TcpListener::bind(direct).await.unwrap();
let state = FeedState {
snapshots: receiver,
metrics: Arc::new(Metrics::default()),
idle_period: NO_IDLE,
};
tokio::spawn(async move { axum::serve(listener, flysim::feed::router(state)).await });
let edge = free_port();
let edge_metrics = Arc::new(EdgeMetrics::default());
if with_edge {
let config = EdgeConfig {
bus_dir: bus_dir.path().to_path_buf(),
feed_bind: edge,
idle_period: NO_IDLE,
metrics_bind: None,
retry: Duration::from_millis(50),
};
tokio::spawn(fly_edge::run(config, Arc::clone(&edge_metrics)));
}
Paths {
snapshots,
direct,
edge,
publisher_metrics,
edge_metrics,
bus_dir,
bus,
}
}
pub fn out_dir() -> Option<PathBuf> {
std::env::var_os("FLY_EDGE_PARITY_OUT").map(PathBuf::from)
}
pub fn write(path: &Path, bytes: &[u8]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}

View file

@ -0,0 +1,301 @@
//! Parity: a feed served through the bus and `fly-edge` is the feed flysim serves directly.
//!
//! The committed stage fixtures (`apps/stage/public/fixtures/*.flyfeed.gz`, the recordings the
//! stage's e2e suite plays) are fed snapshot by snapshot into one watch slot, served both ways at
//! once, and recorded by one client per path and per `wants` flavour: the stage's (everything),
//! the bridge's (nothing) and a frame-only one. The two recordings must match: headers equal
//! apart from wall-time fields and attachments byte-equal -- and in fact the whole messages are
//! byte-equal, because both are written by `Snapshot::encode` from equal snapshots. The edge's
//! attachments must also equal the fixture's own.
//!
//! `FLY_EDGE_PARITY_OUT=<dir>` also writes each pair of recordings as `.flyfeed` files, which
//! `packages/feed`'s `decodeFlyfeed` reads. `FLY_EDGE_PARITY_ALL=1` replays whole fixtures
//! instead of their first 400 snapshots.
mod common;
use std::time::Duration;
use common::*;
use serde_json::Value;
/// The fixtures whose headers carry every field the Rust producer writes. The three older ones
/// (`cold-open`, `steady`, `big-moment`) predate `game.scene` and cannot be a Rust `Snapshot`.
const FIXTURES: [&str; 4] = ["macros", "shop", "center", "bigpad"];
const WANTS: [(&str, &[&str]); 3] = [
("all", &["frame", "audio", "spikes"]),
("none", &[]),
("frame", &["frame"]),
];
/// The header with every `wallMs` removed, at any depth.
fn without_wall_time(header: &[u8]) -> Value {
fn strip(value: &mut Value) {
match value {
Value::Object(map) => {
map.remove("wallMs");
map.values_mut().for_each(strip);
}
Value::Array(items) => items.iter_mut().for_each(strip),
_ => {}
}
}
let mut value: Value = serde_json::from_slice(header).unwrap();
strip(&mut value);
value
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_edge_writes_the_bytes_the_direct_feed_writes_for_every_committed_fixture() {
let limit = if std::env::var_os("FLY_EDGE_PARITY_ALL").is_some() {
usize::MAX
} else {
400
};
for name in FIXTURES {
let snapshots: Vec<_> = fixture_messages(name)
.iter()
.take(limit)
.map(|message| {
(
message.clone(),
snapshot_of(message).unwrap_or_else(|| panic!("{name}")),
)
})
.collect();
assert!(
snapshots.len() >= 100,
"{name}: {} snapshots",
snapshots.len()
);
let paths = start(snapshots[0].1.clone(), true).await;
let mut clients = Vec::new();
for (flavour, wants) in WANTS {
let direct = connect(paths.direct, wants).await;
let edge = connect(paths.edge, wants).await;
clients.push((flavour, direct, edge, Vec::new(), Vec::new()));
}
// Lockstep: publish one snapshot, wait until every client has it. Nothing is superseded,
// so both recordings are complete and comparable message by message.
for (index, (_, snapshot)) in snapshots.iter().enumerate() {
if index > 0 {
paths
.snapshots
.send_replace(std::sync::Arc::new(snapshot.clone()));
}
for (_, direct, edge, direct_log, edge_log) in &mut clients {
for (ws, log) in [
(&mut *direct, &mut *direct_log),
(&mut *edge, &mut *edge_log),
] {
let message = next_binary(ws, Duration::from_secs(20)).await;
assert_eq!(seq_of(&message), snapshot.header.seq, "{name} #{index}");
log.push(message);
}
}
}
for (flavour, _, _, direct_log, edge_log) in &clients {
assert_eq!(direct_log.len(), snapshots.len());
assert_eq!(edge_log.len(), direct_log.len());
for (index, (direct, edge)) in direct_log.iter().zip(edge_log).enumerate() {
let (direct_header, direct_attachments) = split(direct);
let (edge_header, edge_attachments) = split(edge);
assert_eq!(
without_wall_time(direct_header),
without_wall_time(edge_header),
"{name}/{flavour} #{index}: headers"
);
assert_eq!(
direct_attachments, edge_attachments,
"{name}/{flavour} #{index}: attachments"
);
// The stronger fact: the whole message, wall times included, is the same bytes.
assert!(direct == edge, "{name}/{flavour} #{index}: messages differ");
if *flavour == "all" {
let (_, fixture_attachments) = split(&snapshots[index].0);
assert_eq!(
edge_attachments, fixture_attachments,
"{name} #{index}: vs the fixture"
);
}
}
if let Some(dir) = out_dir() {
write(
&dir.join(format!("{name}-{flavour}-direct.flyfeed")),
&encode_flyfeed(name, "flysim direct", direct_log),
);
write(
&dir.join(format!("{name}-{flavour}-edge.flyfeed")),
&encode_flyfeed(name, "flysim bus + fly-edge", edge_log),
);
}
}
let published = flysim::metrics::Metrics::get(&paths.publisher_metrics.bus_published);
assert!(
published >= snapshots.len() as u64,
"{name}: {published} published"
);
assert_eq!(
flysim::metrics::Metrics::get(&paths.publisher_metrics.bus_publish_failures),
0
);
eprintln!(
"{name}: {} snapshots x {} flavours identical on both paths",
snapshots.len(),
WANTS.len()
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_header_too_large_for_an_envelope_travels_as_an_artifact_and_arrives_intact() {
let mut snapshot = snapshot_of(&fixture_messages("macros")[10]).unwrap();
// Far past flybus's 65,536-byte envelope: 400 events of 200 characters.
for id in 0..400u64 {
snapshot.header.events.push(flysim::snapshot::FeedEvent {
id: 10_000 + id,
wall_ms: 1_757_000_000_000 + id,
brain_ms: 5.0,
kind: flysim::snapshot::FeedEventKind::System,
label: "x".repeat(200),
value: None,
reward_kind: None,
by: None,
});
}
assert!(serde_json::to_vec(&snapshot.header).unwrap().len() > 65_536);
let paths = start(snapshot.clone(), true).await;
let mut direct = connect(paths.direct, &["frame", "audio", "spikes"]).await;
let mut edge = connect(paths.edge, &["frame", "audio", "spikes"]).await;
let direct = next_binary(&mut direct, Duration::from_secs(20)).await;
let edge = next_binary(&mut edge, Duration::from_secs(20)).await;
assert!(direct == edge);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_edge_drops_its_clients_and_unbinds_when_the_bus_goes_away_then_comes_back() {
let snapshot = snapshot_of(&fixture_messages("shop")[5]).unwrap();
let paths = start(snapshot.clone(), true).await;
let mut edge = connect(paths.edge, &[]).await;
next_binary(&mut edge, Duration::from_secs(20)).await;
// flysim stopping is its router stopping.
let Paths {
snapshots,
edge: edge_addr,
edge_metrics,
bus_dir,
bus,
..
} = paths;
bus.router.shutdown();
drop(bus);
drop(snapshots);
let closed = tokio::time::timeout(Duration::from_secs(10), async {
use futures_util::StreamExt as _;
loop {
match edge.next().await {
None | Some(Err(_)) => break,
Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => break,
Some(Ok(_)) => continue,
}
}
})
.await;
assert!(closed.is_ok(), "the client was not dropped");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while tokio::net::TcpStream::connect(edge_addr).await.is_ok() {
assert!(
std::time::Instant::now() < deadline,
"the feed port stayed bound"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert_eq!(
edge_metrics
.connected
.load(std::sync::atomic::Ordering::Relaxed),
0
);
// A new flysim on the same directory: the edge finds it and serves again.
let (snapshots, receiver) = tokio::sync::watch::channel(std::sync::Arc::new(snapshot.clone()));
let bus = flysim::feedbus::start_router(bus_dir.path()).await.unwrap();
tokio::spawn(flysim::feedbus::run_publisher(
bus.router.clone(),
receiver,
std::sync::Arc::new(flysim::metrics::Metrics::default()),
));
let mut edge = connect(edge_addr, &[]).await;
let message = next_binary(&mut edge, Duration::from_secs(20)).await;
assert_eq!(seq_of(&message), snapshot.header.seq);
assert_eq!(
edge_metrics
.bus_lost
.load(std::sync::atomic::Ordering::Relaxed),
1
);
drop(snapshots);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_edge_whose_port_is_taken_keeps_retrying_and_serves_once_it_is_free() {
let snapshot = snapshot_of(&fixture_messages("center")[7]).unwrap();
// Someone else (flysim still in direct mode, say) holds the feed port before the edge starts.
let squatter = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = squatter.local_addr().unwrap();
let bus_dir = tempfile::tempdir().unwrap();
let (snapshots, receiver) = tokio::sync::watch::channel(std::sync::Arc::new(snapshot.clone()));
let bus = flysim::feedbus::start_router(bus_dir.path()).await.unwrap();
tokio::spawn(flysim::feedbus::run_publisher(
bus.router.clone(),
receiver,
std::sync::Arc::new(flysim::metrics::Metrics::default()),
));
let metrics = std::sync::Arc::new(fly_edge::EdgeMetrics::default());
tokio::spawn(fly_edge::run(
fly_edge::EdgeConfig {
bus_dir: bus_dir.path().to_path_buf(),
feed_bind: port,
idle_period: NO_IDLE,
metrics_bind: None,
retry: Duration::from_millis(50),
},
std::sync::Arc::clone(&metrics),
));
// It reaches the bus, fails to bind, and says so rather than claiming to wait for the bus.
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while metrics
.bind_failures
.load(std::sync::atomic::Ordering::Relaxed)
< 3
{
assert!(
std::time::Instant::now() < deadline,
"the edge never reached the bus"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_eq!(
metrics.connected.load(std::sync::atomic::Ordering::Relaxed),
0
);
assert_eq!(
metrics.bus_lost.load(std::sync::atomic::Ordering::Relaxed),
0
);
drop(squatter);
let mut edge = connect(port, &[]).await;
let message = next_binary(&mut edge, Duration::from_secs(20)).await;
assert_eq!(seq_of(&message), snapshot.header.seq);
assert_eq!(
metrics.connected.load(std::sync::atomic::Ordering::Relaxed),
1
);
drop(snapshots);
drop(bus);
}

View file

@ -0,0 +1,354 @@
//! A slow or absent edge never slows the loop.
//!
//! A thread stands in for the sim loop: flysim's own `Pacer` at realtime speed and 60 Hz Game
//! Boy frames, publishing full-size snapshots (a real 92,160-byte frame, a 17,407-byte spike
//! bitset for 139,255 neurons, 12,800 bytes of audio) into the watch slot every second frame,
//! with `watch::Sender::send`, exactly as `Sim::publish` does. Around it, three kinds of bad
//! consumer:
//!
//! - the edge is up but three of its WebSocket clients never read, so their sockets fill;
//! - the edge's place on the bus is held by a client that opens every subscription it may
//! (4) and never releases a delivery on any of them (the "slow edge", at its worst);
//! - nobody is subscribed at all (the "absent edge").
//!
//! The gated tests assert the claim, and only the claim: the pacer reports no lag, no watch send
//! waits on a consumer, no publication is refused, the store stays bounded, and a healthy client
//! still reaches the newest snapshot. Those hold on a box at any load, because none of them is a
//! rate.
//!
//! How fast the loop's sleeps come back and how many snapshots a debug-build publisher gets
//! through measure the OS scheduler and the CPU left over, not the bus: a starved publisher
//! coalesces by design. Those bounds are in `the_three_scenarios_keep_their_rates`, which is
//! `#[ignore]`d; run it on a quiet box with
//! `cargo test --release -p fly-edge --test stall -- --ignored --nocapture`.
mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use common::*;
use flybus::{Client, ClientConfig, SubscriptionConfig};
use flysim::feedbus;
use flysim::metrics::Metrics;
use flysim::pacing::Pacer;
use flysim::snapshot::{AttachmentKind, FeedStatus, Snapshot};
/// A full-size running snapshot from a real fixture frame.
fn full_snapshot() -> Snapshot {
let mut snapshot = snapshot_of(&fixture_messages("macros")[3]).unwrap();
assert_eq!(snapshot.frame.len(), flysim::snapshot::FRAME_BYTES);
snapshot.header.status = FeedStatus::Running;
snapshot.header.attachments = AttachmentKind::ALL.to_vec();
snapshot.spikes = Arc::new(vec![0b1010_0101; 139_255usize.div_ceil(8)]);
snapshot.audio = Arc::new(vec![7; 12_800]);
snapshot
}
struct LoopReport {
frames: u64,
published: u64,
lag_seconds: f64,
worst_send: Duration,
worst_shortfall: f64,
p99_shortfall: f64,
}
/// Run the stand-in loop for `seconds` on its own thread.
fn run_loop(
snapshots: tokio::sync::watch::Sender<Arc<Snapshot>>,
template: Snapshot,
seconds: f64,
) -> LoopReport {
std::thread::spawn(move || {
let frame_ms = flysim::config::GAMEBOY_MS_PER_FRAME;
let mut pacer = Pacer::new(frame_ms, 1.0, Instant::now());
let frames = (seconds * 1000.0 / frame_ms) as u64;
let mut worst_send = Duration::ZERO;
let mut shortfalls = Vec::with_capacity(frames as usize);
let mut seq = template.header.seq;
let mut published = 0;
for frame in 0..frames {
if frame % 2 == 0 {
let mut snapshot = template.clone();
seq += 1;
snapshot.header.seq = seq;
snapshot.header.frame = frame;
let started = Instant::now();
let _ = snapshots.send(Arc::new(snapshot));
worst_send = worst_send.max(started.elapsed());
published += 1;
}
let now = Instant::now();
shortfalls.push(pacer.shortfall_seconds(now));
let sleep = pacer.next_sleep(now);
if !sleep.is_zero() {
std::thread::sleep(sleep);
}
}
shortfalls.sort_by(f64::total_cmp);
LoopReport {
frames,
published,
lag_seconds: pacer.lag_seconds(),
worst_send,
worst_shortfall: *shortfalls.last().unwrap(),
p99_shortfall: shortfalls[shortfalls.len() * 99 / 100],
}
})
.join()
.unwrap()
}
fn print_report(report: &LoopReport, publisher: &Metrics, what: &str) {
eprintln!(
"{what}: {} frames, {} snapshots, lag {:.3} s, worst send {:?}, shortfall p99 {:.2} ms worst {:.2} ms, bus published {} failed {}",
report.frames,
report.published,
report.lag_seconds,
report.worst_send,
report.p99_shortfall * 1e3,
report.worst_shortfall * 1e3,
Metrics::get(&publisher.bus_published),
Metrics::get(&publisher.bus_publish_failures),
);
}
/// The claim: the loop is never held by the bus, whatever the load.
fn assert_unharmed(report: &LoopReport, publisher: &Metrics, what: &str) {
assert_eq!(report.lag_seconds, 0.0, "{what}: the pacer fell behind");
// A watch send is a lock and a swap. A send that waited on a consumer would be a whole
// stall, seconds; 50 ms leaves room for a preempted thread on a loaded box.
assert!(
report.worst_send < Duration::from_millis(50),
"{what}: a send took {:?}",
report.worst_send
);
// A slow or absent consumer is never a reason to refuse a latest publication.
assert_eq!(
Metrics::get(&publisher.bus_publish_failures),
0,
"{what}: a publication was refused"
);
// Coalescing is allowed, stopping is not.
assert!(
Metrics::get(&publisher.bus_published) >= 1,
"{what}: nothing reached the bus"
);
}
/// Rates: meaningful only on a quiet box (see the module comment).
fn assert_rates(report: &LoopReport, publisher: &Metrics, what: &str) {
// Sleep overshoot is absorbed by the next frame; under one frame at p99 means the loop kept
// its absolute deadlines.
assert!(
report.p99_shortfall < 0.016,
"{what}: p99 shortfall {:.2} ms",
report.p99_shortfall * 1e3
);
let published = Metrics::get(&publisher.bus_published);
assert!(
published * 2 >= report.published,
"{what}: only {published} of {} reached the bus",
report.published
);
}
const SECONDS: f64 = 6.0;
/// What a scenario leaves for the rate checks.
struct Outcome {
report: LoopReport,
publisher: Arc<Metrics>,
/// Snapshots the healthy client received, where there is one.
healthy_received: Option<u64>,
}
async fn stalled_clients() -> Outcome {
let template = full_snapshot();
let paths = start(template.clone(), true).await;
// Three stages that said hello and then stopped reading: their sockets fill and stay full.
let mut stalled = Vec::new();
for _ in 0..3 {
stalled.push(connect(paths.edge, &["frame", "audio", "spikes"]).await);
}
// One healthy stage, read continuously.
let mut healthy = connect(paths.edge, &["frame", "audio", "spikes"]).await;
let newest = Arc::new(AtomicU64::new(0));
let received = Arc::new(AtomicU64::new(0));
let reader = {
let (newest, received) = (Arc::clone(&newest), Arc::clone(&received));
tokio::spawn(async move {
loop {
let message = next_binary(&mut healthy, Duration::from_secs(120)).await;
newest.store(seq_of(&message), Ordering::Relaxed);
received.fetch_add(1, Ordering::Relaxed);
}
})
};
let snapshots = paths.snapshots.clone();
let report = tokio::task::spawn_blocking(move || run_loop(snapshots, template, SECONDS))
.await
.unwrap();
print_report(&report, &paths.publisher_metrics, "stalled clients");
assert_unharmed(&report, &paths.publisher_metrics, "stalled clients");
// The healthy client reaches the last snapshot published: the newest one always gets
// through, however many in between were coalesced.
let last = paths.snapshots.borrow().header.seq;
let deadline = Instant::now() + Duration::from_secs(60);
while newest.load(Ordering::Relaxed) < last {
assert!(
Instant::now() < deadline,
"healthy client stuck at {} of {last}",
newest.load(Ordering::Relaxed)
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
// The stalled ones are still connected, not dropped for being slow.
assert_eq!(paths.edge_metrics.feed.clients(), 4);
reader.abort();
drop(stalled);
Outcome {
report,
publisher: Arc::clone(&paths.publisher_metrics),
healthy_received: Some(received.load(Ordering::Relaxed)),
}
}
async fn hoarding_subscriber() -> Outcome {
let template = full_snapshot();
let paths = start(template.clone(), false).await;
// The edge's seat, taken by a subscriber that keeps every delivery it gets.
let client = Client::connect_unix(
feedbus::socket_path(paths.bus_dir.path()),
ClientConfig::new(feedbus::EDGE, feedbus::store_root(paths.bus_dir.path())),
)
.await
.unwrap();
// Every subscription the seat may open, each keeping every delivery at the in-flight cap:
// the worst case the store has to hold (`feedbus::limits`, flybus.md "Feed sizing").
let seats = feedbus::limits().max_subscriptions_per_client;
let mut hoards = Vec::new();
for _ in 0..seats {
let mut subscription = client
.subscribe(
feedbus::TOPIC,
SubscriptionConfig::latest().in_flight(2).replay(true),
)
.await
.unwrap();
hoards.push(tokio::spawn(async move {
let mut kept = Vec::new();
while let Some(message) = subscription.next().await {
kept.push(message);
}
kept.len()
}));
}
assert!(
client
.subscribe(feedbus::TOPIC, SubscriptionConfig::latest())
.await
.is_err(),
"a subscription past max_subscriptions_per_client was admitted"
);
// And the seat is the only one: a second connection as the edge is refused.
assert!(
Client::connect_unix(
feedbus::socket_path(paths.bus_dir.path()),
ClientConfig::new(feedbus::EDGE, feedbus::store_root(paths.bus_dir.path())),
)
.await
.is_err(),
"a second client was admitted on edge.sock"
);
let snapshots = paths.snapshots.clone();
let report = tokio::task::spawn_blocking(move || run_loop(snapshots, template, SECONDS))
.await
.unwrap();
print_report(&report, &paths.publisher_metrics, "hoarding subscriber");
assert_unharmed(&report, &paths.publisher_metrics, "hoarding subscriber");
let stats = paths.bus.router.stats();
eprintln!(
"hoarding subscriber: store {} bytes, retained {} bytes",
stats.store_bytes, stats.retained_bytes
);
// Held: per subscription two in flight and one queued, plus one retained and whatever is
// mid-seal: 4 * 3 + 1 + 2 = 15 snapshots at most. Bounded, not growing with the number
// published.
assert!(
stats.store_bytes <= 15 * 122_367,
"store holds {} bytes",
stats.store_bytes
);
for hoard in hoards {
hoard.abort();
}
Outcome {
report,
publisher: Arc::clone(&paths.publisher_metrics),
healthy_received: None,
}
}
async fn absent_edge() -> Outcome {
let template = full_snapshot();
let paths = start(template.clone(), false).await;
let snapshots = paths.snapshots.clone();
let report = tokio::task::spawn_blocking(move || run_loop(snapshots, template, SECONDS))
.await
.unwrap();
print_report(&report, &paths.publisher_metrics, "absent edge");
assert_unharmed(&report, &paths.publisher_metrics, "absent edge");
let stats = paths.bus.router.stats();
assert!(
stats.store_bytes <= 3 * 122_367,
"store holds {} bytes",
stats.store_bytes
);
Outcome {
report,
publisher: Arc::clone(&paths.publisher_metrics),
healthy_received: None,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn clients_of_the_edge_that_never_read_do_not_lag_the_loop_or_the_healthy_client() {
stalled_clients().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_bus_subscriber_that_never_releases_does_not_lag_the_loop_or_fill_the_store() {
hoarding_subscriber().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_absent_edge_costs_the_loop_nothing() {
absent_edge().await;
}
/// The same three scenarios, plus the rates. A measurement of the box as much as of the bus,
/// so not part of the workspace gate.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "timing: needs a quiet box; run with --release -- --ignored"]
async fn the_three_scenarios_keep_their_rates() {
for (what, outcome) in [
("stalled clients", stalled_clients().await),
("hoarding subscriber", hoarding_subscriber().await),
("absent edge", absent_edge().await),
] {
assert_rates(&outcome.report, &outcome.publisher, what);
if let Some(got) = outcome.healthy_received {
assert!(
got * 2 >= outcome.report.published,
"{what}: the healthy client got only {got} of {}",
outcome.report.published
);
}
}
}

View file

@ -52,7 +52,7 @@ pub fn example_composition() -> LegacyComposition {
byte_length: 1_048_576, byte_length: 1_048_576,
format: "gb-rom".to_owned(), format: "gb-rom".to_owned(),
}, },
adapter: "pokered-unique8-v6".to_owned(), adapter: "pokered-unique8-v7".to_owned(),
symbol_provenance: pokered.to_owned(), symbol_provenance: pokered.to_owned(),
mode: "macros".to_owned(), mode: "macros".to_owned(),
macro_channels: decoder_vector("macros")["macroChannels"] macro_channels: decoder_vector("macros")["macroChannels"]
@ -71,7 +71,7 @@ pub fn example_composition() -> LegacyComposition {
audio_sample_rate: 48_000, audio_sample_rate: 48_000,
}, },
flysim_compatibility: format!( flysim_compatibility: format!(
"{}/pokered-unique8-v6/{}/{}/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:{pokered}/statefmt:199616-x86_64-unknown-linux-gnu", "{}/pokered-unique8-v7/{}/{}/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:{pokered}/statefmt:199616-x86_64-unknown-linux-gnu",
gameboy::KERNEL_VERSION, gameboy::KERNEL_VERSION,
gameboy::FAFB_V783_FINGERPRINT, gameboy::FAFB_V783_FINGERPRINT,
gameboy::PLASTICITY_VERSION, gameboy::PLASTICITY_VERSION,

View file

@ -492,7 +492,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -560,9 +560,9 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"digest": "77d8a88ff7fea51eb29b1ae75fc9cf8c17c184e6a1e5a585e86399f41fd9c9b0", "digest": "44916db0a0846d3338f50d4f24d9d4e0214fa239af7d15e4174213cd93b07e28",
"recipeLines": [ "recipeLines": [
"fly-session/composition-v1", "fly-session/composition-v1",
"session=<sessionId>", "session=<sessionId>",

View file

@ -5202,7 +5202,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5243,7 +5243,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "the profile AssetRef digest is the legacy profile's" "reason": "the profile AssetRef digest is the legacy profile's"
}, },
@ -5267,7 +5267,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "raw", "mode": "raw",
"macroChannels": [ "macroChannels": [
@ -5308,7 +5308,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "raw mode deals no macro channels" "reason": "raw mode deals no macro channels"
}, },
@ -5332,7 +5332,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [] "macroChannels": []
@ -5368,7 +5368,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "macros mode needs its channels" "reason": "macros mode needs its channels"
}, },
@ -5392,7 +5392,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5433,7 +5433,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "the executor extension is pokered-macros-v1" "reason": "the executor extension is pokered-macros-v1"
}, },
@ -5457,7 +5457,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5498,7 +5498,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "exact", "restore": "exact",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "the legacy composition declares legacy-transient-reset" "reason": "the legacy composition declares legacy-transient-reset"
}, },
@ -5522,7 +5522,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5563,7 +5563,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "the rollback policy needs gameboy-slots-v1" "reason": "the rollback policy needs gameboy-slots-v1"
}, },
@ -5587,7 +5587,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5628,7 +5628,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "one Game Boy frame is 8572265625/512 ns, not 1/60 s" "reason": "one Game Boy frame is 8572265625/512 ns, not 1/60 s"
}, },
@ -5652,7 +5652,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5693,7 +5693,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v5/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "the FLYSIM01 string and the declaration describe the same fly" "reason": "the FLYSIM01 string and the declaration describe the same fly"
}, },
@ -5717,7 +5717,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5758,7 +5758,7 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSESS1", "checkpointFormatOfRecord": "FLYSESS1",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"reason": "FLYSIM01 stays until RETIRE-01" "reason": "FLYSIM01 stays until RETIRE-01"
}, },

View file

@ -3337,7 +3337,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -3405,11 +3405,11 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"note": "placeholder ROM digest; real macros-mode decoder digest and channels", "note": "placeholder ROM digest; real macros-mode decoder digest and channels",
"canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"82b6601f0390b742a67eca44ef935b49e16a73315db70f5644f1cbd21f7c8a52\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v6\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[\"macro_go_objective\",\"macro_go_out\",\"macro_go_warp\",\"macro_go_route\",\"macro_go_item\",\"macro_go_npc\",\"macro_go_frontier\",\"macro_go_shop\",\"macro_go_heal\",\"macro_talk\",\"macro_menu\",\"macro_next\",\"macro_yes\",\"macro_no\",\"macro_close\",\"macro_confirm\",\"macro_back\",\"macro_move_1\",\"macro_move_2\",\"macro_move_3\",\"macro_move_4\",\"macro_switch\",\"macro_item\",\"macro_throw_ball\",\"macro_run\",\"macro_buy_potion\",\"macro_buy_ball\",\"macro_buy_antidote\",\"macro_buy_repel\",\"macro_heal\",\"macro_leave\"],\"mode\":\"macros\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}", "canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"82b6601f0390b742a67eca44ef935b49e16a73315db70f5644f1cbd21f7c8a52\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v7\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[\"macro_go_objective\",\"macro_go_out\",\"macro_go_warp\",\"macro_go_route\",\"macro_go_item\",\"macro_go_npc\",\"macro_go_frontier\",\"macro_go_shop\",\"macro_go_heal\",\"macro_talk\",\"macro_menu\",\"macro_next\",\"macro_yes\",\"macro_no\",\"macro_close\",\"macro_confirm\",\"macro_back\",\"macro_move_1\",\"macro_move_2\",\"macro_move_3\",\"macro_move_4\",\"macro_switch\",\"macro_item\",\"macro_throw_ball\",\"macro_run\",\"macro_buy_potion\",\"macro_buy_ball\",\"macro_buy_antidote\",\"macro_buy_repel\",\"macro_heal\",\"macro_leave\"],\"mode\":\"macros\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}",
"digest": "77d8a88ff7fea51eb29b1ae75fc9cf8c17c184e6a1e5a585e86399f41fd9c9b0" "digest": "44916db0a0846d3338f50d4f24d9d4e0214fa239af7d15e4174213cd93b07e28"
}, },
{ {
"name": "an example legacy composition in raw mode", "name": "an example legacy composition in raw mode",
@ -3431,7 +3431,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v6", "adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "raw", "mode": "raw",
"macroChannels": [] "macroChannels": []
@ -3467,11 +3467,11 @@
"episodePolicy": "legacy-ratchet-rollback-v1", "episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset", "restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01", "checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu" "flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
}, },
"note": "", "note": "",
"canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"6234e4a0363cfe82b8d515943c4f645a3960eda8fa5bf9465009061f80d50812\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v6\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[],\"mode\":\"raw\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v6/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}", "canonical": "{\"checkpointFormatOfRecord\":\"FLYSIM01\",\"compositionId\":\"pokered-live\",\"decoderConfigDigest\":\"6234e4a0363cfe82b8d515943c4f645a3960eda8fa5bf9465009061f80d50812\",\"environment\":{\"audio\":{\"channels\":2,\"sampleRate\":48000},\"controllerSchema\":{\"digest\":\"1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e\",\"id\":\"gameboy-joypad-v1\",\"version\":1},\"extensions\":[\"gameboy-slots-v1\"],\"inspectionSchema\":{\"digest\":\"d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6\",\"id\":\"gameboy-memory-inspection-v1\",\"version\":1},\"setupFrames\":1,\"slots\":[\"best\"],\"stepDuration\":{\"denominator\":\"512\",\"numerator\":\"8572265625\"}},\"episodePolicy\":\"legacy-ratchet-rollback-v1\",\"executor\":{\"adapter\":\"pokered-unique8-v7\",\"id\":\"pokered-macros-v1\",\"macroChannels\":[],\"mode\":\"raw\",\"rom\":{\"byteLength\":\"1048576\",\"digest\":\"c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20\",\"format\":\"gb-rom\",\"id\":\"pokered-rom\"},\"symbolProvenance\":\"0cd19d3b877b7dc66d12c7050bed9a7f38154d4b\"},\"flysimCompatibility\":\"lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu\",\"profile\":{\"byteLength\":\"1137\",\"digest\":\"41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878\",\"format\":\"fly-profile-v1\",\"id\":\"gameboy-legacy-fafb-v783-v1\"},\"restore\":\"legacy-transient-reset\",\"scheduler\":\"lockstep-v1\"}",
"digest": "ac42e702ec6c9b09482b1ee335743ef85fe599a72e2c34a4d32aea67d67d0201" "digest": "568eceeb052ebf4fbd0c10661e227783cecc8ac7fee75ae6ba84335c684bfb7c"
}, },
{ {
"name": "a rank climb: slot saved, then the milestone capture", "name": "a rank climb: slot saved, then the milestone capture",

View file

@ -259,10 +259,10 @@ impl NeuralAgent {
/// scene has put on the pad (`docs/design/macros.md` section 12). A host with no macro group /// scene has put on the pad (`docs/design/macros.md` section 12). A host with no macro group
/// -- every caller that came before it -- passes `None` and decodes exactly as it always did. /// -- every caller that came before it -- passes `None` and decodes exactly as it always did.
/// ///
/// `flysim`'s sim loop does not come through here (it drives the network and the decoder /// `flysim` does not come through here: its frame (`flysim::frame::LegacyFrame`) drives the
/// itself, so that the macro layer can read the emulator between the two), but the bench that /// network and the decoder itself, so that the macro layer can read the emulator between the
/// measures the two arms against each other does, and a bench whose macro group could win a /// two, and it installs a frame and its rewards straight after the frame rather than after the
/// channel the scene never bound would be measuring something the stream cannot do. /// next ticks. Every `flysim` harness runs that frame too.
pub fn tick_bound( pub fn tick_bound(
&mut self, &mut self,
frame: &[u8], frame: &[u8],

View file

@ -16,7 +16,7 @@ ROM bytes -> Emulator::run_frame -> RGBA frame + PCM + WRAM
| --- | --- | | --- | --- |
| `emulator` | Safe wrapper over binjgb: frames, framebuffer, buttons, WRAM, audio, save states, ROM hash | | `emulator` | Safe wrapper over binjgb: frames, framebuffer, buttons, WRAM, audio, save states, ROM hash |
| `adapter` | `GameAdapter`, `RewardEvent`, `ProgressSnapshot`, `MemoryReader`, `adapter_for` | | `adapter` | `GameAdapter`, `RewardEvent`, `ProgressSnapshot`, `MemoryReader`, `adapter_for` |
| `pokemon_red` | The `pokered-unique8-v5` reward adapter, its catalog and its generated symbol table | | `pokemon_red` | The `pokered-unique8-v7` reward adapter, its catalog and its generated symbol table |
| `platformer` | The `sml-progress-v1` Super Mario Land adapter, its catalog and its RAM map | | `platformer` | The `sml-progress-v1` Super Mario Land adapter, its catalog and its RAM map |
| `ratchet` | The progress ratchet, generic over the adapter's rank and its `RecoveryPolicy` | | `ratchet` | The progress ratchet, generic over the adapter's rank and its `RecoveryPolicy` |
| `recovery` | Rolling the game back to the ratchet's best safe snapshot | | `recovery` | Rolling the game back to the ratchet's best safe snapshot |

View file

@ -56,10 +56,10 @@ impl MemoryReader for &mut dyn MemoryReader {
/// One reward payout in one frame. /// One reward payout in one frame.
/// ///
/// `kind` is an adapter-owned interned name (Pokémon: `milestone`, /// `kind` is an adapter-owned interned name (Pokémon: `milestone`,
/// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`); it is the /// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`,
/// key the statistics counters and the on-screen ticker group by. Field names /// `talk`, `item`); it is the key the statistics counters and the on-screen ticker group
/// serialize exactly as the prototype's `RewardEvent` did, so a checkpoint /// by. Field names serialize exactly as the prototype's `RewardEvent` did, so a
/// written by either implementation reads in the other. /// checkpoint written by either implementation reads in the other.
#[derive(Debug, Clone, PartialEq, Serialize)] #[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RewardEvent { pub struct RewardEvent {
pub kind: &'static str, pub kind: &'static str,
@ -241,7 +241,7 @@ impl std::error::Error for AdapterError {}
/// A game, as the sim loop sees it. /// A game, as the sim loop sees it.
pub trait GameAdapter: Send { pub trait GameAdapter: Send {
/// Adapter version string, pinned into the checkpoint compatibility string. /// Adapter version string, pinned into the checkpoint compatibility string.
/// Pokémon: `pokered-unique8-v6`. /// Pokémon: `pokered-unique8-v7`.
fn id(&self) -> &'static str; fn id(&self) -> &'static str;
/// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration /// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration

View file

@ -30,7 +30,7 @@ pub const PROTOTYPE_PLASTICITY_VERSION: &str = "fly-kc-mbon-rstdp-v2";
pub struct Compatibility<'a> { pub struct Compatibility<'a> {
/// `kernelVersion(config)` from the neural library. /// `kernelVersion(config)` from the neural library.
pub neural_kernel_version: &'a str, pub neural_kernel_version: &'a str,
/// The adapter's version string, e.g. `pokered-unique8-v6`. /// The adapter's version string, e.g. `pokered-unique8-v7`.
pub adapter: &'a str, pub adapter: &'a str,
/// The dataset's seven SHA-256 digests joined with `:`. /// The dataset's seven SHA-256 digests joined with `:`.
pub dataset_fingerprint: &'a str, pub dataset_fingerprint: &'a str,
@ -78,7 +78,7 @@ const ADAPTER_SEGMENT: usize = 1;
/// The environment variable that opts a deploy into the adapter migration. /// The environment variable that opts a deploy into the adapter migration.
/// ///
/// Read by flysim at restore and by `infra/05-deploy.sh`'s compatibility gate. Comma- or /// Read by flysim at restore and by `infra/05-deploy.sh`'s compatibility gate. Comma- or
/// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v5`. /// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v6`.
pub const ACCEPT_ADAPTERS_ENV: &str = "FLY_ACCEPT_ADAPTERS"; pub const ACCEPT_ADAPTERS_ENV: &str = "FLY_ACCEPT_ADAPTERS";
/// What a build may do with a checkpoint whose compatibility string is not its own. /// What a build may do with a checkpoint whose compatibility string is not its own.
@ -180,7 +180,7 @@ mod tests {
assert_eq!( assert_eq!(
fixture().prototype_string(), fixture().prototype_string(),
concat!( concat!(
"lif-1ms-f64-v2/pokered-unique8-v6/aa:bb:cc:dd:ee:ff:00/", "lif-1ms-f64-v2/pokered-unique8-v7/aa:bb:cc:dd:ee:ff:00/",
"fly-kc-mbon-rstdp-v2/", "fly-kc-mbon-rstdp-v2/",
"binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/", "binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/",
"pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
@ -194,37 +194,37 @@ mod tests {
#[test] #[test]
fn an_identical_string_restores_without_any_opt_in() { fn an_identical_string_restores_without_any_opt_in() {
let current = with_adapter("pokered-unique8-v6"); let current = with_adapter("pokered-unique8-v7");
assert_eq!(decide(&current, &current, &[], &[]), RestoreDecision::Exact); assert_eq!(decide(&current, &current, &[], &[]), RestoreDecision::Exact);
} }
#[test] #[test]
fn a_v5_checkpoint_restores_under_v6_only_with_the_opt_in() { fn a_v6_checkpoint_restores_under_v7_only_with_the_opt_in() {
let old = with_adapter("pokered-unique8-v5"); let old = with_adapter("pokered-unique8-v6");
let new = with_adapter("pokered-unique8-v6"); let new = with_adapter("pokered-unique8-v7");
let migrates = ["pokered-unique8-v5"]; let migrates = ["pokered-unique8-v6"];
assert!(matches!(decide(&old, &new, &migrates, &[]), RestoreDecision::Refuse(_))); assert!(matches!(decide(&old, &new, &migrates, &[]), RestoreDecision::Refuse(_)));
assert_eq!( assert_eq!(
decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v5"))), decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v6"))),
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() } RestoreDecision::MigrateAdapter { from: "pokered-unique8-v6".to_string() }
); );
// And only for a pair the running adapter says it can migrate. // And only for a pair the running adapter says it can migrate.
assert!(matches!( assert!(matches!(
decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v5"))), decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v6"))),
RestoreDecision::Refuse(_) RestoreDecision::Refuse(_)
)); ));
} }
#[test] #[test]
fn nothing_but_the_adapter_segment_may_move() { fn nothing_but_the_adapter_segment_may_move() {
let migrates = ["pokered-unique8-v5"]; let migrates = ["pokered-unique8-v6"];
let accepted = accepted_adapters(Some("pokered-unique8-v5")); let accepted = accepted_adapters(Some("pokered-unique8-v6"));
let new = with_adapter("pokered-unique8-v6"); let new = with_adapter("pokered-unique8-v7");
// A different dataset, with the same adapter bump, is not a migration. // A different dataset, with the same adapter bump, is not a migration.
let other_dataset = Compatibility { let other_dataset = Compatibility {
adapter: "pokered-unique8-v5", adapter: "pokered-unique8-v6",
dataset_fingerprint: "00:11:22:33:44:55:66", dataset_fingerprint: "00:11:22:33:44:55:66",
..fixture() ..fixture()
} }
@ -236,7 +236,7 @@ mod tests {
// Neither is a different kernel, and neither is a string of another shape. // Neither is a different kernel, and neither is a string of another shape.
let other_kernel = let other_kernel =
Compatibility { adapter: "pokered-unique8-v5", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() } Compatibility { adapter: "pokered-unique8-v6", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() }
.string(); .string();
assert!(matches!( assert!(matches!(
decide(&other_kernel, &new, &migrates, &accepted), decide(&other_kernel, &new, &migrates, &accepted),
@ -250,8 +250,8 @@ mod tests {
assert!(accepted_adapters(None).is_empty()); assert!(accepted_adapters(None).is_empty());
assert!(accepted_adapters(Some(" ")).is_empty()); assert!(accepted_adapters(Some(" ")).is_empty());
assert_eq!( assert_eq!(
accepted_adapters(Some("pokered-unique8-v5, pokered-unique8-v4")), accepted_adapters(Some("pokered-unique8-v6, pokered-unique8-v5")),
vec!["pokered-unique8-v5".to_string(), "pokered-unique8-v4".to_string()] vec!["pokered-unique8-v6".to_string(), "pokered-unique8-v5".to_string()]
); );
} }

View file

@ -1,4 +1,4 @@
//! The `pokered-unique8-v6` reward catalog. //! The `pokered-unique8-v7` reward catalog.
//! //!
//! A direct port of the prototype's `src/reward/catalog.ts`, including the //! A direct port of the prototype's `src/reward/catalog.ts`, including the
//! declaration order, which is the order `counts` and `last` serialize in. //! declaration order, which is the order `counts` and `last` serialize in.
@ -21,6 +21,8 @@ pub mod kind {
pub const BADGE: &str = "badge"; pub const BADGE: &str = "badge";
pub const BOUNDARY: &str = "boundary"; pub const BOUNDARY: &str = "boundary";
pub const CATCH: &str = "catch"; pub const CATCH: &str = "catch";
pub const TALK: &str = "talk";
pub const ITEM: &str = "item";
} }
/// What a `catch` of a species this run has already caught pays. /// What a `catch` of a species this run has already caught pays.
@ -44,7 +46,7 @@ pub struct RewardRule {
pub stimulation_ms: u32, pub stimulation_ms: u32,
} }
pub const REWARDS: [RewardRule; 9] = [ pub const REWARDS: [RewardRule; 11] = [
RewardRule { RewardRule {
kind: kind::MILESTONE, kind: kind::MILESTONE,
label: "Story", label: "Story",
@ -125,6 +127,30 @@ pub const REWARDS: [RewardRule; 9] = [
value: 0.30, value: 0.30,
stimulation_ms: 150, stimulation_ms: 150,
}, },
// The operator's decision of 2026-09-23: pay the fly for engaging with what is *inside* a
// building rather than for leaving it (`boundary` pays nothing on an indoor map from v7).
// Both appended, for the reason every rule since `boundary` was: the declaration order is
// the key order `counts` serializes in, and every checkpoint already written carries the
// first nine in this order.
//
// `talk` is one payout per person or sign per map for the lifetime of the ledger, and only
// indoors: the conversation the fly opened by pressing A at it, paid when the box closes.
RewardRule {
kind: kind::TALK,
label: "Talk",
trigger: "Conversation the fly opened indoors; once per map and person or sign",
value: 0.10,
stimulation_ms: 100,
},
// `item` is one payout per item ball or hidden item for the lifetime of the ledger, on any
// map: the cartridge's own "this one has been taken" bit rising.
RewardRule {
kind: kind::ITEM,
label: "Item",
trigger: "Item ball or hidden item picked up; once per item",
value: 0.15,
stimulation_ms: 120,
},
]; ];
/// Position of `kind` in [`REWARDS`], or `None` for an unknown kind. This is /// Position of `kind` in [`REWARDS`], or `None` for an unknown kind. This is
@ -229,12 +255,17 @@ mod tests {
assert_eq!(rule(kind::CATCH).unwrap().value, 0.30); assert_eq!(rule(kind::CATCH).unwrap().value, 0.30);
assert_eq!(CATCH_REPEAT_VALUE, 0.10); assert_eq!(CATCH_REPEAT_VALUE, 0.10);
assert_eq!(rule(kind::CATCH).unwrap().stimulation_ms, 150); assert_eq!(rule(kind::CATCH).unwrap().stimulation_ms, 150);
// Nor these: the operator's engagement rules, `pokered-unique8-v7`.
assert_eq!(rule(kind::TALK).unwrap().value, 0.10);
assert_eq!(rule(kind::TALK).unwrap().stimulation_ms, 100);
assert_eq!(rule(kind::ITEM).unwrap().value, 0.15);
assert_eq!(rule(kind::ITEM).unwrap().stimulation_ms, 120);
assert!(rule("blackout").is_none(), "the catalog has no penalties"); assert!(rule("blackout").is_none(), "the catalog has no penalties");
assert!(REWARDS.iter().all(|rule| rule.value > 0.0)); assert!(REWARDS.iter().all(|rule| rule.value > 0.0));
} }
#[test] #[test]
fn the_catch_rule_is_last_so_the_older_key_order_does_not_move() { fn new_rules_are_appended_so_the_older_key_order_does_not_move() {
let order: Vec<&str> = REWARDS.iter().map(|rule| rule.kind).collect(); let order: Vec<&str> = REWARDS.iter().map(|rule| rule.kind).collect();
assert_eq!( assert_eq!(
order, order,
@ -248,9 +279,11 @@ mod tests {
kind::BADGE, kind::BADGE,
kind::BOUNDARY, kind::BOUNDARY,
kind::CATCH, kind::CATCH,
kind::TALK,
kind::ITEM,
] ]
); );
assert_eq!(index(kind::CATCH), Some(REWARDS.len() - 1)); assert_eq!(index(kind::ITEM), Some(REWARDS.len() - 1));
} }
#[test] #[test]

View file

@ -0,0 +1,423 @@
//! What the engagement rules of `pokered-unique8-v7` read: indoors, a conversation the fly
//! opened, and an item picked up.
//!
//! The operator's decision of 2026-09-23 (`docs/rewards-learning.md`, "Engagement rewards"):
//! pay the fly for engaging with what is inside a building -- `talk` and `item` -- and stop
//! paying `boundary` for walking back out of one. Everything here is a read of game memory
//! after a frame; nothing chooses, biases or presses a button, and nothing is checkpointed.
//! The lifetime ledgers the payouts are keyed into are the adapter's own `seen` set, in
//! [`super::PokemonRedReward`].
use crate::adapter::MemoryReader;
use super::macros::state::{Facing, Player};
use super::state::{self, poke};
use super::symbols::ram;
/// Tileset ids, `constants/tileset_constants.asm` at [`super::symbols::POKERED_COMMIT`]. Only the
/// ones the indoor rule names.
pub mod tileset {
pub const OVERWORLD: u8 = 0;
pub const FOREST: u8 = 3;
pub const UNDERGROUND: u8 = 11;
pub const SHIP_PORT: u8 = 14;
pub const CAVERN: u8 = 17;
pub const PLATEAU: u8 = 23;
/// `DEF NUM_TILESETS EQU const_value`: 24 tilesets, ids 0 to 23.
pub const COUNT: u8 = 24;
}
/// Whether a map with this tileset is **inside a building**, by the cartridge's own two tables.
///
/// - `CheckIfInOutsideMap` (`home/overworld.asm`) is the game's own outdoor test: tileset
/// `OVERWORLD` or `PLATEAU` is "a town or route", and `WarpFound2` labels the other branch
/// `.indoorMaps`. On its own that also calls Viridian Forest and every cave indoor.
/// - `BikeRidingTilesets` (`data/tilesets/bike_riding_tilesets.asm`) is the game's list of places
/// a bicycle may be ridden -- `OVERWORLD`, `FOREST`, `UNDERGROUND`, `SHIP_PORT`, `CAVERN` -- and
/// the bike is the one thing the cartridge refuses *inside a building* by rule.
///
/// Indoor is neither: not outside, and not somewhere the bike is allowed. That is every house,
/// mart, Pokémon Center, gym, gate, lab, museum, the S.S. Anne, Silph Co., the Pokémon Tower, the
/// Mansion, the Rocket Hideout and the Indigo Plateau's rooms -- and *not* Viridian Forest, a
/// cave, the Underground Path or Vermilion's dock, whose exits are how the fly gets anywhere.
/// A tileset id past the table is not indoor: an unreadable map is never a reason to withhold
/// `boundary`.
pub fn indoor(tileset: u8) -> bool {
use tileset::*;
tileset < COUNT
&& !matches!(
tileset,
OVERWORLD | PLATEAU | FOREST | UNDERGROUND | SHIP_PORT | CAVERN
)
}
/// What a conversation was with, as `DisplayTextID` names it: a sprite slot, or a sign's text id.
///
/// The same split the macros' session `talked` ledger uses, but this is not that ledger: the
/// payout is keyed into the adapter's lifetime `seen` set, which is checkpointed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Thing {
Sprite(u8),
Sign(u8),
}
/// A conversation the fly opened and the cartridge has now closed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Conversation {
pub map: u8,
pub thing: Thing,
}
impl Conversation {
/// The `seen` ledger key: one payout per `(map, object)` for the lifetime of the ledger.
pub fn key(&self) -> String {
match self.thing {
Thing::Sprite(slot) => format!("talk:{}:sprite:{slot}", self.map),
Thing::Sign(id) => format!("talk:{}:sign:{id}", self.map),
}
}
pub fn label(&self) -> String {
match self.thing {
Thing::Sprite(slot) => format!("TALKED TO #{slot} IN AREA {}", self.map),
Thing::Sign(id) => format!("READ SIGN #{id} IN AREA {}", self.map),
}
}
}
/// Samples after the box opens by which `DisplayTextID` has certainly written its argument.
///
/// `DisplayTextIDInit` sets the font bit and then loads the font's tiles into VRAM, which takes
/// frames; `DisplayTextID` copies its argument into `wSpriteIndex` only after that. Measured on
/// the cartridge (`tests/rom_engage.rs`, the Viridian Forest north gate): the bit rose on one
/// frame and the argument arrived **twenty frames** later. Until then the byte still holds
/// whatever the *last* text was about -- which may well be the person in front of the fly, from
/// a conversation that did not pay -- so it is not read as this conversation's argument until it
/// has changed, or until this many samples have gone by, after which an unchanged byte means the
/// new text is about the same thing as the last one. More than twice the measured delay.
const ARGUMENT_SETTLED: u8 = 45;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Armed {
map: u8,
x: u32,
y: u32,
/// The fly had the joypad and was standing still: [`state::controllable`] and a zero
/// `wWalkCounter`. The overworld only reads an A press in that state.
ready: bool,
/// `wSpriteIndex` before the box opened: the previous text's argument.
stale: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Opening {
map: u8,
samples: u8,
stale: u8,
/// The bottom dialogue box has been on screen during this opening.
dialogue: bool,
}
/// The `talk` rule's per-frame watch. Transient: a restore or a rollback clears it, so a
/// conversation in flight at a checkpoint pays nothing, which is the conservative answer.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TalkWatch {
armed: Option<Armed>,
opening: Option<Opening>,
pending: Option<Conversation>,
}
impl TalkWatch {
pub fn clear(&mut self) {
*self = Self::default();
}
/// A sample that is not the overworld -- a battle. Whatever the fly was doing before it is
/// not what opens the next text box, so the arming is dropped; a conversation already open
/// (a trainer the fly spoke to) stays pending and pays when its box is finally closed.
pub fn interrupt(&mut self) {
self.armed = None;
self.opening = None;
}
/// One overworld sample (`wIsInBattle` zero, every playability gate passed).
///
/// A conversation pays when all of this holds, each read out of WRAM:
///
/// 1. **The fly started it.** On the last sample before the text box opened
/// (`wFontLoaded` bit 0 rising) the fly had the joypad -- no ignored buttons, no simulated
/// input, no scripted movement ([`state::controllable`]) -- was standing still
/// (`wWalkCounter` zero, which is the only state the overworld reads A in) and stood on the
/// tile it is on now. Most script text opens with the joypad already taken, and is not
/// `ready`; a map script that runs the frame after a step ends can open text while the fly
/// is still `ready`, so it pays only if rule 2 names the thing in front (none in the early
/// game; the Fighting Dojo master and the Elite Four once each).
/// 2. **It is with the thing in front of the fly.** `DisplayTextID` copies its argument into
/// `wSpriteIndex` once the font is loaded ([`ARGUMENT_SETTLED`] has the timing, and why
/// the byte is read only once it has changed or settled), in the bottom dialogue box --
/// the start menu is drawn elsewhere. A value up to `wNumSprites` is a sprite slot, and that sprite must stand
/// on the tile the player faces -- or one further, across a counter, on a tileset that has
/// counter tiles (`IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter`). A larger
/// value is a text id, and it must be the text id of the sign on the tile the player faces.
/// An item ball is a sprite but not a person: it pays `item`, not `talk`.
/// 3. **Indoors**, by [`indoor`], on the map the box opened on.
/// 4. **It finished.** The box closed again on the same map. A conversation that ends in a
/// warp, a restore or a blackout pays nothing.
///
/// Returns the conversation on the sample the box closes; the caller pays it once per key.
pub fn observe(
&mut self,
memory: &mut dyn MemoryReader,
map: u8,
x: u32,
y: u32,
) -> Option<Conversation> {
let open = memory.read8(ram::wFontLoaded) & poke::BIT_FONT_LOADED != 0;
if !open {
// A box that closes before its argument was ever seen to change: a short text about
// the same thing as the last one. The argument was written before a letter printed,
// so it is this text's; the dialogue box must have been drawn for it.
let argument = memory.read8(ram::wSpriteIndex);
if let Some(opening) = self.opening.take()
&& opening.dialogue
&& opening.map == map
&& let Some(thing) = thing_named(memory, argument)
{
self.pending = Some(Conversation { map, thing });
}
let finished = self
.pending
.take()
.filter(|conversation| conversation.map == map);
let ready = state::controllable(memory) && memory.read8(ram::wWalkCounter) == 0;
let stale = argument;
self.armed = Some(Armed {
map,
x,
y,
ready,
stale,
});
return finished;
}
if let Some(armed) = self.armed.take()
&& armed.ready
&& armed.map == map
&& armed.x == x
&& armed.y == y
&& self.pending.is_none()
&& indoor(memory.read8(ram::wCurMapTileset))
{
self.opening = Some(Opening {
map,
samples: 0,
stale: armed.stale,
dialogue: false,
});
}
if let Some(opening) = self.opening.as_mut() {
opening.samples += 1;
opening.dialogue |= state::text_box(memory).waiting;
let map = opening.map;
let argument = memory.read8(ram::wSpriteIndex);
if argument != opening.stale || opening.samples >= ARGUMENT_SETTLED {
// One reading, whichever way it goes: the argument this text was opened with,
// and only while the dialogue box is what is drawn (not the start menu's).
let dialogue = state::text_box(memory).waiting;
self.opening = None;
if dialogue && let Some(thing) = thing_named(memory, argument) {
self.pending = Some(Conversation { map, thing });
}
}
}
None
}
}
/// The step `facing` points at from `(x, y)`, or `None` off the top or left of the map.
fn ahead(x: u8, y: u8, facing: Facing) -> Option<(u8, u8)> {
let (dx, dy) = facing.delta();
let x = u8::try_from(i16::from(x) + dx).ok()?;
let y = u8::try_from(i16::from(y) + dy).ok()?;
Some((x, y))
}
/// `wMapSpriteExtraData`'s two bytes for a sprite slot (1-based, as `hSpriteIndex` is).
fn extra_data(memory: &mut dyn MemoryReader, slot: u8) -> (u8, u8) {
let entry = ram::wMapSpriteExtraData + (u16::from(slot) - 1) * 2;
(memory.read8(entry), memory.read8(entry + 1))
}
/// Whether sprite `slot` is an item ball: `LoadMapHeader` writes `(item id, 0)` into its extra
/// data for an `ITEM`-flagged `object_event`, `(trainer class, trainer number)` for a `TRAINER`
/// one -- trainer numbers start at 1 -- and two zeroes for everything else.
fn is_item_ball(memory: &mut dyn MemoryReader, slot: u8) -> bool {
let (item, second) = extra_data(memory, slot);
item != 0 && second == 0
}
/// `DisplayTextID`'s argument, if it names something the player is facing.
///
/// The caller also asks for the bottom dialogue box ([`state::text_box`]'s `waiting`):
/// `DisplayTextIDInit` draws it for every text id but the start menu's, which it draws at the top
/// right instead.
fn thing_named(memory: &mut dyn MemoryReader, argument: u8) -> Option<Thing> {
if argument == 0 {
// TEXT_START_MENU.
return None;
}
let player: Player = state::player(memory)?;
let one = ahead(player.x, player.y, player.facing)?;
let sprites = memory.read8(ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
if argument <= sprites {
let npc = state::npcs(memory)
.into_iter()
.find(|npc| npc.slot == argument)?;
let at = (npc.x, npc.y);
let reached = at == one
|| (ahead(one.0, one.1, player.facing) == Some(at)
&& state::counter_tiles(memory)
.iter()
.any(|tile| *tile != poke::NO_COUNTER_TILE));
if reached && !is_item_ball(memory, argument) {
return Some(Thing::Sprite(argument));
}
return None;
}
state::signs(memory)
.into_iter()
.any(|sign| sign.text_id == argument && (sign.x, sign.y) == one)
.then_some(Thing::Sign(argument))
}
/// `wToggleableObjectFlags` is `flag_array $100`.
const TOGGLE_BYTES: usize = 32;
/// `wObtainedHiddenItemsFlags` is `flag_array MAX_HIDDEN_ITEMS`, and `MAX_HIDDEN_ITEMS` is 112
/// (`constants/item_constants.asm`).
const HIDDEN_ITEM_BYTES: usize = 14;
/// `wToggleableObjectList` is `ds 16 * 2 + 1`: sixteen `(sprite slot, global index)` pairs and a
/// `$ff` terminator.
const TOGGLE_LIST_ENTRIES: u16 = 16;
/// The two item balls a script *reveals*: `TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4` (`$87`, the Silph
/// Scope) and `TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5` (`$88`, the Lift Key), the only `ITEM`
/// `object_event`s `data/maps/toggleable_objects.asm` starts `OFF`, and the only item entries
/// `constants/toggle_constants.asm` does not mark "X, never toggled by a script".
///
/// Every other item ball's bit is clear from a new game until `PickUpItem` sets it, so a set bit
/// is a pickup. These two are set from the start and cleared when Giovanni's defeat shows them,
/// so seeding them as "already taken" would withhold two payouts for ever. They are the only
/// bits the seed leaves out.
pub const SCRIPT_SHOWN_ITEM_BALLS: [u8; 2] = [0x87, 0x88];
/// The cartridge's two "this item has been taken" bitsets, as of one sample.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemFlags {
toggles: [u8; TOGGLE_BYTES],
hidden: [u8; HIDDEN_ITEM_BYTES],
}
fn bit(bytes: &[u8], index: usize) -> bool {
bytes
.get(index / 8)
.is_some_and(|byte| byte & (1 << (index % 8)) != 0)
}
impl ItemFlags {
pub fn read(memory: &mut dyn MemoryReader) -> Self {
let mut toggles = [0; TOGGLE_BYTES];
for (offset, byte) in toggles.iter_mut().enumerate() {
*byte = memory.read8(ram::wToggleableObjectFlags + offset as u16);
}
let mut hidden = [0; HIDDEN_ITEM_BYTES];
for (offset, byte) in hidden.iter_mut().enumerate() {
*byte = memory.read8(ram::wObtainedHiddenItemsFlags + offset as u16);
}
Self { toggles, hidden }
}
/// Ledger keys for every item this state already shows as taken: the seed that stops a
/// pickup made before the rule existed from paying after a rollback un-takes it.
///
/// Every set toggle bit but [`SCRIPT_SHOWN_ITEM_BALLS`] -- which includes the bits of people
/// a script has hidden, harmlessly, because only an item ball's key is ever looked up -- and
/// every set hidden-item bit.
pub fn seed(&self) -> Vec<String> {
let mut keys = Vec::new();
for index in 0..TOGGLE_BYTES * 8 {
if bit(&self.toggles, index) && !SCRIPT_SHOWN_ITEM_BALLS.contains(&(index as u8)) {
keys.push(ball_key(index as u8));
}
}
for index in 0..HIDDEN_ITEM_BYTES * 8 {
if bit(&self.hidden, index) {
keys.push(hidden_key(index as u8));
}
}
keys
}
}
/// One item the fly has just picked up.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pickup {
pub key: String,
pub label: String,
}
pub fn ball_key(global: u8) -> String {
format!("item:{global}")
}
pub fn hidden_key(index: u8) -> String {
format!("hidden:{index}")
}
/// Items whose "taken" bit rose between `before` and `now`.
///
/// - **An item ball** is one of this map's toggleable sprites (`wToggleableObjectList`) whose
/// extra data says item ([`is_item_ball`]). `PickUpItem` sets its global bit in
/// `wToggleableObjectFlags` through `HideObject`, and only after `GiveItem` succeeded, so a full
/// bag pays nothing. A toggleable that is not an item -- a person a script hides, a legendary
/// after its battle -- is never looked at.
/// - **A hidden item** is a bit of `wObtainedHiddenItemsFlags`, which `FoundHiddenItemText` sets
/// after `GiveItem` succeeded and nothing else in the game writes. Hidden coins have a bitset of
/// their own and are not items.
///
/// A bit that was already set on the previous sample is not a pickup, which is what keeps a
/// restored or seeded state from paying for anything it already holds.
pub fn pickups(memory: &mut dyn MemoryReader, before: &ItemFlags, now: &ItemFlags) -> Vec<Pickup> {
let mut out = Vec::new();
if now == before {
// The overwhelmingly common frame: nothing was taken, and nothing more need be read.
return out;
}
let sprites = memory.read8(ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
for entry in 0..TOGGLE_LIST_ENTRIES {
let slot = memory.read8(ram::wToggleableObjectList + entry * 2);
if slot == 0xff {
break;
}
let global = memory.read8(ram::wToggleableObjectList + entry * 2 + 1);
let index = usize::from(global);
if slot == 0 || slot > sprites || !bit(&now.toggles, index) || bit(&before.toggles, index) {
continue;
}
let (item, second) = extra_data(memory, slot);
if item != 0 && second == 0 {
out.push(Pickup {
key: ball_key(global),
label: format!("FOUND ITEM #{item}"),
});
}
}
for index in 0..HIDDEN_ITEM_BYTES * 8 {
if bit(&now.hidden, index) && !bit(&before.hidden, index) {
out.push(Pickup {
key: hidden_key(index as u8),
label: "FOUND A HIDDEN ITEM".to_string(),
});
}
}
out
}

View file

@ -365,6 +365,24 @@ impl Wram {
self.set(ram::wNumSprites, count) self.set(ram::wNumSprites, count)
} }
/// One sprite the cartridge is not drawing: `$ff` in its image index, which is what
/// `CheckSpriteAvailability` writes for a sprite off the screen or switched off, and the
/// movement byte that decides whether the window test applies to it (row 58).
pub fn npc_undrawn(
&mut self,
slot: u8,
picture: u8,
x: u8,
y: u8,
movement: u8,
) -> &mut Self {
self.npc(slot, picture, x, y, 0x00);
let data1 = ram::wSpriteStateData1 + u16::from(slot) * poke::SPRITE_BYTES;
let data2 = ram::wSpriteStateData2 + u16::from(slot) * poke::SPRITE_BYTES;
self.set(data1 + poke::SPRITE_IMAGE_INDEX, poke::SPRITE_NOT_DRAWN)
.set(data2 + poke::SPRITE_MOVEMENT_BYTE, movement)
}
/// The current map's sign table: `bg_event`s, `Y, X` per entry with no bias, and a text id /// The current map's sign table: `bg_event`s, `Y, X` per entry with no bias, and a text id
/// each. /// each.
pub fn signs(&mut self, signs: &[(u8, u8, u8)]) -> &mut Self { pub fn signs(&mut self, signs: &[(u8, u8, u8)]) -> &mut Self {
@ -398,6 +416,38 @@ impl Wram {
pub const BLOCKSET_BANK: u8 = 0x11; pub const BLOCKSET_BANK: u8 = 0x11;
pub const BLOCKSET_BASE: u16 = 0x4000; pub const BLOCKSET_BASE: u16 = 0x4000;
/// Rows of the cartridge's move table, where `data/moves/moves.asm` puts it: `$0E:$4000`,
/// six bytes a row in move-id order, each opening with its own id. `(id, effect, power,
/// type)`; accuracy 100 and PP 35 stand in for the two bytes nothing here reads.
pub fn move_table(&mut self, rows: &[(u8, u8, u8, u8)]) -> &mut Self {
use super::state::poke::moves::{ROW_BYTES, TABLE_ADDRESS, TABLE_BANK};
for (id, effect, power, kind) in rows {
let base = TABLE_ADDRESS + u16::from(id - 1) * ROW_BYTES;
for (offset, byte) in [*id, *effect, *power, *kind, 0xff, 35].into_iter().enumerate() {
self.rom.insert((TABLE_BANK, base + offset as u16), byte);
}
}
self
}
/// One byte of a fake cartridge bank.
pub fn rom_byte(&mut self, bank: u8, address: u16, byte: u8) -> &mut Self {
self.rom.insert((bank, address), byte);
self
}
/// Every stage of both battlers at normal (7), as `InitBattleVariables`-era code leaves them.
pub fn normal_stages(&mut self) -> &mut Self {
for stat in 0..6 {
self.set(ram::wPlayerMonStatMods + stat, 7).set(ram::wEnemyMonStatMods + stat, 7);
}
for stat in 0..4 {
self.set_word_be(ram::wBattleMonAttack + 2 * stat, 12)
.set_word_be(ram::wEnemyMonAttack + 2 * stat, 9);
}
self
}
/// Which tileset the loaded map uses, for the tile-pair collision lists. /// Which tileset the loaded map uses, for the tile-pair collision lists.
pub fn tileset(&mut self, id: u8) -> &mut Self { pub fn tileset(&mut self, id: u8) -> &mut Self {
self.set(ram::wCurMapTileset, id) self.set(ram::wCurMapTileset, id)

View file

@ -377,6 +377,17 @@ pub trait MacroState: GameState {
false false
} }
/// Whether the battle engine will answer the fly's move `id` with nothing on this frame: a
/// stat stage already at its limit, a status move against a target it cannot affect.
///
/// Row 60 (`docs/design/macros.md` 12.23), read from the cartridge's own move table and the
/// bytes its effect routines test ([`crate::pokemon_red::state::move_without_effect`]). The
/// default is `false`: a state that cannot read the table has proved nothing, so the `MOVE n`
/// buttons stay where they were.
fn move_without_effect(&mut self, _id: u8) -> bool {
false
}
/// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable. /// Whether a `GO FRONTIER` on this map has already proved its frontier unreachable.
/// ///
/// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state /// [`FrontierLedger`] is the evidence and the measurement. The default is `false`: a state

View file

@ -18,13 +18,20 @@ use crate::macros::{
use super::super::mapgrid::MapGrids; use super::super::mapgrid::MapGrids;
use super::super::state::PokeState; use super::super::state::PokeState;
use super::cartridge::{Areas, Frontiers, MacroState, Pushed, Stood, Talked, Targets, Tile}; use super::cartridge::{
Areas, Frontiers, LAST_MAP, MacroState, Pushed, Stood, Talked, Targets, Tile, outdoors,
};
use super::geography; use super::geography;
use super::executor::{MacroAbort, MacroMachine, Refusal}; use super::executor::{MacroAbort, MacroMachine, Refusal};
use super::palette::{self, MacroId, Palette}; use super::palette::{self, MacroId, Palette};
use super::plan; use super::plan;
use super::state::{GameState, Scene}; use super::state::{GameState, Scene};
/// The longest a warp's tear is honoured (row 58): the thirty-two frames measured at the Pewter Gym
/// door, with room for a slower fade, and short enough that a false reading costs a second and a
/// half of an empty pad rather than a stall.
pub const TEAR_FRAMES: u16 = 90;
/// The macro palette over Pokémon Red. /// The macro palette over Pokémon Red.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PokemonPalette { pub struct PokemonPalette {
@ -99,6 +106,19 @@ pub struct PokemonPalette {
nearest: Option<(u8, u32)>, nearest: Option<(u8, u32)>,
/// Whether the last `observe` was the frame that number fell on. /// Whether the last `observe` was the frame that number fell on.
nearer: bool, nearer: bool,
/// The map of the last frame that was not a warp's tear, and how many tear frames have run
/// since (row 58, [`PokemonPalette::tear`], [`TEAR_FRAMES`]).
///
/// Measured on the cartridge at the Pewter Gym's door: `wCurMap` changes to the new map
/// **thirty-two frames** before the map header, the coordinates and the warp table follow it,
/// while the screen fades. On those frames every reading in the seam describes the map the fly
/// just left under the new map's id -- the player "on map 54 at (16, 17)", which is Pewter
/// City's doormat -- and nothing sets the joypad bits `controllable` reads until the fade is
/// over, so the scene read `Overworld` and a pad was dealt. A walk started there plans over
/// the wrong map, ends when the cartridge takes the joypad at the end of the fade, and the
/// ledgers wrote what it had been aiming at against the new map's id.
settled: Option<u8>,
tear_frames: u16,
/// The brain clock of the frame being decided, from [`MacroPalette::clock`]. /// The brain clock of the frame being decided, from [`MacroPalette::clock`].
/// ///
/// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather /// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather
@ -126,10 +146,46 @@ impl PokemonPalette {
grids: MapGrids::default(), grids: MapGrids::default(),
nearest: None, nearest: None,
nearer: false, nearer: false,
settled: None,
tear_frames: 0,
now_ms: 0.0, now_ms: 0.0,
} }
} }
/// Whether this frame is a warp's tear (row 58): the map byte has changed since the last
/// settled frame, and the fly still stands on a warp of the *loaded* table that leads to the
/// map the byte now names. Updates the settled map on every frame that is not one.
///
/// On a tear the warp table is still the map the fly just left, so the tile underfoot is the
/// door it walked through: Pewter City's (16, 17), whose destination is the gym, under the
/// gym's id; or a building's doormat, whose destination is `LAST_MAP`, under the id of the
/// town outside. Once the header loads, the table is the new map's and the tile underfoot is
/// the arrival warp, which leads back where the fly came from -- so the reading ends by itself.
/// Measured: thirty-two frames at the gym door each way.
///
/// The map byte having changed is what keeps the teleport pads of Saffron Gym and Silph Co. --
/// the three maps in Red with a warp to themselves -- from reading as a tear: a pad moves the
/// fly without changing the map. Bounded by [`TEAR_FRAMES`] all the same, because an empty pad
/// that did not end would be a fly that waits for ever.
fn tear(&mut self, state: &mut dyn MacroState) -> bool {
let Some(player) = state.player() else { return false };
let changed = self.settled.is_some_and(|was| was != player.map);
let torn = changed
&& self.tear_frames < TEAR_FRAMES
&& state.warps().iter().any(|warp| {
(warp.x, warp.y) == (player.x, player.y)
&& (warp.destination_map == player.map
|| (warp.destination_map == LAST_MAP && outdoors(player.map)))
});
if torn {
self.tear_frames += 1;
} else {
self.tear_frames = 0;
self.settled = Some(player.map);
}
torn
}
/// Frames the running macro has spent, for a log line. /// Frames the running macro has spent, for a log line.
pub fn frames(&self) -> u32 { pub fn frames(&self) -> u32 {
self.machine.frames() self.machine.frames()
@ -217,7 +273,7 @@ impl PokemonPalette {
} }
// A tile the cartridge drove the fly off: no window, because the map is like that until // A tile the cartridge drove the fly off: no window, because the map is like that until
// the event that unlocks it, and nothing here knows which event that is (row 37). // the event that unlocks it, and nothing here knows which event that is (row 37).
if let Some((map, tile)) = self.machine.take_pushed() { while let Some((map, tile)) = self.machine.take_pushed() {
self.pushed.record(map, tile); self.pushed.record(map, tile);
} }
// A refusal from where the fly is standing: that button is not dealt again from this tile // A refusal from where the fly is standing: that button is not dealt again from this tile
@ -243,6 +299,10 @@ impl MacroPalette for PokemonPalette {
} }
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed { fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
let torn = {
let mut state = PokeState::new(memory);
self.tear(&mut state)
};
let (scene, bindings, standing, stepping, approach) = { let (scene, bindings, standing, stepping, approach) = {
let Self { let Self {
machine, machine,
@ -265,7 +325,10 @@ impl MacroPalette for PokemonPalette {
// `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the // `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the
// palette and the scene the feed reports cannot disagree about which frame they are // palette and the scene the feed reports cannot disagree about which frame they are
// for. // for.
let scene = state.scene(); // A warp's tear is a warp in flight: the cartridge is driving and the seam's readings
// are the last map's under the new map's id, which is section 12.13's `Unknown` with
// nothing on screen -- an empty pad the fly waits out, for thirty-two frames (row 58).
let scene = if torn { Scene::Unknown } else { state.scene() };
// Whether a conversation has ended, and how, is a question about the frames *after* // Whether a conversation has ended, and how, is a question about the frames *after*
// the `TALK` gave the buttons back, so the machine is given every frame rather than // the `TALK` gave the buttons back, so the machine is given every frame rather than
// only the ones it owns (`docs/design/macros.md` section 12.4). // only the ones it owns (`docs/design/macros.md` section 12.4).
@ -279,17 +342,16 @@ impl MacroPalette for PokemonPalette {
// master: while the cartridge is walking it -- a warp in flight, a ledge hop, a script // master: while the cartridge is walking it -- a warp in flight, a ledge hop, a script
// -- the coordinates and the loaded map header are from different frames, and a tile // -- the coordinates and the loaded map header are from different frames, and a tile
// recorded from that pair is a tile of nowhere. // recorded from that pair is a tile of nowhere.
let standing = (!state.scripted()).then(|| state.player()).flatten(); let standing = (!state.scripted() && !torn).then(|| state.player()).flatten();
// And the tile the step in flight is landing on (row 54). Read from the same frame and // And the tile the step in flight is landing on (row 54). Read from the same frame and
// behind the same "the fly is its own master" gate as the ground itself. // behind the same "the fly is its own master" gate as the ground itself.
let stepping = standing.and_then(|_| state.stepping_onto()); let stepping = standing.and_then(|_| state.stepping_onto());
// How far the objective is, over the same map graph `GO OBJECTIVE` walks (section // How far the objective is, over the same map graph `GO OBJECTIVE` walks (section
// 12.15). Read from the same frame and the same state everything else is, and only // 12.15). Read from the same frame and the same state everything else is, and only
// where the fly is its own master, for the same reason the ground is. // where the fly is its own master, for the same reason the ground is.
let approach = standing.and_then(|player| { let approach = standing.and_then(|_| {
let objective = palette::objective_place(&mut state)?; let objective = palette::objective_place(&mut state)?;
let hops = let hops = geography::hops(palette::region_here(&mut state)?, objective.map)?;
geography::hops(geography::region_at(player.map, player.y), objective.map)?;
Some((objective.map, hops)) Some((objective.map, hops))
}); });
*cached = Some(palette); *cached = Some(palette);
@ -455,7 +517,7 @@ impl MacroPalette for PokemonPalette {
let _ = self.machine.take_reached(); let _ = self.machine.take_reached();
// A rollback is not the map pushing the fly anywhere, nor its frontier going out of // A rollback is not the map pushing the fly anywhere, nor its frontier going out of
// reach: the fly is about to be standing somewhere else. // reach: the fly is about to be standing somewhere else.
let _ = self.machine.take_pushed(); while self.machine.take_pushed().is_some() {}
let _ = self.machine.take_exhausted(); let _ = self.machine.take_exhausted();
let _ = self.machine.take_refused(); let _ = self.machine.take_refused();
// The cached palette was dealt for a frame that is being thrown away. Dropping it makes // The cached palette was dealt for a frame that is being thrown away. Dropping it makes
@ -525,6 +587,79 @@ mod tests {
} }
} }
#[test]
fn a_warps_tear_deals_no_pad() {
// Row 58, measured at the Pewter Gym's door: `wCurMap` names the gym for thirty-two frames
// while the header, the coordinates and the warp table are still Pewter City's -- "map 54
// at (16, 17)", which is the town's doormat. A pad dealt there started a walk over the
// wrong map, and what it was aiming at went into the ledgers under the gym's id.
let mut wram = Wram::overworld();
wram.map(maps::PEWTER_CITY, 20, 18, 16, 18)
.warps(&[(16, 17, 0, maps::PEWTER_GYM), (29, 13, 0, maps::PEWTER_MUSEUM_1F)]);
let mut palette = PokemonPalette::new(7);
let settled = palette.observe(&mut wram, &NoLedger);
assert_eq!(settled.scene, SceneId::Overworld);
// The step onto the door lands and the map byte changes; nothing else has loaded.
wram.map(maps::PEWTER_GYM, 20, 18, 16, 17);
let torn = palette.observe(&mut wram, &NoLedger);
assert_eq!(torn.scene, SceneId::Unknown, "a warp in flight");
assert!(torn.bindings.is_empty(), "and nothing to press: {:?}", torn.bindings);
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Unknown);
// The header loads: the gym's own size, its doormat, its own table.
wram.map(maps::PEWTER_GYM, 5, 7, 4, 13).warps(&[(4, 13, 2, 0xff), (5, 13, 2, 0xff)]);
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Overworld);
// And out again: the doormat's `LAST_MAP` under the town's id is the same tear.
wram.map(maps::PEWTER_CITY, 5, 7, 4, 13);
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Unknown);
}
#[test]
fn the_frames_between_a_trainers_text_and_its_battle_deal_no_pad_and_record_no_ground() {
// Row 61, Viridian Forest. A trainer who saw the fly: its "!" bubble and the five frames
// after its challenge text read as an ordinary overworld -- no box, no script bit,
// `wJoyIgnore` and `wCurOpponent` zero. The pad was dealt there and the push-back the
// fly's walk had earned when the trainer took the joypad was written there, walling the
// one free tile of the corridor to the north gate for the session.
let mut wram = Wram::overworld();
wram.set(crate::pokemon_red::symbols::ram::wStatusFlags7, 1 << 3);
let mut palette = PokemonPalette::new(7);
let engaged = palette.observe(&mut wram, &NoLedger);
assert_eq!(engaged.scene, SceneId::Unknown, "the cartridge's, inside the challenge");
assert!(engaged.bindings.is_empty(), "nothing to press: {:?}", engaged.bindings);
assert_eq!(palette.stood(), 0, "and no ground recorded from it");
// `.battleOccurred` clears the bit and the overworld is the fly's again.
wram.set(crate::pokemon_red::symbols::ram::wStatusFlags7, 0);
let own = palette.observe(&mut wram, &NoLedger);
assert_eq!(own.scene, SceneId::Overworld);
assert_eq!(palette.stood(), 1);
}
#[test]
fn a_teleport_pad_is_not_a_tear() {
// Saffron Gym and two Silph Co. floors warp to themselves. Standing on a pad whose
// destination is the map the fly is on is an ordinary frame there, and an empty pad on it
// would be a fly that waits for ever: the map byte did not change, so it is not a tear.
let mut wram = Wram::overworld();
wram.map(0xb2, 10, 9, 1, 1).warps(&[(1, 1, 3, 0xb2), (5, 5, 0, 0xb2)]);
let mut palette = PokemonPalette::new(7);
for _ in 0..3 {
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Overworld);
}
// And a tear that does not end is still bounded.
wram.map(0x02, 10, 9, 1, 1).warps(&[(1, 1, 0, 0x02)]);
let mut torn = 0;
for _ in 0..(TEAR_FRAMES + 10) {
if palette.observe(&mut wram, &NoLedger).scene == SceneId::Unknown {
torn += 1;
}
}
assert_eq!(torn, u32::from(TEAR_FRAMES), "at most {TEAR_FRAMES} frames");
}
#[test] #[test]
fn the_tile_a_step_is_landing_on_is_ground_the_run_has_covered() { fn the_tile_a_step_is_landing_on_is_ground_the_run_has_covered() {
// Row 54 of `infra/docs/macros-traps.md`. `wXCoord` and `wYCoord` are the tile the step // Row 54 of `infra/docs/macros-traps.md`. `wXCoord` and `wYCoord` are the tile the step

View file

@ -149,6 +149,12 @@ const ANSWER_REOPEN_FRAMES: u32 = 24;
/// animation is about three. /// animation is about three.
const HEAL_WAIT_FRAMES: u32 = 360; const HEAL_WAIT_FRAMES: u32 = 360;
/// Frames running the overworld has to be the fly's before a push-back is written as a refusal
/// ([`MacroMachine::observe_push`], row 59). A trainer's challenge text closes onto five frames of
/// overworld before the battle is decided; six times that is still half a second, and a refusal
/// the cartridge really made loses nothing by being written half a second late.
pub const PUSH_SETTLE_FRAMES: u32 = 30;
/// How a macro ended, i.e. the `outcome` field of the `macro` feed event (section 5: "outcome = /// How a macro ended, i.e. the `outcome` field of the `macro` feed event (section 5: "outcome =
/// done/blocked/timeout/refused"). /// done/blocked/timeout/refused").
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -541,6 +547,16 @@ struct PendingTalk {
at: Tile, at: Tile,
} }
/// What a macro the cartridge ended by taking the joypad earned, held until the cartridge gives
/// the joypad back ([`MacroMachine::pending_push`], row 58).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PendingPush {
/// The tile the fly was driven off, for the pushed ledger (row 37).
tile: Option<(u8, Tile)>,
/// The target the macro was aimed at, for the blocked ledger (section 12.4).
target: Option<(u8, TargetKey)>,
}
/// A walk the frame cap cut short, as the next start needs it. /// A walk the frame cap cut short, as the next start needs it.
/// ///
/// Keyed by the target rather than by the macro, because that is what a resumed walk *is*: the /// Keyed by the target rather than by the macro, because that is what a resumed walk *is*: the
@ -589,7 +605,7 @@ pub struct MacroMachine {
/// and the blocked ledger could only say it about the target the walk was aimed at. Recorded /// and the blocked ledger could only say it about the target the walk was aimed at. Recorded
/// from the frame the push is seen, because by the time the next macro starts the fly has been /// from the frame the push is seen, because by the time the next macro starts the fly has been
/// walked somewhere else. /// walked somewhere else.
pushed_tile: Option<(u8, Tile)>, pushed_tile: Vec<(u8, Tile)>,
/// A refusal the route search or the precondition made, and where the fly stood for it -- /// A refusal the route search or the precondition made, and where the fly stood for it --
/// `(map, slot, tile)`, waiting to be taken into the session's ledger /// `(map, slot, tile)`, waiting to be taken into the session's ledger
/// ([`super::cartridge::Targets::record_refused`], row 57 of `infra/docs/macros-traps.md`). /// ([`super::cartridge::Targets::record_refused`], row 57 of `infra/docs/macros-traps.md`).
@ -637,6 +653,24 @@ pub struct MacroMachine {
/// the next press will not undo. One hold of frames is the window, because that is how long /// the next press will not undo. One hold of frames is the window, because that is how long
/// the fly has to choose again; anything later and something else happened in between. /// the fly has to choose again; anything later and something else happened in between.
pending_answer: Option<PendingAnswer>, pending_answer: Option<PendingAnswer>,
/// A macro the cartridge ended by taking the joypad, whose ledger entries wait for the
/// cartridge to give it back (row 58).
///
/// Section 12.4 and row 37 read "the cartridge took the joypad" as the cartridge *refusing*
/// the step -- the Viridian gate's "This is private property!" and the walk back -- and wrote
/// the target into the blocked ledger and the tile into the pushed one on the spot. A trainer
/// who sees the fly takes the joypad the same way: the "!", the walk up, the challenge. In the
/// Pewter Gym that cost BROCK: the fly walked toward him past the Jr. Trainer's line of sight,
/// the trainer's walk ended the macro, BROCK went into the blocked ledger for ten brain
/// minutes and the tile the walk set out from into the pushed one for the session -- and a fly
/// that lost the battle and walked back found the leader excluded and the way out on the pad.
/// What the cartridge does when it gives the joypad back is what says which it was: back in
/// the overworld is a refusal and is written as one; a battle is a battle, and nothing about
/// the target or the ground is learned from it.
pending_push: Vec<PendingPush>,
/// How many frames running the overworld has been the fly's while [`Self::pending_push`]
/// waits: the push-back is decided at [`PUSH_SETTLE_FRAMES`] (row 59).
pending_push_calm: u32,
/// A finished `TALK`'s target, waiting to be taken into the session's talked ledger. /// A finished `TALK`'s target, waiting to be taken into the session's talked ledger.
/// ///
/// The machine records rather than keeps: the ledger is the driver's /// The machine records rather than keeps: the ledger is the driver's
@ -664,12 +698,14 @@ impl MacroMachine {
blocked: Vec::new(), blocked: Vec::new(),
reached: None, reached: None,
exhausted: None, exhausted: None,
pushed_tile: None, pushed_tile: Vec::new(),
refused_at: None, refused_at: None,
timed_out: None, timed_out: None,
resume: VecDeque::new(), resume: VecDeque::new(),
pending_talk: None, pending_talk: None,
pending_answer: None, pending_answer: None,
pending_push: Vec::new(),
pending_push_calm: 0,
talked: None, talked: None,
rng: if seed == 0 { 1 } else { seed }, rng: if seed == 0 { 1 } else { seed },
} }
@ -903,8 +939,11 @@ impl MacroMachine {
} }
/// The tile a scripted push-back earned, taken rather than read (row 37). /// The tile a scripted push-back earned, taken rather than read (row 37).
///
/// Call it until it answers `None`: the entries a script held back are written together when
/// it gives the joypad back (row 58).
pub fn take_pushed(&mut self) -> Option<(u8, Tile)> { pub fn take_pushed(&mut self) -> Option<(u8, Tile)> {
self.pushed_tile.take() if self.pushed_tile.is_empty() { None } else { Some(self.pushed_tile.remove(0)) }
} }
/// Where the last `no route` or `precondition` refusal happened, taken rather than read /// Where the last `no route` or `precondition` refusal happened, taken rather than read
@ -936,7 +975,7 @@ impl MacroMachine {
self.reached = None; self.reached = None;
// A rollback is not the map pushing the fly anywhere, and it is not the frontier being // A rollback is not the map pushing the fly anywhere, and it is not the frontier being
// out of reach either: the fly is about to be somewhere else entirely. // out of reach either: the fly is about to be somewhere else entirely.
self.pushed_tile = None; self.pushed_tile.clear();
self.exhausted = None; self.exhausted = None;
self.refused_at = None; self.refused_at = None;
self.timed_out = None; self.timed_out = None;
@ -948,6 +987,9 @@ impl MacroMachine {
self.pending_talk = None; self.pending_talk = None;
// Nor is it a prompt reopening: the frames the answer was made in are being thrown away. // Nor is it a prompt reopening: the frames the answer was made in are being thrown away.
self.pending_answer = None; self.pending_answer = None;
// Nor the cartridge refusing a step: the frames it happened in are being thrown away too.
self.pending_push.clear();
self.pending_push_calm = 0;
} }
/// Whether the fly is standing somewhere other than where the running macro began. /// Whether the fly is standing somewhere other than where the running macro began.
@ -976,6 +1018,7 @@ impl MacroMachine {
/// - the fly answered `NO` — not talked, and that one is decided in [`MacroMachine::finish`]. /// - the fly answered `NO` — not talked, and that one is decided in [`MacroMachine::finish`].
pub fn observe_frame(&mut self, state: &mut dyn MacroState) { pub fn observe_frame(&mut self, state: &mut dyn MacroState) {
self.observe_answer(state); self.observe_answer(state);
self.observe_push(state);
let Some(pending) = self.pending_talk else { return }; let Some(pending) = self.pending_talk else { return };
if state.scripted() { if state.scripted() {
self.pending_talk = None; self.pending_talk = None;
@ -1002,6 +1045,49 @@ impl MacroMachine {
} }
} }
/// One frame after the cartridge took the joypad from a macro: decide what it was (row 58).
///
/// Back in the overworld with the buttons the fly's again, for [`PUSH_SETTLE_FRAMES`] running:
/// a refusal, written exactly as section 12.4 and row 37 always wrote it. A battle: a
/// trainer's challenge, and it teaches the ledgers nothing. Anything else -- the text, the
/// walk, the frames between -- is still the cartridge's, and the decision waits.
///
/// The window is row 59's. A trainer's challenge text closes onto five frames of an ordinary
/// overworld -- no text, no script, no joypad bit, `wCurOpponent` still clear -- before
/// `StartTrainerBattle` runs (`home/trainers.asm`: it follows `DisplayTextID`, whose
/// close-down redraws the map first). Decided on the first of them, Route 3's first trainer
/// walled (11, 6), the one gap between the road's west end and the rest of it, for the
/// session, and the fly walked between Pewter City and that end for hours.
fn observe_push(&mut self, state: &mut dyn MacroState) {
if self.pending_push.is_empty() {
self.pending_push_calm = 0;
return;
}
match class(state.scene()) {
Class::Battle | Class::ForcedSwitch => {
self.pending_push.clear();
self.pending_push_calm = 0;
}
Class::Overworld if self.pending_push_calm + 1 < PUSH_SETTLE_FRAMES => {
self.pending_push_calm += 1;
}
Class::Overworld => {
self.pending_push_calm = 0;
// Every macro the script ended while it held the joypad -- the walk it interrupted
// and any press made into its text -- in the order they ended.
for pending in std::mem::take(&mut self.pending_push) {
if let Some(tile) = pending.tile {
self.pushed_tile.push(tile);
}
if let Some(target) = pending.target {
self.blocked.push(target);
}
}
}
_ => self.pending_push_calm = 0,
}
}
/// Whether the answer still standing on `map` is a `NO` to a readable prompt: a declined offer /// Whether the answer still standing on `map` is a `NO` to a readable prompt: a declined offer
/// rather than a conversation walked through (section 12.20). /// rather than a conversation walked through (section 12.20).
fn declined_out_of(&self, map: u8) -> bool { fn declined_out_of(&self, map: u8) -> bool {
@ -1127,14 +1213,18 @@ impl MacroMachine {
// entrance -- with no window, in the middle of the town. A dozen of those fenced // entrance -- with no window, in the middle of the town. A dozen of those fenced
// the fly into a pocket no walk could leave. The tile a walk last stood the fly on // the fly into a pocket no walk could leave. The tile a walk last stood the fly on
// is its own record of where the cartridge took over. // is its own record of where the cartridge took over.
if let Some(player) = at { //
// Held until the cartridge gives the joypad back, which is what says whether this
// was a refusal or a trainer walking up (row 58, [`MacroMachine::observe_push`]).
let tile = at.map(|player| {
let current = Tile::new(player.x, player.y); let current = Tile::new(player.x, player.y);
let tile = match active.plan.front() { let tile = match active.plan.front() {
Some(Step::Walk(walk)) => walk.expect.unwrap_or(current), Some(Step::Walk(walk)) => walk.expect.unwrap_or(current),
_ => active.from.unwrap_or(current), _ => active.from.unwrap_or(current),
}; };
self.pushed_tile = Some((player.map, tile)); (player.map, tile)
} });
self.pending_push.push(PendingPush { tile, target: None });
} }
let (closer, stalled) = walk_flags(&active); let (closer, stalled) = walk_flags(&active);
// A walk the cap cut short keeps its route for the next hold; any other ending means // A walk the cap cut short keeps its route for the next hold; any other ending means
@ -1181,8 +1271,15 @@ impl MacroMachine {
// target's own fact, not the world's, so it is excluded for the window like // target's own fact, not the world's, so it is excluded for the window like
// any other refusal. Without it the gate was walked into once per hold for // any other refusal. Without it the gate was walked into once per hold for
// ever, because every macro that hit it ended `Done`. // ever, because every macro that hit it ended `Done`.
if pushed && let Some(entry) = active.target { //
self.blocked.push(entry); // Held with the tile above, and for the same reason: a trainer's walk up to the
// fly takes the joypad exactly as the gate's walk back does, and only what the
// cartridge does next tells them apart (row 58).
if pushed
&& let Some(entry) = active.target
&& let Some(pending) = self.pending_push.last_mut()
{
pending.target = Some(entry);
} }
// A `GO FRONTIER` whose press faced new ground it could not stand on: `Done`, // A `GO FRONTIER` whose press faced new ground it could not stand on: `Done`,
// because facing it is what the arrival promises, and excluded, because the // because facing it is what the arrival promises, and excluded, because the

View file

@ -28,6 +28,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
use super::super::maps; use super::super::maps;
use super::cartridge::Edge; use super::cartridge::Edge;
use super::state::MapGrid;
/// A column with no connection on it. /// A column with no connection on it.
const NONE: u8 = 0xff; const NONE: u8 = 0xff;
@ -44,10 +45,13 @@ const EAST: usize = 3;
/// `wCurMapConnections`' four bits are loaded from. Kanto's overworld is one grid, so the table is /// `wCurMapConnections`' four bits are loaded from. Kanto's overworld is one grid, so the table is
/// symmetric by construction and [`neighbours`] does not rely on that — it reads both directions. /// symmetric by construction and [`neighbours`] does not rely on that — it reads both directions.
/// ///
/// Two rows are worth a note. `ROUTE_3` and `ROUTE_4` are connected along the east-west axis even /// Every row is the header's own, checked line by line against the disassembly at the pinned
/// though Mt. Moon stands between them, so the walkable path is the cave and not the edge; that /// commit (row 59). Four pairs had the right neighbour in the wrong column, and a wrong column is
/// costs nothing, because an edge whose tiles are not walkable produces no exit at all /// a wrong map on the other side of an edge: `ROUTE_3` / `ROUTE_4` (Route 4 is north of Route 3,
/// ([`super::path::exits`] filters on the walkable predicate) and the cave is in [`LINKS`]. /// not east, and Route 3's top edge is the road to Mt. Moon's Pokécenter), `ROUTE_14` /
/// `ROUTE_15` and `ROUTE_24` / `ROUTE_25` (west and east, not south and north), and `ROUTE_22` /
/// `ROUTE_23`, which the table had left out. A connection nobody can walk across is still the
/// header's, and [`NO_CROSSING`] says which.
const CONNECTIONS: &[(u8, [u8; 4])] = &[ const CONNECTIONS: &[(u8, [u8; 4])] = &[
(maps::PALLET_TOWN, [maps::ROUTE_1, maps::ROUTE_21, NONE, NONE]), (maps::PALLET_TOWN, [maps::ROUTE_1, maps::ROUTE_21, NONE, NONE]),
(maps::VIRIDIAN_CITY, [maps::ROUTE_2, maps::ROUTE_1, maps::ROUTE_22, NONE]), (maps::VIRIDIAN_CITY, [maps::ROUTE_2, maps::ROUTE_1, maps::ROUTE_22, NONE]),
@ -62,8 +66,8 @@ const CONNECTIONS: &[(u8, [u8; 4])] = &[
(maps::SAFFRON_CITY, [maps::ROUTE_5, maps::ROUTE_6, maps::ROUTE_7, maps::ROUTE_8]), (maps::SAFFRON_CITY, [maps::ROUTE_5, maps::ROUTE_6, maps::ROUTE_7, maps::ROUTE_8]),
(maps::ROUTE_1, [maps::VIRIDIAN_CITY, maps::PALLET_TOWN, NONE, NONE]), (maps::ROUTE_1, [maps::VIRIDIAN_CITY, maps::PALLET_TOWN, NONE, NONE]),
(maps::ROUTE_2, [maps::PEWTER_CITY, maps::VIRIDIAN_CITY, NONE, NONE]), (maps::ROUTE_2, [maps::PEWTER_CITY, maps::VIRIDIAN_CITY, NONE, NONE]),
(maps::ROUTE_3, [NONE, NONE, maps::PEWTER_CITY, maps::ROUTE_4]), (maps::ROUTE_3, [maps::ROUTE_4, NONE, maps::PEWTER_CITY, NONE]),
(maps::ROUTE_4, [NONE, NONE, maps::ROUTE_3, maps::CERULEAN_CITY]), (maps::ROUTE_4, [NONE, maps::ROUTE_3, NONE, maps::CERULEAN_CITY]),
(maps::ROUTE_5, [maps::CERULEAN_CITY, maps::SAFFRON_CITY, NONE, NONE]), (maps::ROUTE_5, [maps::CERULEAN_CITY, maps::SAFFRON_CITY, NONE, NONE]),
(maps::ROUTE_6, [maps::SAFFRON_CITY, maps::VERMILION_CITY, NONE, NONE]), (maps::ROUTE_6, [maps::SAFFRON_CITY, maps::VERMILION_CITY, NONE, NONE]),
(maps::ROUTE_7, [NONE, NONE, maps::CELADON_CITY, maps::SAFFRON_CITY]), (maps::ROUTE_7, [NONE, NONE, maps::CELADON_CITY, maps::SAFFRON_CITY]),
@ -73,28 +77,54 @@ const CONNECTIONS: &[(u8, [u8; 4])] = &[
(maps::ROUTE_11, [NONE, NONE, maps::VERMILION_CITY, maps::ROUTE_12]), (maps::ROUTE_11, [NONE, NONE, maps::VERMILION_CITY, maps::ROUTE_12]),
(maps::ROUTE_12, [maps::LAVENDER_TOWN, maps::ROUTE_13, maps::ROUTE_11, NONE]), (maps::ROUTE_12, [maps::LAVENDER_TOWN, maps::ROUTE_13, maps::ROUTE_11, NONE]),
(maps::ROUTE_13, [maps::ROUTE_12, NONE, maps::ROUTE_14, NONE]), (maps::ROUTE_13, [maps::ROUTE_12, NONE, maps::ROUTE_14, NONE]),
(maps::ROUTE_14, [NONE, maps::ROUTE_15, NONE, maps::ROUTE_13]), (maps::ROUTE_14, [NONE, NONE, maps::ROUTE_15, maps::ROUTE_13]),
(maps::ROUTE_15, [maps::ROUTE_14, NONE, maps::FUCHSIA_CITY, NONE]), (maps::ROUTE_15, [NONE, NONE, maps::FUCHSIA_CITY, maps::ROUTE_14]),
(maps::ROUTE_16, [NONE, maps::ROUTE_17, NONE, maps::CELADON_CITY]), (maps::ROUTE_16, [NONE, maps::ROUTE_17, NONE, maps::CELADON_CITY]),
(maps::ROUTE_17, [maps::ROUTE_16, maps::ROUTE_18, NONE, NONE]), (maps::ROUTE_17, [maps::ROUTE_16, maps::ROUTE_18, NONE, NONE]),
(maps::ROUTE_18, [maps::ROUTE_17, NONE, NONE, maps::FUCHSIA_CITY]), (maps::ROUTE_18, [maps::ROUTE_17, NONE, NONE, maps::FUCHSIA_CITY]),
(maps::ROUTE_19, [maps::FUCHSIA_CITY, NONE, maps::ROUTE_20, NONE]), (maps::ROUTE_19, [maps::FUCHSIA_CITY, NONE, maps::ROUTE_20, NONE]),
(maps::ROUTE_20, [NONE, NONE, maps::CINNABAR_ISLAND, maps::ROUTE_19]), (maps::ROUTE_20, [NONE, NONE, maps::CINNABAR_ISLAND, maps::ROUTE_19]),
(maps::ROUTE_21, [maps::PALLET_TOWN, maps::CINNABAR_ISLAND, NONE, NONE]), (maps::ROUTE_21, [maps::PALLET_TOWN, maps::CINNABAR_ISLAND, NONE, NONE]),
// Route 22 ends at the League gate, which is a building rather than an edge, and this (maps::ROUTE_22, [maps::ROUTE_23, NONE, NONE, maps::VIRIDIAN_CITY]),
// table has no id for it: Indigo Plateau is on the graph but not reachable from the south. (maps::ROUTE_23, [maps::INDIGO_PLATEAU, maps::ROUTE_22, NONE, NONE]),
(maps::ROUTE_22, [NONE, NONE, NONE, maps::VIRIDIAN_CITY]), (maps::ROUTE_24, [NONE, maps::CERULEAN_CITY, NONE, maps::ROUTE_25]),
(maps::ROUTE_23, [maps::INDIGO_PLATEAU, NONE, NONE, NONE]), (maps::ROUTE_25, [NONE, NONE, maps::ROUTE_24, NONE]),
(maps::ROUTE_24, [maps::ROUTE_25, maps::CERULEAN_CITY, NONE, NONE]),
(maps::ROUTE_25, [NONE, maps::ROUTE_24, NONE, NONE]),
]; ];
/// Connections in the headers that no step on foot crosses, from both sides.
///
/// Row 59, measured from the disassembly: for every connection, the tiles of this map's edge that
/// are walkable *and* land on a walkable tile of the other map's strip (the header's offset, the
/// other map's blocks and collision list). These eight have none. Pallet Town's south edge has
/// two walkable tiles and Route 21 is water under both; Cinnabar's east edge and Route 20's two
/// ends are sea; Route 22's north edge is the League's fence, and the road is its gate, a building
/// the graph has no row for. The map on the other side is still named ([`connected`]), and a
/// surfer's road is for a later row; on foot none of them is a way out
/// ([`super::path::exits`] offers no exit on them) or a road ([`neighbours`] leaves them out).
/// Without this the road from Pallet Town to Cerulean was by sea, and a fly that whited out in
/// Mt. Moon walked into Pallet's shore once every two seconds.
const NO_CROSSING: &[(u8, Edge)] = &[
(maps::PALLET_TOWN, Edge::South),
(maps::ROUTE_21, Edge::North),
(maps::CINNABAR_ISLAND, Edge::East),
(maps::ROUTE_20, Edge::West),
(maps::ROUTE_20, Edge::East),
(maps::ROUTE_19, Edge::West),
(maps::ROUTE_22, Edge::North),
(maps::ROUTE_23, Edge::South),
];
/// Whether a step off `map`'s `edge` can land on the other map on foot ([`NO_CROSSING`]).
pub fn crossable(map: u8, edge: Edge) -> bool {
!NO_CROSSING.contains(&(map, edge))
}
/// Doors and floor changes, as undirected pairs of maps. /// Doors and floor changes, as undirected pairs of maps.
/// ///
/// Only the ones a rung place needs a route through, because that is all [`next_hop`] is for: an /// Only the ones a rung place needs a route through, because that is all [`next_hop`] is for: an
/// unlisted building is simply not on the graph, which makes it a place `GO OBJECTIVE` cannot aim /// unlisted building is simply not on the graph, which makes it a place `GO OBJECTIVE` cannot aim
/// at from another map and changes nothing else. A cave with two mouths appears twice, which is /// at from another map and changes nothing else. Every pair is two warp tables that name each
/// what makes Mt. Moon a way from Route 3 to Route 4. /// other (a `LAST_MAP` door resolved to the one outdoor map whose warps lead in).
const LINKS: &[(u8, u8)] = &[ const LINKS: &[(u8, u8)] = &[
(maps::REDS_HOUSE_1F, maps::PALLET_TOWN), (maps::REDS_HOUSE_1F, maps::PALLET_TOWN),
(maps::REDS_HOUSE_2F, maps::REDS_HOUSE_1F), (maps::REDS_HOUSE_2F, maps::REDS_HOUSE_1F),
@ -122,10 +152,14 @@ const LINKS: &[(u8, u8)] = &[
(maps::PEWTER_MUSEUM_2F, maps::PEWTER_MUSEUM_1F), (maps::PEWTER_MUSEUM_2F, maps::PEWTER_MUSEUM_1F),
(maps::PEWTER_MART, maps::PEWTER_CITY), (maps::PEWTER_MART, maps::PEWTER_CITY),
(maps::PEWTER_POKECENTER, maps::PEWTER_CITY), (maps::PEWTER_POKECENTER, maps::PEWTER_CITY),
(maps::MT_MOON_1F, maps::ROUTE_3), // Mt. Moon has two mouths, and both are on Route 4 (`data/maps/objects/Route4.asm`): (18, 5)
// into the first floor, and (24, 5) into B1F, whose (27, 3) is the way back out on the far
// side of the mountain. Route 3 has no warps at all. Which chamber of B1F and B2F each ladder
// opens onto is [`SPLIT`]'s business.
(maps::MT_MOON_1F, maps::ROUTE_4), (maps::MT_MOON_1F, maps::ROUTE_4),
(maps::MT_MOON_1F, maps::MT_MOON_B1F), (maps::MT_MOON_1F, maps::MT_MOON_B1F),
(maps::MT_MOON_B1F, maps::MT_MOON_B2F), (maps::MT_MOON_B1F, maps::MT_MOON_B2F),
(maps::MT_MOON_B1F, maps::ROUTE_4),
(maps::CERULEAN_GYM, maps::CERULEAN_CITY), (maps::CERULEAN_GYM, maps::CERULEAN_CITY),
(maps::CERULEAN_MART, maps::CERULEAN_CITY), (maps::CERULEAN_MART, maps::CERULEAN_CITY),
(maps::CERULEAN_POKECENTER, maps::CERULEAN_CITY), (maps::CERULEAN_POKECENTER, maps::CERULEAN_CITY),
@ -141,11 +175,12 @@ const LINKS: &[(u8, u8)] = &[
/// the forest's south gate; the north half touches Pewter City and the forest's north gate; the /// the forest's south gate; the north half touches Pewter City and the forest's north gate; the
/// belt of trees between them needs CUT. A graph with one node for it answered "Pewter is two /// belt of trees between them needs CUT. A graph with one node for it answered "Pewter is two
/// hops from the south gate, south" — which is a road that does not exist — and sent the fly back /// hops from the south gate, south" — which is a road that does not exist — and sent the fly back
/// out of the gate it had just walked into, once per hold, for four hours. /// out of the gate it had just walked into, once per hold, for four hours. Row 59 found three more
/// on the road to Cerulean: Route 4, and Mt. Moon's two lower floors ([`SPLIT`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Region { pub struct Region {
pub map: u8, pub map: u8,
/// Which piece, for a map [`SPLIT`] has a row for; 0 everywhere else. /// Which piece, for a map [`SPLIT`] has a row for: its index in that row. 0 everywhere else.
pub part: u8, pub part: u8,
} }
@ -154,108 +189,268 @@ impl Region {
pub const fn whole(map: u8) -> Self { pub const fn whole(map: u8) -> Self {
Self { map, part: 0 } Self { map, part: 0 }
} }
/// Piece `part` of a map [`SPLIT`] has a row for.
pub const fn piece(map: u8, part: u8) -> Self {
Self { map, part }
}
} }
/// [`SPLIT`]'s two piece numbers. /// One piece of a split map.
const NORTH_PIECE: u8 = 0; struct Piece {
const SOUTH_PIECE: u8 = 1; /// The map's own warps that stand on this piece's ground: the index into its warp table (as
/// `wWarpEntries` holds it, and as a warp elsewhere names it for its destination, 0-based) and
/// the tile. A warp that lands on one of these lands in this piece; the tiles are what the
/// fly's own piece is told apart by ([`region_on`]).
doors: &'static [(u8, u8, u8)],
/// Everything one step from this piece: a whole map, or a piece of another split map. Their
/// maps together are exactly [`neighbours`]'s answer for the map, and every step is listed
/// back from the other side; [`tests::a_split_maps_pieces_add_up_and_answer_each_other`] pins
/// both.
next: &'static [Region],
}
/// A map whose walkable ground is in two pieces, and which of its neighbours each piece touches. /// A map whose walkable ground is in pieces the player cannot walk between.
struct Split { struct Split {
map: u8, map: u8,
/// The tile rows each piece's own doorway is on, measured from the cartridge: a tile belongs pieces: &'static [Piece],
/// to the piece whose row it is nearer to. Anchoring on the doorways rather than on a row in
/// the middle means the number comes from the warp table rather than from a claim about where
/// the trees are, and a tile in the impassable belt between them — ground the fly cannot
/// stand on — is the only place the answer could be wrong.
north_door: u8,
south_door: u8,
/// The neighbours reachable from each piece. Together they are exactly [`neighbours`]'s answer
/// for the map, which [`tests::a_split_maps_pieces_divide_its_neighbours_between_them`] pins.
north: &'static [u8],
south: &'static [u8],
} }
/// Every map whose ground is in two pieces. One row, and it took four hours of stream to find. /// Route 2's two halves, in [`SPLIT`]'s order.
#[cfg(test)]
const NORTH_PIECE: u8 = 0;
#[cfg(test)]
const SOUTH_PIECE: u8 = 1;
/// Route 4's two sides of the mountain.
#[cfg(test)]
const WEST_SIDE: u8 = 0;
const EAST_SIDE: u8 = 1;
/// Mt. Moon B1F's four chambers, named by what they hold.
const B1F_EXIT: u8 = 0;
const B1F_WEST: u8 = 1;
const B1F_MIDDLE: u8 = 2;
const B1F_SOUTH: u8 = 3;
/// Mt. Moon B2F's three pieces.
const B2F_MAIN: u8 = 0;
const B2F_NORTH: u8 = 1;
const B2F_SOUTH: u8 = 2;
/// Every map whose ground is in pieces, with each piece's doors and neighbours.
/// ///
/// `ROUTE_2`, surveyed from the cartridge on 2026-09-17 (`docs/design/macros-wram.md`'s method, /// Every row is measured from the disassembly at the pinned commit: the map's blocks, its
/// the run recorded in `infra/docs/macros-traps.md` row 33). The map is 20 by 72 and its warp /// tileset's blockset and collision list, the tile-pair walls and the ledges, flooded tile by tile
/// table reads: /// (`infra/docs/macros-traps.md` row 59 has the method). A map is listed here only when two of its
/// ways out are on different pieces, and the audit ran over every map on the graph.
/// ///
/// | warp | tile | into | /// - **`ROUTE_2`** (row 33): the forest's north gate at (3, 11) and Pewter's edge; the south gate
/// | ---: | --- | --- | /// at (3, 43) and Viridian's. Maps 46, 48 and 49 stay off the graph, the module's standing rule
/// | 0 | (12, 9) | `DIGLETTS_CAVE_ROUTE_2` (46) | /// for a building no rung place needs a route through: 49's two doors are both Route 2's own.
/// | 1 | (3, 11) | `VIRIDIAN_FOREST_NORTH_GATE` (47) | /// - **`ROUTE_4`** (row 59): Mt. Moon stands across it. The west side holds the Pokécenter at
/// | 2 | (15, 19) | `ROUTE_2_TRADE_HOUSE` (48) | /// (11, 5), the cave mouth at (18, 5) and the road down to Route 3; the east side holds B1F's
/// | 3 | (16, 35) | `ROUTE_2_GATE` (49) | /// exit at (24, 5) and the ledges down to Cerulean. From the Pewter side the only way east is
/// | 4 | (15, 39) | `ROUTE_2_GATE` (49) | /// through the mountain.
/// | 5 | (3, 43) | `VIRIDIAN_FOREST_SOUTH_GATE` (50) | /// - **`MT_MOON_B1F`** (row 59): four chambers, each two ladders and nothing between them. The
/// /// one road through is 1F (5, 5) to the west chamber, (21, 17) down to B2F, B2F (5, 7) up to the
/// with `north: true` and `south: true` in `wCurMapConnections` — Pewter off the top row, Viridian /// exit chamber, (27, 3) out onto Route 4's east side. The middle and south chambers are ladders
/// off the bottom one. The two forest gates at rows 11 and 43 are the doorways this splits on. /// to dead ends on B2F.
/// /// - **`MT_MOON_B2F`** (row 59): the fossil floor, one large piece with the two ladders the road
/// Maps 46, 48 and 49 are deliberately *not* on the graph, which is this module's standing rule /// uses, and two small pieces under the dead-end ladders.
/// for a building no rung place needs a route through: 49's two doors are both warps of `ROUTE_2` const SPLIT: &[Split] = &[
/// itself, so it is a shortcut within one map rather than a way between two, and 46 and 48 are Split {
/// ends of the line. A route the table does not carry is simply not offered; nothing is guessed. map: maps::ROUTE_2,
const SPLIT: &[Split] = &[Split { pieces: &[
map: maps::ROUTE_2, Piece {
north_door: 11, doors: &[(1, 3, 11)],
south_door: 43, next: &[
north: &[maps::PEWTER_CITY, maps::VIRIDIAN_FOREST_NORTH_GATE], Region::whole(maps::PEWTER_CITY),
south: &[maps::VIRIDIAN_CITY, maps::VIRIDIAN_FOREST_SOUTH_GATE], Region::whole(maps::VIRIDIAN_FOREST_NORTH_GATE),
}]; ],
},
Piece {
doors: &[(5, 3, 43)],
next: &[
Region::whole(maps::VIRIDIAN_CITY),
Region::whole(maps::VIRIDIAN_FOREST_SOUTH_GATE),
],
},
],
},
Split {
map: maps::ROUTE_4,
pieces: &[
Piece {
doors: &[(0, 11, 5), (1, 18, 5)],
next: &[Region::whole(maps::ROUTE_3), Region::whole(maps::MT_MOON_1F)],
},
Piece {
doors: &[(2, 24, 5)],
next: &[
Region::piece(maps::MT_MOON_B1F, B1F_EXIT),
Region::whole(maps::CERULEAN_CITY),
],
},
],
},
Split {
map: maps::MT_MOON_B1F,
pieces: &[
Piece {
doors: &[(6, 23, 3), (7, 27, 3)],
next: &[
Region::piece(maps::MT_MOON_B2F, B2F_MAIN),
Region::piece(maps::ROUTE_4, EAST_SIDE),
],
},
Piece {
doors: &[(0, 5, 5), (4, 21, 17)],
next: &[
Region::whole(maps::MT_MOON_1F),
Region::piece(maps::MT_MOON_B2F, B2F_MAIN),
],
},
Piece {
doors: &[(1, 17, 11), (2, 25, 9)],
next: &[
Region::whole(maps::MT_MOON_1F),
Region::piece(maps::MT_MOON_B2F, B2F_NORTH),
],
},
Piece {
doors: &[(3, 25, 15), (5, 13, 27)],
next: &[
Region::whole(maps::MT_MOON_1F),
Region::piece(maps::MT_MOON_B2F, B2F_SOUTH),
],
},
],
},
Split {
map: maps::MT_MOON_B2F,
pieces: &[
Piece {
doors: &[(1, 21, 17), (3, 5, 7)],
next: &[
Region::piece(maps::MT_MOON_B1F, B1F_EXIT),
Region::piece(maps::MT_MOON_B1F, B1F_WEST),
],
},
Piece {
doors: &[(0, 25, 9)],
next: &[Region::piece(maps::MT_MOON_B1F, B1F_MIDDLE)],
},
Piece {
doors: &[(2, 15, 27)],
next: &[Region::piece(maps::MT_MOON_B1F, B1F_SOUTH)],
},
],
},
];
fn split_of(map: u8) -> Option<&'static Split> { fn split_of(map: u8) -> Option<&'static Split> {
SPLIT.iter().find(|split| split.map == map) SPLIT.iter().find(|split| split.map == map)
} }
/// The piece of `map` a tile on row `y` is in. /// The pieces of a split map with their numbers, which are their [`Region::part`]s.
/// fn pieces(split: &'static Split) -> impl Iterator<Item = (u8, &'static Piece)> {
/// For every map but [`SPLIT`]'s rows this is [`Region::whole`]. Callers pass the player's own split.pieces.iter().enumerate().filter_map(|(part, piece)| Some((u8::try_from(part).ok()?, piece)))
/// row, which is the only thing that can tell the two halves of `ROUTE_2` apart.
pub fn region_at(map: u8, y: u8) -> Region {
match split_of(map) {
None => Region::whole(map),
Some(split) => {
let north = y.abs_diff(split.north_door);
let south = y.abs_diff(split.south_door);
Region { map, part: if north <= south { NORTH_PIECE } else { SOUTH_PIECE } }
}
}
} }
/// The piece of `map` that `from` opens onto, or `None` when no piece of it touches `from`. /// The piece of `map` the tile `(x, y)` is in, by the doors alone.
/// ///
/// This is the reverse of [`region_at`] and it needs no tile: a door or an edge is listed under /// For every map but [`SPLIT`]'s rows this is [`Region::whole`]. On a split map it is the piece
/// exactly one piece, so "which half of Route 2 does the north gate open onto" is a table lookup. /// with the nearest door, counting tiles across and down, the first piece on a tie. That is exact
/// `None` is an edge the graph does not have -- the south half of Route 2 is not reachable from /// for every tile of Route 2's and Route 4's ground, which is where the grid cannot answer
/// Pewter City, whatever the map ids alone would suggest. /// ([`region_on`]: a ledge is a one-way step the grid does not model), and it is only the fallback
fn region_toward(map: u8, from: u8) -> Option<Region> { /// on Mt. Moon's floors, whose chambers wrap round each other.
match split_of(map) { pub fn region_at(map: u8, x: u8, y: u8) -> Region {
None => Some(Region::whole(map)), let Some(split) = split_of(map) else { return Region::whole(map) };
Some(split) => { let distance = |piece: &Piece| {
if split.north.contains(&from) { piece
Some(Region { map, part: NORTH_PIECE }) .doors
} else if split.south.contains(&from) { .iter()
Some(Region { map, part: SOUTH_PIECE }) .map(|(_, dx, dy)| u16::from(x.abs_diff(*dx)) + u16::from(y.abs_diff(*dy)))
} else { .min()
None .unwrap_or(u16::MAX)
} };
let part = pieces(split).min_by_key(|(part, piece)| (distance(piece), *part)).map_or(0, |(part, _)| part);
Region { map, part }
}
/// The piece of `map` the fly standing on `(x, y)` is in.
///
/// The ground decides: with the decoded map grid (section 15) the piece is the one whose doors a
/// walk from here can reach, when exactly one piece's can. The grid has no ledges -- a ledge is a
/// step one way only, and the grid reads it as a wall -- so on the part of Route 4 below the
/// ledges no door is reachable and [`region_at`] answers from the doors. Row 59 flooded every tile
/// of every piece's ground in the disassembly under this rule and it names the right piece for
/// all of them.
pub fn region_on(map: u8, x: u8, y: u8, grid: Option<&MapGrid>) -> Region {
let Some(split) = split_of(map) else { return Region::whole(map) };
if let Some(grid) = grid.filter(|grid| grid.map() == map) {
let walk = grid.reachable(x, y);
// A door tile the collision list refuses is still stepped onto from beside it.
let reached = |dx: u8, dy: u8| {
walk.contains(dx, dy)
|| [(0i16, 1i16), (0, -1), (1, 0), (-1, 0)].iter().any(|(ox, oy)| {
match (u8::try_from(i16::from(dx) + ox), u8::try_from(i16::from(dy) + oy)) {
(Ok(nx), Ok(ny)) => walk.contains(nx, ny),
_ => false,
}
})
};
let mut hit =
pieces(split).filter(|(_, piece)| piece.doors.iter().any(|(_, dx, dy)| reached(*dx, *dy)));
if let (Some((part, _)), None) = (hit.next(), hit.next()) {
return Region { map, part };
} }
} }
region_at(map, x, y)
}
/// The piece of `map` a warp lands in when it names `map`'s warp `index` as its destination.
///
/// `None` only for a split map whose table does not list that warp, which is not guessed at.
pub fn arrival_by_warp(map: u8, index: u8) -> Option<Region> {
let Some(split) = split_of(map) else { return Some(Region::whole(map)) };
pieces(split)
.find(|(_, piece)| piece.doors.iter().any(|(door, _, _)| *door == index))
.map(|(part, _)| Region { map, part })
}
/// The piece of `map` that stepping off an edge of `from` lands in.
///
/// An edge is listed under exactly one piece of a split map: Pewter's south edge opens onto Route
/// 2's north half, Route 3's north edge onto Route 4's west side and Cerulean's west edge onto its
/// east side. `None` is an edge the graph does not have.
pub fn arrival_by_edge(map: u8, from: u8) -> Option<Region> {
let Some(split) = split_of(map) else { return Some(Region::whole(map)) };
pieces(split)
.find(|(_, piece)| piece.next.iter().any(|next| next.map == from))
.map(|(part, _)| Region { map, part })
} }
/// Every piece of ground one step from `region`. /// Every piece of ground one step from `region`.
fn region_neighbours(region: Region) -> Vec<Region> { fn region_neighbours(region: Region) -> Vec<Region> {
let of = |map: u8| region_toward(map, region.map); if let Some(split) = split_of(region.map) {
match split_of(region.map) { return split.pieces.get(usize::from(region.part)).map_or_else(Vec::new, |piece| piece.next.to_vec());
None => neighbours(region.map).into_iter().filter_map(of).collect(), }
Some(split) => { // A whole map steps onto every piece of a split neighbour that lists it back: Mt. Moon's
let own = if region.part == NORTH_PIECE { split.north } else { split.south }; // first floor has a ladder into three of B1F's four chambers.
own.iter().copied().filter_map(of).collect() let mut out = Vec::new();
for map in neighbours(region.map) {
match split_of(map) {
None => out.push(Region::whole(map)),
Some(split) => out.extend(
pieces(split)
.filter(|(_, piece)| piece.next.contains(&region))
.map(|(part, _)| Region { map, part }),
),
} }
} }
out
} }
/// The map on the other side of `map`'s `edge`, or `None` where the table does not know. /// The map on the other side of `map`'s `edge`, or `None` where the table does not know.
@ -280,11 +475,20 @@ pub fn outdoor_of(interior: u8) -> Option<u8> {
neighbours(interior).into_iter().find(|map| super::cartridge::outdoors(*map)) neighbours(interior).into_iter().find(|map| super::cartridge::outdoors(*map))
} }
/// Every map one step from `map`, doors and edges together, deduplicated and in id order. /// Every map one step on foot from `map`, doors and edges together, deduplicated and in id order.
///
/// A connection with no crossing ([`NO_CROSSING`]) is not a step on foot and is left out.
pub fn neighbours(map: u8) -> Vec<u8> { pub fn neighbours(map: u8) -> Vec<u8> {
const EDGES: [Edge; 4] = [Edge::North, Edge::South, Edge::West, Edge::East];
let mut out: Vec<u8> = Vec::new(); let mut out: Vec<u8> = Vec::new();
if let Some(row) = CONNECTIONS.iter().find(|(id, _)| *id == map) { if let Some(row) = CONNECTIONS.iter().find(|(id, _)| *id == map) {
out.extend(row.1.iter().copied().filter(|id| *id != NONE)); out.extend(
row.1
.iter()
.zip(EDGES)
.filter(|(id, edge)| **id != NONE && crossable(map, *edge))
.map(|(id, _)| *id),
);
} }
for (a, b) in LINKS { for (a, b) in LINKS {
if *a == map { if *a == map {
@ -296,7 +500,7 @@ pub fn neighbours(map: u8) -> Vec<u8> {
} }
// Symmetric closure: a row that names a neighbour is a connection whichever side lists it. // Symmetric closure: a row that names a neighbour is a connection whichever side lists it.
for (id, row) in CONNECTIONS { for (id, row) in CONNECTIONS {
if row.contains(&map) { if row.iter().zip(EDGES).any(|(other, edge)| *other == map && crossable(*id, edge)) {
out.push(*id); out.push(*id);
} }
} }
@ -313,16 +517,23 @@ pub fn neighbours(map: u8) -> Vec<u8> {
/// an unreachable one, and for `from == to` -- there is no hop to take when the fly is already /// an unreachable one, and for `from == to` -- there is no hop to take when the fly is already
/// there, and `GO OBJECTIVE` has its own answer for that case. /// there, and `GO OBJECTIVE` has its own answer for that case.
pub fn next_hop(from: Region, to: u8) -> Option<u8> { pub fn next_hop(from: Region, to: u8) -> Option<u8> {
next_step(from, to).map(|hop| hop.map)
}
/// [`next_hop`] with the piece it lands in, which is what an exit has to match on a map with two
/// doors into one split map: three of Mt. Moon's first-floor ladders go down to B1F, and only
/// one of them reaches the way out ([`arrival_by_warp`] names where each one lands).
pub fn next_step(from: Region, to: u8) -> Option<Region> {
if from.map == to { if from.map == to {
return None; return None;
} }
let mut seen: HashSet<Region> = HashSet::from([from]); let mut seen: HashSet<Region> = HashSet::from([from]);
// piece -> the first hop out of `from` that reaches it // piece -> the first hop out of `from` that reaches it
let mut first: HashMap<Region, u8> = HashMap::new(); let mut first: HashMap<Region, Region> = HashMap::new();
let mut queue: VecDeque<Region> = VecDeque::new(); let mut queue: VecDeque<Region> = VecDeque::new();
for hop in region_neighbours(from) { for hop in region_neighbours(from) {
if seen.insert(hop) { if seen.insert(hop) {
first.insert(hop, hop.map); first.insert(hop, hop);
queue.push_back(hop); queue.push_back(hop);
} }
} }
@ -495,6 +706,62 @@ mod tests {
} }
} }
#[test]
fn the_rows_the_disassembly_corrected_say_what_its_headers_say() {
// Row 59, `data/maps/headers/*.asm` at the pinned commit. Route 4 is north of Route 3:
// Route 3's top edge is the road to Mt. Moon's Pokécenter, and Route 3 has no east exit.
assert_eq!(connected(maps::ROUTE_3, Edge::North), Some(maps::ROUTE_4));
assert_eq!(connected(maps::ROUTE_3, Edge::East), None);
assert_eq!(connected(maps::ROUTE_4, Edge::South), Some(maps::ROUTE_3));
assert_eq!(connected(maps::ROUTE_4, Edge::West), None);
assert_eq!(connected(maps::ROUTE_4, Edge::East), Some(maps::CERULEAN_CITY));
// Nugget Bridge's far end is Route 24's east edge, not its north one.
assert_eq!(connected(maps::ROUTE_24, Edge::East), Some(maps::ROUTE_25));
assert_eq!(connected(maps::ROUTE_24, Edge::North), None);
assert_eq!(connected(maps::ROUTE_25, Edge::West), Some(maps::ROUTE_24));
assert_eq!(connected(maps::ROUTE_25, Edge::South), None);
assert_eq!(connected(maps::ROUTE_14, Edge::West), Some(maps::ROUTE_15));
assert_eq!(connected(maps::ROUTE_15, Edge::East), Some(maps::ROUTE_14));
// Mt. Moon's two mouths are both on Route 4, and Route 3 has no warps: a cave door that
// is not there is a road the fly walks up and down for ever (row 59's Pewter ring).
assert!(!neighbours(maps::ROUTE_3).contains(&maps::MT_MOON_1F));
assert_eq!(outdoor_of(maps::MT_MOON_1F), Some(maps::ROUTE_4));
assert_eq!(outdoor_of(maps::MT_MOON_B1F), Some(maps::ROUTE_4));
assert_eq!(
neighbours(maps::ROUTE_3),
vec![maps::PEWTER_CITY, maps::ROUTE_4],
"Route 3 is a road between two maps and nothing else"
);
}
#[test]
fn a_connection_nobody_can_walk_across_is_named_and_is_not_a_road() {
// Row 59: Pallet Town's shore. The header connects it to Route 21, which is water.
assert_eq!(connected(maps::PALLET_TOWN, Edge::South), Some(maps::ROUTE_21));
assert!(!crossable(maps::PALLET_TOWN, Edge::South));
assert!(!neighbours(maps::PALLET_TOWN).contains(&maps::ROUTE_21));
assert!(!neighbours(maps::ROUTE_21).contains(&maps::PALLET_TOWN));
// So the road from Pallet Town to Cerulean is the long one on land: north, through the
// forest, Pewter and Mt. Moon, and not by sea through Cinnabar and Fuchsia.
assert_eq!(next_hop(Region::whole(maps::PALLET_TOWN), maps::CERULEAN_CITY), Some(maps::ROUTE_1));
assert_eq!(hops(Region::whole(maps::PALLET_TOWN), maps::CERULEAN_CITY), Some(16));
// Every entry is one of the header's own connections, and both sides are listed.
for (map, edge) in NO_CROSSING {
let other = connected(*map, *edge).expect("a no-crossing entry is a header connection");
let back = match edge {
Edge::North => Edge::South,
Edge::South => Edge::North,
Edge::West => Edge::East,
Edge::East => Edge::West,
};
assert!(!crossable(other, back), "{other:#04x} lists {map:#04x} as crossable");
}
// Indigo Plateau is on the graph and, until a row gives the League gate, not on foot
// from the south.
assert_eq!(connected(maps::ROUTE_22, Edge::North), Some(maps::ROUTE_23));
assert_eq!(next_hop(Region::whole(maps::VIRIDIAN_CITY), maps::INDIGO_PLATEAU), None);
}
#[test] #[test]
fn a_front_door_resolves_to_the_town_outside_it() { fn a_front_door_resolves_to_the_town_outside_it() {
assert_eq!(outdoor_of(maps::OAKS_LAB), Some(maps::PALLET_TOWN)); assert_eq!(outdoor_of(maps::OAKS_LAB), Some(maps::PALLET_TOWN));
@ -520,7 +787,7 @@ mod tests {
assert_eq!(next_hop(at(maps::OAKS_LAB), maps::VIRIDIAN_MART), Some(maps::PALLET_TOWN)); assert_eq!(next_hop(at(maps::OAKS_LAB), maps::VIRIDIAN_MART), Some(maps::PALLET_TOWN));
// Upstairs is two hops from the town, through the ground floor. // Upstairs is two hops from the town, through the ground floor.
assert_eq!(next_hop(at(maps::PALLET_TOWN), maps::REDS_HOUSE_2F), Some(maps::REDS_HOUSE_1F)); assert_eq!(next_hop(at(maps::PALLET_TOWN), maps::REDS_HOUSE_2F), Some(maps::REDS_HOUSE_1F));
// Through the cave, because Route 3 and Route 4 are the same two maps either way round. // Out along Route 3, whose only other end is the road up to Mt. Moon.
assert_eq!(next_hop(at(maps::PEWTER_CITY), maps::CERULEAN_GYM), Some(maps::ROUTE_3)); assert_eq!(next_hop(at(maps::PEWTER_CITY), maps::CERULEAN_GYM), Some(maps::ROUTE_3));
// Nowhere to go, and nowhere known. // Nowhere to go, and nowhere known.
assert_eq!(next_hop(at(maps::PALLET_TOWN), maps::PALLET_TOWN), None); assert_eq!(next_hop(at(maps::PALLET_TOWN), maps::PALLET_TOWN), None);
@ -528,37 +795,169 @@ mod tests {
} }
#[test] #[test]
fn a_split_maps_pieces_divide_its_neighbours_between_them() { fn a_split_maps_pieces_add_up_and_answer_each_other() {
// The invariant that keeps [`SPLIT`] honest: a piece's own list is a real subset of the // The invariants that keep [`SPLIT`] honest. A typo in any of them is a road that does
// map's neighbours, the two pieces together are all of them, and neither claims the same // not exist, or a door that leads nowhere.
// neighbour twice. A typo here is a road that does not exist.
for split in SPLIT { for split in SPLIT {
let mut both: Vec<u8> = // The pieces' neighbours together are exactly the map's.
split.north.iter().chain(split.south.iter()).copied().collect(); let mut maps_of: Vec<u8> =
both.sort_unstable(); split.pieces.iter().flat_map(|piece| piece.next.iter().map(|r| r.map)).collect();
let mut once = both.clone(); maps_of.sort_unstable();
once.dedup(); maps_of.dedup();
assert_eq!(both, once, "{:#04x} lists a neighbour under both pieces", split.map);
assert_eq!( assert_eq!(
both, maps_of,
neighbours(split.map), neighbours(split.map),
"{:#04x}'s pieces do not add up to its neighbours", "{:#04x}'s pieces do not add up to its neighbours",
split.map split.map
); );
assert_ne!(split.north_door, split.south_door); let mut doors: Vec<u8> =
split.pieces.iter().flat_map(|piece| piece.doors.iter().map(|d| d.0)).collect();
doors.sort_unstable();
let count = doors.len();
doors.dedup();
assert_eq!(doors.len(), count, "{:#04x} lists one warp under two pieces", split.map);
for (part, piece) in pieces(split) {
let here = Region { map: split.map, part };
assert!(!piece.doors.is_empty(), "{here:?} has no door to be told apart by");
// A door's own tile is in its own piece.
for (_, x, y) in piece.doors {
assert_eq!(region_at(split.map, *x, *y), here, "door ({x}, {y})");
}
// Every step is listed back from the other side, so a route is reversible.
for next in piece.next {
assert!(
region_neighbours(*next).contains(&here),
"{next:?} does not step back onto {here:?}"
);
if let Some(other) = split_of(next.map) {
assert!(usize::from(next.part) < other.pieces.len(), "{next:?}");
}
}
}
} }
} }
#[test]
fn the_road_from_pewter_to_cerulean_is_through_mt_moon_one_chamber_at_a_time() {
// Row 59. Every step of the road, as the pieces the fly stands in, measured from the
// disassembly: Route 3's top edge, Route 4's west side, the cave mouth at (18, 5), 1F's
// ladder at (5, 5), B1F's west chamber, its ladder at (21, 17), B2F, its ladder at (5, 7),
// B1F's exit chamber, (27, 3), Route 4's east side, Cerulean.
let road = [
Region::whole(maps::PEWTER_CITY),
Region::whole(maps::ROUTE_3),
Region::piece(maps::ROUTE_4, WEST_SIDE),
Region::whole(maps::MT_MOON_1F),
Region::piece(maps::MT_MOON_B1F, B1F_WEST),
Region::piece(maps::MT_MOON_B2F, B2F_MAIN),
Region::piece(maps::MT_MOON_B1F, B1F_EXIT),
Region::piece(maps::ROUTE_4, EAST_SIDE),
Region::whole(maps::CERULEAN_CITY),
];
for pair in road.windows(2) {
assert_eq!(next_step(pair[0], maps::CERULEAN_CITY), Some(pair[1]), "from {:?}", pair[0]);
}
assert_eq!(hops(road[0], maps::CERULEAN_CITY), Some(8));
// Mt. Moon's rung is the first floor, one hop from the cave mouth's side of Route 4 --
// which is where the Pewter ring said the fly could never get to.
assert_eq!(next_hop(Region::whole(maps::PEWTER_CITY), maps::MT_MOON_1F), Some(maps::ROUTE_3));
assert_eq!(next_hop(Region::whole(maps::ROUTE_3), maps::MT_MOON_1F), Some(maps::ROUTE_4));
assert_eq!(
next_hop(Region::piece(maps::ROUTE_4, WEST_SIDE), maps::MT_MOON_1F),
Some(maps::MT_MOON_1F)
);
// The dead ends lead back the way they came.
assert_eq!(
next_step(Region::piece(maps::MT_MOON_B1F, B1F_MIDDLE), maps::CERULEAN_CITY),
Some(Region::whole(maps::MT_MOON_1F))
);
assert_eq!(
next_step(Region::piece(maps::MT_MOON_B2F, B2F_SOUTH), maps::CERULEAN_CITY),
Some(Region::piece(maps::MT_MOON_B1F, B1F_SOUTH))
);
// From Cerulean's side of the mountain the way back to Pewter is the cave, not Route 4's
// south edge, which is on the other side.
assert_eq!(
next_step(Region::piece(maps::ROUTE_4, EAST_SIDE), maps::PEWTER_CITY),
Some(Region::piece(maps::MT_MOON_B1F, B1F_EXIT))
);
assert_eq!(
next_step(Region::whole(maps::CERULEAN_CITY), maps::ROUTE_24),
Some(Region::whole(maps::ROUTE_24))
);
assert_eq!(next_hop(Region::whole(maps::ROUTE_24), maps::ROUTE_25), Some(maps::ROUTE_25));
}
#[test]
fn a_door_or_an_edge_lands_in_the_piece_it_opens_onto() {
// Which warp of the destination a warp names is the cartridge's own answer (`wWarpEntries`
// byte 2, 0-based): 1F's ladders are B1F's warps 0, 2 and 3.
assert_eq!(arrival_by_warp(maps::MT_MOON_B1F, 0), Some(Region::piece(maps::MT_MOON_B1F, B1F_WEST)));
assert_eq!(arrival_by_warp(maps::MT_MOON_B1F, 2), Some(Region::piece(maps::MT_MOON_B1F, B1F_MIDDLE)));
assert_eq!(arrival_by_warp(maps::MT_MOON_B1F, 3), Some(Region::piece(maps::MT_MOON_B1F, B1F_SOUTH)));
// B1F's exit is `LAST_MAP` warp 2: Route 4's (24, 5), the far side of the mountain.
assert_eq!(arrival_by_warp(maps::ROUTE_4, 2), Some(Region::piece(maps::ROUTE_4, EAST_SIDE)));
// 1F's doormat is `LAST_MAP` warp 1: the cave mouth, the Pewter side.
assert_eq!(arrival_by_warp(maps::ROUTE_4, 1), Some(Region::piece(maps::ROUTE_4, WEST_SIDE)));
// The forest gates' doormats onto Route 2 are its warps 1 and 5.
assert_eq!(arrival_by_warp(maps::ROUTE_2, 1), Some(Region::piece(maps::ROUTE_2, NORTH_PIECE)));
assert_eq!(arrival_by_warp(maps::ROUTE_2, 5), Some(Region::piece(maps::ROUTE_2, SOUTH_PIECE)));
// A warp the table does not list is not guessed at; a whole map is always whole.
assert_eq!(arrival_by_warp(maps::ROUTE_2, 0), None);
assert_eq!(arrival_by_warp(maps::PEWTER_GYM, 0), Some(Region::whole(maps::PEWTER_GYM)));
// Edges.
assert_eq!(arrival_by_edge(maps::ROUTE_4, maps::ROUTE_3), Some(Region::piece(maps::ROUTE_4, WEST_SIDE)));
assert_eq!(
arrival_by_edge(maps::ROUTE_4, maps::CERULEAN_CITY),
Some(Region::piece(maps::ROUTE_4, EAST_SIDE))
);
assert_eq!(arrival_by_edge(maps::ROUTE_2, maps::PEWTER_CITY), Some(Region::piece(maps::ROUTE_2, NORTH_PIECE)));
assert_eq!(arrival_by_edge(maps::ROUTE_3, maps::ROUTE_4), Some(Region::whole(maps::ROUTE_3)));
}
#[test]
fn route_4s_sides_are_told_apart_by_the_doors_where_the_ground_cannot() {
// With no grid, the nearest door: the cave mouth's side reaches down to Route 3's road at
// (7..11, 17), and Cerulean's side is everything east of the mountain.
assert_eq!(region_at(maps::ROUTE_4, 9, 17), Region::piece(maps::ROUTE_4, WEST_SIDE));
assert_eq!(region_at(maps::ROUTE_4, 18, 6), Region::piece(maps::ROUTE_4, WEST_SIDE));
assert_eq!(region_at(maps::ROUTE_4, 24, 6), Region::piece(maps::ROUTE_4, EAST_SIDE));
assert_eq!(region_at(maps::ROUTE_4, 89, 10), Region::piece(maps::ROUTE_4, EAST_SIDE));
assert_eq!(region_at(maps::ROUTE_3, 60, 0), Region::whole(maps::ROUTE_3));
// B2F's chambers wrap round each other, so there the grid decides.
let mut grid = MapGrid::new(maps::MT_MOON_B2F, 40, 36);
// A corridor from B2F's (5, 7) ladder east along row 7 and down column 33 to (33, 31):
// nearer the (15, 27) ladder than either of its own, and on the main piece.
for x in 4..=33 {
grid.set(x, 7, 0, super::super::state::Walkable::Yes);
}
for y in 7..=31 {
grid.set(33, y, 0, super::super::state::Walkable::Yes);
}
assert_eq!(region_at(maps::MT_MOON_B2F, 33, 31), Region::piece(maps::MT_MOON_B2F, B2F_SOUTH));
assert_eq!(
region_on(maps::MT_MOON_B2F, 33, 31, Some(&grid)),
Region::piece(maps::MT_MOON_B2F, B2F_MAIN)
);
// A grid of another map says nothing, and neither does none.
let other = MapGrid::new(maps::ROUTE_4, 90, 18);
assert_eq!(
region_on(maps::MT_MOON_B2F, 33, 31, Some(&other)),
Region::piece(maps::MT_MOON_B2F, B2F_SOUTH)
);
assert_eq!(region_on(maps::MT_MOON_B2F, 33, 31, None), Region::piece(maps::MT_MOON_B2F, B2F_SOUTH));
}
#[test] #[test]
fn route_2s_halves_are_told_apart_by_the_row_the_fly_is_standing_on() { fn route_2s_halves_are_told_apart_by_the_row_the_fly_is_standing_on() {
// The two doorways are rows 11 and 43, measured from the cartridge's own warp table. // The two doorways are rows 11 and 43, measured from the cartridge's own warp table.
assert_eq!(region_at(maps::ROUTE_2, 11).part, NORTH_PIECE); assert_eq!(region_at(maps::ROUTE_2, 3, 11).part, NORTH_PIECE);
assert_eq!(region_at(maps::ROUTE_2, 43).part, SOUTH_PIECE); assert_eq!(region_at(maps::ROUTE_2, 3, 43).part, SOUTH_PIECE);
assert_eq!(region_at(maps::ROUTE_2, 0).part, NORTH_PIECE, "Pewter's end"); assert_eq!(region_at(maps::ROUTE_2, 8, 0).part, NORTH_PIECE, "Pewter's end");
assert_eq!(region_at(maps::ROUTE_2, 71).part, SOUTH_PIECE, "Viridian's end"); assert_eq!(region_at(maps::ROUTE_2, 8, 71).part, SOUTH_PIECE, "Viridian's end");
// Every other map is one piece, whatever row is asked about. // Every other map is one piece, whatever row is asked about.
assert_eq!(region_at(maps::ROUTE_1, 30), Region::whole(maps::ROUTE_1)); assert_eq!(region_at(maps::ROUTE_1, 10, 30), Region::whole(maps::ROUTE_1));
assert_eq!(region_at(maps::VIRIDIAN_FOREST_SOUTH_GATE, 7).part, 0); assert_eq!(region_at(maps::VIRIDIAN_FOREST_SOUTH_GATE, 4, 7).part, 0);
} }
#[test] #[test]
@ -578,16 +977,16 @@ mod tests {
Some(maps::ROUTE_2) Some(maps::ROUTE_2)
); );
// Route 2's north half steps off its own top edge; its south half walks to the gate. // Route 2's north half steps off its own top edge; its south half walks to the gate.
assert_eq!(next_hop(region_at(maps::ROUTE_2, 11), maps::PEWTER_CITY), Some(maps::PEWTER_CITY)); assert_eq!(next_hop(region_at(maps::ROUTE_2, 3, 11), maps::PEWTER_CITY), Some(maps::PEWTER_CITY));
assert_eq!( assert_eq!(
next_hop(region_at(maps::ROUTE_2, 43), maps::PEWTER_CITY), next_hop(region_at(maps::ROUTE_2, 3, 43), maps::PEWTER_CITY),
Some(maps::VIRIDIAN_FOREST_SOUTH_GATE) Some(maps::VIRIDIAN_FOREST_SOUTH_GATE)
); );
// From Viridian City the first hop is still Route 2, which is the road the fly takes north. // From Viridian City the first hop is still Route 2, which is the road the fly takes north.
assert_eq!(next_hop(Region::whole(maps::VIRIDIAN_CITY), maps::PEWTER_CITY), Some(maps::ROUTE_2)); assert_eq!(next_hop(Region::whole(maps::VIRIDIAN_CITY), maps::PEWTER_CITY), Some(maps::ROUTE_2));
// And the way back south from the north half is the forest, not Route 2's own bottom edge. // And the way back south from the north half is the forest, not Route 2's own bottom edge.
assert_eq!( assert_eq!(
next_hop(region_at(maps::ROUTE_2, 11), maps::VIRIDIAN_CITY), next_hop(region_at(maps::ROUTE_2, 3, 11), maps::VIRIDIAN_CITY),
Some(maps::VIRIDIAN_FOREST_NORTH_GATE) Some(maps::VIRIDIAN_FOREST_NORTH_GATE)
); );
} }
@ -645,11 +1044,11 @@ mod tests {
assert_eq!(here.map, maps::PEWTER_GYM); assert_eq!(here.map, maps::PEWTER_GYM);
assert_eq!(steps, 3); assert_eq!(steps, 3);
// A split map is measured from the piece the fly is standing in, exactly as `next_hop` is. // A split map is measured from the piece the fly is standing in, exactly as `next_hop` is.
assert_eq!(hops(region_at(maps::ROUTE_2, 11), maps::PEWTER_CITY), Some(1)); assert_eq!(hops(region_at(maps::ROUTE_2, 3, 11), maps::PEWTER_CITY), Some(1));
// Five from the south half, because the belt of trees between the halves needs CUT and // Five from the south half, because the belt of trees between the halves needs CUT and
// the road is the forest: the south gate, the forest, the north gate, Route 2's north // the road is the forest: the south gate, the forest, the north gate, Route 2's north
// half, Pewter. // half, Pewter.
assert_eq!(hops(region_at(maps::ROUTE_2, 43), maps::PEWTER_CITY), Some(5)); assert_eq!(hops(region_at(maps::ROUTE_2, 3, 43), maps::PEWTER_CITY), Some(5));
// And nothing is guessed. // And nothing is guessed.
assert_eq!(hops(at(maps::PALLET_TOWN), 0xf0), None); assert_eq!(hops(at(maps::PALLET_TOWN), 0xf0), None);
} }

View file

@ -1422,14 +1422,47 @@ fn exit_tiers(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
/// was in. Empty when there is no objective and when it is on this map. /// was in. Empty when there is no objective and when it is on this map.
pub fn toward_objective(state: &mut dyn MacroState, candidates: &[Exit]) -> Vec<Exit> { pub fn toward_objective(state: &mut dyn MacroState, candidates: &[Exit]) -> Vec<Exit> {
let Some(objective) = objective_place(state) else { return Vec::new() }; let Some(objective) = objective_place(state) else { return Vec::new() };
let Some(player) = state.player() else { return Vec::new() }; let Some(from) = region_here(state) else { return Vec::new() };
let here = player.map; let here = from.map;
if here == objective.map { if here == objective.map {
return Vec::new(); return Vec::new();
} }
let hop = geography::next_hop(geography::region_at(here, player.y), objective.map); match geography::next_step(from, objective.map) {
let aim = hop.unwrap_or(objective.map); Some(hop) => {
candidates.iter().copied().filter(|exit| exit.destination(here) == Some(aim)).collect() candidates.iter().copied().filter(|exit| leads_to(state, exit, here, hop)).collect()
}
None => candidates
.iter()
.copied()
.filter(|exit| exit.destination(here) == Some(objective.map))
.collect(),
}
}
/// The piece of ground the fly is standing in: its map, and on a map whose ground is in pieces
/// the piece its walk can reach the doors of (`docs/design/macros.md` sections 12.7 and 12.24).
pub fn region_here(state: &mut dyn MacroState) -> Option<geography::Region> {
let player = state.player()?;
let grid = state.map_grid();
Some(geography::region_on(player.map, player.x, player.y, grid.as_deref()))
}
/// Whether `exit` takes the fly onto `hop`: the map on the other side, and on a map whose ground
/// is in pieces, the piece it lands in. A warp names the destination's warp it arrives at, which
/// is what tells Mt. Moon's three ladders down to B1F apart (section 12.24); an edge lands in the
/// piece that lists the map it is stepped off. A landing the table cannot name is not a match.
fn leads_to(state: &mut dyn MacroState, exit: &Exit, here: u8, hop: geography::Region) -> bool {
if exit.destination(here) != Some(hop.map) {
return false;
}
let landing = match exit.id {
ExitId::Warp(index) => state
.warps()
.get(usize::from(index))
.and_then(|warp| geography::arrival_by_warp(hop.map, warp.destination_warp)),
ExitId::Edge(_) => geography::arrival_by_edge(hop.map, here),
};
landing == Some(hop)
} }
/// The people on this map still worth walking to, each with the key the ledgers name it by. /// The people on this map still worth walking to, each with the key the ledgers name it by.
@ -1687,9 +1720,18 @@ pub fn objective_goals(state: &mut dyn MacroState) -> Vec<Aim> {
if objective.target.is_some() { if objective.target.is_some() {
let ahead = Tile::new(player.x, player.y).step(player.facing); let ahead = Tile::new(player.x, player.y).step(player.facing);
let here_tile = Tile::new(player.x, player.y); let here_tile = Tile::new(player.x, player.y);
let mut ranked: Vec<(u32, Tile, TalkTarget)> = objective_targets(state) let targets = objective_targets(state);
// **Facing any of them is the arrival** (row 58). With one target this was already
// true -- the thing ahead is left out and nothing else is left -- but a gym has three
// people the ladder names, and standing in front of the leader left the Jr. Trainer
// to walk to: `GO OBJECTIVE` walked to him, then back to the leader, and `TALK` was
// the one press it never made room for. A fly facing a person the rung is waiting on
// has nothing left for a walk to do.
if targets.iter().any(|(tile, _)| Some(*tile) == ahead) {
return Vec::new();
}
let mut ranked: Vec<(u32, Tile, TalkTarget)> = targets
.into_iter() .into_iter()
.filter(|(tile, _)| Some(*tile) != ahead)
.map(|(tile, target)| (tile.distance(here_tile), tile, target)) .map(|(tile, target)| (tile.distance(here_tile), tile, target))
.collect(); .collect();
ranked.sort_unstable(); ranked.sort_unstable();
@ -1759,9 +1801,10 @@ pub fn goals_toward(state: &mut dyn MacroState, target: u8) -> Vec<Aim> {
}) })
.collect() .collect()
}; };
if let Some(hop) = geography::next_hop(geography::region_at(here, player.y), target) { let hop = region_here(state).and_then(|from| geography::next_step(from, target));
if let Some(hop) = hop {
let toward: Vec<Exit> = let toward: Vec<Exit> =
exits.iter().copied().filter(|exit| exit.destination(here) == Some(hop)).collect(); exits.iter().copied().filter(|exit| leads_to(state, exit, here, hop)).collect();
if !toward.is_empty() { if !toward.is_empty() {
return of(toward); return of(toward);
} }
@ -1795,7 +1838,18 @@ pub fn objective_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)>
return Vec::new(); return Vec::new();
} }
let targets = match kind { let targets = match kind {
PlaceKind::Person => path::person_targets(state), // Row 58: the room's people, drawn or not. From the Pewter Gym's doormat the only person
// on screen is the guide, and with him talked to this list was empty -- so `GO OUT` was a
// candidate and `GO OBJECTIVE` had nothing to aim at, while BROCK stood twelve tiles up the
// room, outside the window the cartridge draws. The whole map's grid is what the walk
// plans over (section 15), so a person off the screen is somewhere a walk can go.
PlaceKind::Person => {
let mut all = path::person_targets(state);
all.extend(path::offscreen_person_targets(state));
all
}
// Not objects: every item ball in the game is a toggleable object, so a ball the run has
// picked up and one out of sight read alike from outside the window.
PlaceKind::Object => path::interactable_targets(state), PlaceKind::Object => path::interactable_targets(state),
}; };
targets targets
@ -1873,7 +1927,8 @@ pub const fn move_index(kind: MacroKind) -> Option<u8> {
} }
} }
/// Whether `kind`'s move slot holds a move with PP: the four buttons' precondition (section 14). /// Whether `kind`'s move slot holds a move with PP that the battle engine will not answer with
/// nothing: the four buttons' precondition (section 14, row 60).
/// ///
/// Three things it is *not*, each of them a bug this palette has had: /// Three things it is *not*, each of them a bug this palette has had:
/// ///
@ -1901,23 +1956,41 @@ pub fn move_slot_bound(state: &mut dyn MacroState, kind: MacroKind) -> bool {
// all, so the button is bound there whatever the seam can make of the battler. That is the // all, so the button is bound there whatever the seam can make of the battler. That is the
// backstop `NEXT` used to be on this row (section 12.10): the own turn's main menu always has // backstop `NEXT` used to be on this row (section 12.10): the own turn's main menu always has
// a button that ends the turn, and it is never one that merely reopens a list. // a button that ends the turn, and it is never one that merely reopens a list.
if index == 0 && matches!(battle.menu, BattleMenu::Main { .. }) { let main = matches!(battle.menu, BattleMenu::Main { .. });
return true;
}
// And the same backstop over an **open move list** whose battler the seam cannot read // And the same backstop over an **open move list** whose battler the seam cannot read
// (section 12.11). That frame used to deal `BACK` alone -- the only button on it closed the // (section 12.11). That frame used to deal `BACK` alone -- the only button on it closed the
// list `MOVE 1` on the menu underneath had just opened, which is 12.10's pair with `MOVE 1` in // list `MOVE 1` on the menu underneath had just opened, which is 12.10's pair with `MOVE 1` in
// `NEXT`'s place. `MOVE 1`'s script over an open list confirms wherever the cursor stands, so // `NEXT`'s place. `MOVE 1`'s script over an open list confirms wherever the cursor stands, so
// it reads no move either, and confirming a move is what ends a turn. // it reads no move either, and confirming a move is what ends a turn.
let Some(own) = battle.own else { let Some(own) = battle.own else {
return index == 0 && matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. }); return index == 0
&& (main || matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. }));
}; };
let holds = |slot: usize| -> Option<&Move> { let holds = |slot: usize| -> Option<&Move> {
own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0) own.moves.get(slot).and_then(|entry| entry.as_ref()).filter(|entry| entry.id != 0)
}; };
let Some(entry) = holds(usize::from(index)) else { return false }; // **A move the cartridge will answer with nothing is not dealt beside one it will not**
// (row 60, section 12.23). Live on Route 1: Squirtle's TAIL WHIP against a Pidgey whose
// DEFENSE was already at -6 was `MOVE 2` 183 times, "Nothing happened!" every time, and no
// battle ended by the fly's hand. What the move does is the move table's and the effect
// routine's answer ([`MacroState::move_without_effect`]), read the same way for every move,
// and it is PP's rule over again: a spent move is not offered beside a usable one, and when
// nothing is usable what was dealt stays dealt -- taking the last moves away would leave a list
// whose only button is `BACK`, which is 12.11's pair.
let mut useful = [false; 4];
for (slot, flag) in useful.iter_mut().enumerate() {
if let Some(entry) = holds(slot).copied() {
*flag = entry.pp > 0 && !state.move_without_effect(entry.id);
}
}
let any_useful = useful.iter().any(|flag| *flag);
let entry = holds(usize::from(index)).copied();
if index == 0 && main {
return !any_useful || useful[0] || entry.is_none_or(|entry| entry.pp == 0);
}
let Some(entry) = entry else { return false };
if entry.pp > 0 { if entry.pp > 0 {
return true; return useful[usize::from(index)] || !any_useful;
} }
// Out of PP. Only `MOVE 1` stays, and only when nothing else has any either -- otherwise the // Out of PP. Only `MOVE 1` stays, and only when nothing else has any either -- otherwise the
// fly would be offered a spent move beside a usable one. // fly would be offered a spent move beside a usable one.

View file

@ -348,6 +348,28 @@ pub fn person_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> {
out out
} }
/// The people of this map the cartridge is not drawing only because they are off the screen,
/// keyed as [`person_targets`] keys the drawn ones (row 58).
///
/// Kept apart from [`person_targets`] on purpose: that list is what `GO NPC`, `TALK` and the
/// talked ledger's facing test read, and a sprite outside the window may also be a toggleable
/// object the cartridge has switched off ([`GameState::offscreen_npcs`]). The one reader is the
/// ladder's own target list, which has to know the leader is in the room before the fly can see
/// him.
///
/// [`GameState::offscreen_npcs`]: super::state::GameState::offscreen_npcs
pub fn offscreen_person_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> {
let mut out: Vec<(Tile, TalkTarget)> = state
.offscreen_npcs()
.iter()
.filter(|npc| npc.person())
.map(|npc| (Tile::new(npc.x, npc.y), TalkTarget::Sprite(npc.slot)))
.collect();
out.sort_unstable();
out.dedup();
out
}
/// What is on `tile`: the thing a press at it would talk to, or `None` for bare ground. /// What is on `tile`: the thing a press at it would talk to, or `None` for bare ground.
/// ///
/// People first, because a person standing on a sign's tile is what the press would reach. /// People first, because a person standing on a sign's tile is what the press would reach.
@ -408,7 +430,9 @@ pub fn exits(state: &mut dyn MacroState) -> Vec<Exit> {
Edge::East => connections.east, Edge::East => connections.east,
Edge::West => connections.west, Edge::West => connections.west,
}; };
if !connected { // A connection the headers name and no step on foot crosses -- Pallet Town's shore --
// is not a way out (`geography::NO_CROSSING`, row 59).
if !connected || !geography::crossable(player.map, edge) {
continue; continue;
} }
// A step off the edge of an outdoor map is the next area; off an interior one -- which // A step off the edge of an outdoor map is the next area; off an interior one -- which

View file

@ -536,18 +536,22 @@ impl MapGrid {
/// reachable ones is fenced in, and no amount of re-planning is going to help it /// reachable ones is fenced in, and no amount of re-planning is going to help it
/// (`docs/design/macros.md` section 15, `examples/scene_probe.rs`). /// (`docs/design/macros.md` section 15, `examples/scene_probe.rs`).
pub fn reachable_from(&self, x: u8, y: u8) -> usize { pub fn reachable_from(&self, x: u8, y: u8) -> usize {
if self.index(x, y).is_none() { self.reachable(x, y).iter().filter(|seen| **seen).count()
return 0; }
}
/// Whether `(tx, ty)` is among the tiles [`MapGrid::reachable_from`] counts from `(x, y)`.
///
/// The whole flood at once, row-major like the grid itself, so a caller asking about several
/// tiles pays for one walk. Off the map is never reachable.
pub fn reachable(&self, x: u8, y: u8) -> Reachable {
let mut seen = vec![false; self.tiles.len()]; let mut seen = vec![false; self.tiles.len()];
let Some(start) = self.index(x, y) else {
return Reachable { width: self.width, seen };
};
seen[start] = true;
let mut queue = std::collections::VecDeque::new(); let mut queue = std::collections::VecDeque::new();
if let Some(index) = self.index(x, y) {
seen[index] = true;
}
queue.push_back((x, y)); queue.push_back((x, y));
let mut count = 0;
while let Some((tx, ty)) = queue.pop_front() { while let Some((tx, ty)) = queue.pop_front() {
count += 1;
for facing in [Facing::Up, Facing::Down, Facing::Left, Facing::Right] { for facing in [Facing::Up, Facing::Down, Facing::Left, Facing::Right] {
if self.walled(tx, ty, facing) { if self.walled(tx, ty, facing) {
continue; continue;
@ -563,7 +567,30 @@ impl MapGrid {
queue.push_back((nx, ny)); queue.push_back((nx, ny));
} }
} }
count Reachable { width: self.width, seen }
}
}
/// The tiles a walk from one tile of a [`MapGrid`] could reach ([`MapGrid::reachable`]).
#[derive(Debug, Clone)]
pub struct Reachable {
width: u8,
seen: Vec<bool>,
}
impl Reachable {
/// Whether the walk reaches `(x, y)`.
pub fn contains(&self, x: u8, y: u8) -> bool {
x < self.width
&& self
.seen
.get(usize::from(y) * usize::from(self.width) + usize::from(x))
.copied()
.unwrap_or(false)
}
fn iter(&self) -> impl Iterator<Item = &bool> {
self.seen.iter()
} }
} }
@ -655,6 +682,15 @@ pub trait GameState {
/// the same sixteen slots. [`Npc::person`] is the test that separates the two. /// the same sixteen slots. [`Npc::person`] is the test that separates the two.
fn npcs(&mut self) -> Vec<Npc>; fn npcs(&mut self) -> Vec<Npc>;
/// Sprites of the current map the cartridge is not drawing only because they are off the
/// screen (row 58, `pokemon_red::state::offscreen_npcs`).
///
/// Defaulted to none, which narrows: a seam that cannot answer knows the drawn sprites and
/// nothing more, which is what every reader had before row 58.
fn offscreen_npcs(&mut self) -> Vec<Npc> {
Vec::new()
}
/// The current map's signs, i.e. its `bg_event` text tiles. /// The current map's signs, i.e. its `bg_event` text tiles.
/// ///
/// Empty on a map with none. Required rather than defaulted like the rest of this trait: an /// Empty on a map with none. Required rather than defaulted like the rest of this trait: an

View file

@ -18,7 +18,7 @@ use crate::adapter::PlaceKind;
use crate::emulator::buttons; use crate::emulator::buttons;
use super::cartridge::{ use super::cartridge::{
BLOCKED_MINUTES_DEFAULT, CHEAPEST_PURCHASE, Edge, ExitId, FACINGS, MacroState, Objective, BLOCKED_MINUTES_DEFAULT, CHEAPEST_PURCHASE, Edge, ExitId, FACINGS, LAST_MAP, MacroState, Objective,
TalkTarget, TargetKey, TargetLedger, Targets, Tile, battle_entry, button, item, price, TalkTarget, TargetKey, TargetLedger, Targets, Tile, battle_entry, button, item, price,
}; };
use super::geography::Amenity; use super::geography::Amenity;
@ -89,6 +89,8 @@ struct World {
warps: Vec<Warp>, warps: Vec<Warp>,
connections: Connections, connections: Connections,
npcs: Vec<Npc>, npcs: Vec<Npc>,
/// Sprites the cartridge is not drawing only because they are off the screen (row 58).
offscreen: Vec<Npc>,
signs: Vec<Sign>, signs: Vec<Sign>,
list: List, list: List,
@ -109,6 +111,8 @@ struct World {
money: u32, money: u32,
bag: Vec<(u8, u8)>, bag: Vec<(u8, u8)>,
stock: Vec<u8>, stock: Vec<u8>,
/// Move ids the battle engine would answer with "Nothing happened!" on this frame (row 60).
no_effect: BTreeSet<u8>,
/// Tiles the game lets the player talk *over*: a mart's or a centre's counter. /// Tiles the game lets the player talk *over*: a mart's or a centre's counter.
counters: BTreeSet<Tile>, counters: BTreeSet<Tile>,
/// Errands this run has discharged (`docs/design/macros.md` section 13). /// Errands this run has discharged (`docs/design/macros.md` section 13).
@ -223,6 +227,7 @@ impl World {
warps: Vec::new(), warps: Vec::new(),
connections: Connections::default(), connections: Connections::default(),
npcs: Vec::new(), npcs: Vec::new(),
offscreen: Vec::new(),
signs: Vec::new(), signs: Vec::new(),
list: List::None, list: List::None,
cursor: 0, cursor: 0,
@ -239,6 +244,7 @@ impl World {
pushes: BTreeSet::new(), pushes: BTreeSet::new(),
exhausted: BTreeSet::new(), exhausted: BTreeSet::new(),
stock: Vec::new(), stock: Vec::new(),
no_effect: BTreeSet::new(),
visited: BTreeSet::new(), visited: BTreeSet::new(),
stood: BTreeSet::new(), stood: BTreeSet::new(),
seen_maps: BTreeSet::new(), seen_maps: BTreeSet::new(),
@ -636,6 +642,10 @@ impl GameState for World {
self.npcs.clone() self.npcs.clone()
} }
fn offscreen_npcs(&mut self) -> Vec<Npc> {
self.offscreen.clone()
}
fn signs(&mut self) -> Vec<Sign> { fn signs(&mut self) -> Vec<Sign> {
self.signs.clone() self.signs.clone()
} }
@ -689,6 +699,10 @@ impl MacroState for World {
self.prompt && self.scene == Scene::Dialog self.prompt && self.scene == Scene::Dialog
} }
fn move_without_effect(&mut self, id: u8) -> bool {
self.no_effect.contains(&id)
}
fn shop_stock(&mut self) -> Vec<u8> { fn shop_stock(&mut self) -> Vec<u8> {
self.stock.clone() self.stock.clone()
} }
@ -811,6 +825,13 @@ fn drive(
// The loop's own bookkeeping, so a test sees what the next decision would see: whatever the // The loop's own bookkeeping, so a test sees what the next decision would see: whatever the
// finish earned goes into the session's ledgers, which is `PokemonPalette::record_talk`'s job // finish earned goes into the session's ledgers, which is `PokemonPalette::record_talk`'s job
// in the sim loop and this line's here. // in the sim loop and this line's here.
settle(machine, world);
Ok(machine.outcome().expect("a finished macro has an outcome").1)
}
/// `PokemonPalette::record_talk`, over the fixture: whatever the machine has earned goes into the
/// session's ledgers.
fn settle(machine: &mut MacroMachine, world: &mut World) {
while let Some((map, target)) = machine.take_blocked() { while let Some((map, target)) = machine.take_blocked() {
world.targets.record_blocked(map, target); world.targets.record_blocked(map, target);
} }
@ -823,11 +844,27 @@ fn drive(
if let Some((map, target)) = machine.take_reached() { if let Some((map, target)) = machine.take_reached() {
world.targets.record_reached(map, target); world.targets.record_reached(map, target);
} }
while let Some((map, tile)) = machine.take_pushed() {
assert_eq!(map, world.map);
world.pushes.insert(tile);
}
if let Some((map, target)) = machine.take_talked() { if let Some((map, target)) = machine.take_talked() {
assert_eq!(map, world.map); assert_eq!(map, world.map);
world.talked.insert(target); world.talked.insert(target);
} }
Ok(machine.outcome().expect("a finished macro has an outcome").1) }
/// The cartridge gives the joypad back in the overworld: the settle window of it, observed, and
/// whatever it decided taken into the ledgers (rows 58 and 59).
fn hand_back(machine: &mut MacroMachine, world: &mut World) {
world.scene = Scene::Overworld;
world.scripted = false;
world.scripted_at = None;
world.switch = None;
for _ in 0..super::executor::PUSH_SETTLE_FRAMES {
machine.observe_frame(world);
}
settle(machine, world);
} }
/// A palette of exactly one button, for a script whose macro no scene binds any more. /// A palette of exactly one button, for a script whose macro no scene binds any more.
@ -3510,7 +3547,11 @@ fn a_walk_the_cartridge_pushes_back_excludes_what_it_was_walking_to() {
let north = TargetKey::Exit(ExitId::Edge(Edge::North)); let north = TargetKey::Exit(ExitId::Edge(Edge::North));
assert!(on_the_pad(&mut world, MacroKind::GoRoute)); assert!(on_the_pad(&mut world, MacroKind::GoRoute));
assert_eq!(run(&mut world, MacroKind::GoRoute), Ok(MacroAbort::Done)); let mut machine = MacroMachine::new(0x1234_5678);
assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoRoute), Ok(MacroAbort::Done));
// The gate's text is still up: the cartridge has not given the joypad back (row 58).
assert!(!world.targets.blocked(world.map, north), "nothing decided inside the script");
hand_back(&mut machine, &mut world);
assert!( assert!(
world.targets.blocked(world.map, north), world.targets.blocked(world.map, north),
"the road the cartridge refused is excluded for the window" "the road the cartridge refused is excluded for the window"
@ -3907,6 +3948,95 @@ fn a_turn_with_nothing_to_attack_switch_or_flee_with_still_has_a_button() {
assert!(pad_of(&mut world).contains(&"MOVE 1")); assert!(pad_of(&mut world).contains(&"MOVE 1"));
} }
/// `constants/move_constants.asm`: the two moves a level-5 Squirtle knows.
const TACKLE: u8 = 0x21;
const TAIL_WHIP: u8 = 0x27;
#[test]
fn a_move_the_cartridge_answers_with_nothing_is_off_the_pad_beside_one_it_does_not() {
// Row 60, live on Route 1: Squirtle L5 with TACKLE and TAIL WHIP, a Pidgey whose DEFENSE is
// already at -6, and `MOVE 2` chosen 183 times to "Nothing happened!". Over the menu and over
// the open list, TAIL WHIP leaves the pad and TACKLE stays.
let mut world = World::battle();
world.mons = vec![mon(0, 8, 20, &[(TACKLE, 35), (TAIL_WHIP, 30)])];
world.active = Some(0);
world.list = List::BattleMain;
assert!(on_the_pad(&mut world, MacroKind::Move2), "before the stage is at its limit");
world.no_effect.insert(TAIL_WHIP);
for list in [List::BattleMain, List::Moves(2)] {
world.list = list;
world.grid = list == List::BattleMain;
let pad = pad_of(&mut world);
assert!(!pad.contains(&"MOVE 2"), "{list:?} deals {pad:?}");
assert!(pad.contains(&"MOVE 1"), "{list:?} deals {pad:?}");
}
// Nothing presses for the fly: the button is gone, and nothing is chosen in its place.
world.list = List::BattleMain;
world.grid = true;
assert!(!move_slot_bound(&mut world, MacroKind::Move2));
// Slot one is read the same way: FIGHT's backstop over the menu is not a way to deal a move
// that does nothing beside one that does.
let mut swapped = World::battle();
swapped.mons = vec![mon(0, 8, 20, &[(TAIL_WHIP, 30), (TACKLE, 35)])];
swapped.active = Some(0);
swapped.list = List::BattleMain;
swapped.no_effect.insert(TAIL_WHIP);
assert_eq!(
pad_of(&mut swapped).iter().filter(|name| name.starts_with("MOVE")).collect::<Vec<_>>(),
[&"MOVE 2"]
);
}
#[test]
fn with_no_move_that_does_anything_the_moves_stay_as_pp_deals_them() {
// PP's own rule (section 12.8, row 30a): with nothing usable, what ends the turn stays on the
// pad. Taking every move away over an open list would leave `BACK` alone, which closes what
// `MOVE 1` on the menu underneath opened -- 12.11's pair.
let mut world = World::battle();
world.mons = vec![mon(0, 8, 20, &[(TACKLE, 0), (TAIL_WHIP, 30)])];
world.active = Some(0);
world.no_effect.insert(TAIL_WHIP);
world.list = List::Moves(2);
world.grid = false;
world.cursor_max = 1;
let pad = pad_of(&mut world);
assert!(pad.contains(&"MOVE 2"), "the one move with PP still ends the turn: {pad:?}");
assert_ne!(pad, ["BACK"]);
world.list = List::BattleMain;
world.grid = true;
let pad = pad_of(&mut world);
assert!(pad.contains(&"MOVE 1") && pad.contains(&"MOVE 2"), "{pad:?}");
// And both moves without effect: nothing changes from what PP alone deals.
let mut both = World::battle();
both.mons = vec![mon(0, 8, 20, &[(TACKLE, 35), (TAIL_WHIP, 30)])];
both.active = Some(0);
both.list = List::BattleMain;
let before = pad_of(&mut both);
both.no_effect.extend([TACKLE, TAIL_WHIP]);
assert_eq!(pad_of(&mut both), before);
}
#[test]
fn a_spent_move_and_a_move_without_effect_leave_the_one_that_works() {
// The two readings together: slot one spent, slot two refused, slot three usable. Only
// `MOVE 3` is a move; `MOVE 1` over the menu is FIGHT's backstop only while slot one is the
// thing that can end the turn, and it is spent -- row 34's behaviour, unchanged.
let mut world = World::battle();
world.mons = vec![mon(0, 8, 20, &[(TACKLE, 0), (TAIL_WHIP, 30), (0x2d, 40)])];
world.active = Some(0);
world.no_effect.insert(TAIL_WHIP);
world.list = List::Moves(3);
world.grid = false;
world.cursor_max = 2;
let moves: Vec<&str> =
pad_of(&mut world).into_iter().filter(|name| name.starts_with("MOVE")).collect();
assert_eq!(moves, ["MOVE 3"]);
}
/// The bound buttons of the macros-mode pad for the scene the world is in, unbound slots dropped. /// The bound buttons of the macros-mode pad for the scene the world is in, unbound slots dropped.
fn pad_of(world: &mut World) -> Vec<&'static str> { fn pad_of(world: &mut World) -> Vec<&'static str> {
let scene = world.scene(); let scene = world.scene();
@ -4032,18 +4162,20 @@ fn go_shop_and_go_heal_are_on_the_pad_while_their_errand_stands() {
#[test] #[test]
fn an_edge_the_table_cannot_name_stops_being_somewhere_new_once_it_is_stood_on() { fn an_edge_the_table_cannot_name_stops_being_somewhere_new_once_it_is_stood_on() {
// The rung-11 reading of row 54 (`infra/docs/macros-traps.md`). The cartridge reports Route // The rung-11 reading of row 54 (`infra/docs/macros-traps.md`). The cartridge reported Route
// 3's connections as north and west; `geography`'s row carries west and east, so the north // 3's connections as north and west while `geography`'s row carried west and east, so the
// edge's destination is unnameable -- and an unnameable destination counted as *unvisited*, // north edge's destination was unnameable -- and an unnameable destination counted as
// which made those tiles first-tier for `GO ROUTE` on every hold for ever, with // *unvisited*, which made those tiles first-tier for `GO ROUTE` on every hold for ever. Row 59
// `GO OBJECTIVE` off the pad beside them because nothing on this map leads to the objective. // corrected the row itself, and since then every header connection has a row; the rule is
// pinned on `$0B`, the one outdoor id with no header of its own (`UNUSED_MAP_0B`), whose
// edges the table cannot name.
let mut world = World::room(); let mut world = World::room();
world.map = maps::ROUTE_3; world.map = 0x0b;
world.size = MapSize { width: 8, height: 8 }; world.size = MapSize { width: 8, height: 8 };
world.player = Tile::new(4, 4); world.player = Tile::new(4, 4);
world.connections = Connections { north: true, south: false, east: false, west: true }; world.connections = Connections { north: true, south: false, east: false, west: true };
// West is Pewter City, which the table does name and the run has stood on. // West the run has already stood on.
world.seen_maps.insert(maps::PEWTER_CITY); world.visited.insert(ExitId::Edge(Edge::West));
let north: Vec<ExitId> = ways(&mut world, Way::Route).iter().map(|exit| exit.id).collect(); let north: Vec<ExitId> = ways(&mut world, Way::Route).iter().map(|exit| exit.id).collect();
assert!( assert!(
@ -5023,10 +5155,13 @@ fn an_escorted_walk_walls_the_tile_it_reached_not_the_one_it_set_out_from() {
let mut machine = MacroMachine::new(1); let mut machine = MacroMachine::new(1);
let _ = run_with(&mut machine, &mut world, MacroKind::GoRoute); let _ = run_with(&mut machine, &mut world, MacroKind::GoRoute);
let (map, tile) = machine.take_pushed().expect("the script moved the fly: a push-back"); // Row 58: written when the cartridge gives the joypad back in the overworld.
assert_eq!(map, maps::PEWTER_CITY); let reached = world.player;
assert_ne!(tile, Tile::new(3, 6), "not the tile the walk set out from"); hand_back(&mut machine, &mut world);
assert_eq!(tile, world.player, "the tile the walk had reached when the script took over"); let pushed: Vec<Tile> = world.pushes.iter().copied().collect();
assert_eq!(pushed.len(), 1, "the script moved the fly: a push-back");
assert_ne!(pushed[0], Tile::new(3, 6), "not the tile the walk set out from");
assert_eq!(pushed[0], reached, "the tile the walk had reached when the script took over");
} }
/// The push-back writes the ledger, and it writes the *tile* rather than the target. /// The push-back writes the ledger, and it writes the *tile* rather than the target.
@ -5042,10 +5177,13 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() {
let mut machine = MacroMachine::new(1); let mut machine = MacroMachine::new(1);
let _ = run_with(&mut machine, &mut world, MacroKind::Talk); let _ = run_with(&mut machine, &mut world, MacroKind::Talk);
let pushed = machine.take_pushed(); assert!(world.pushes.is_empty(), "nothing is decided while the cartridge holds the joypad");
// Row 58: the ledger is written when the cartridge gives the joypad back in the overworld,
// which is what tells the gate's walk back from a trainer's walk up.
hand_back(&mut machine, &mut world);
assert_eq!( assert_eq!(
pushed, world.pushes.iter().copied().collect::<Vec<_>>(),
Some((world.map, Tile::new(3, 3))), vec![Tile::new(3, 3)],
"the tile the macro was standing on, not the person it was facing" "the tile the macro was standing on, not the person it was facing"
); );
} }
@ -5198,3 +5336,154 @@ fn a_completed_heal_writes_the_nurse_into_the_talked_ledger() {
assert_eq!(center.player, Tile::new(3, 3)); assert_eq!(center.player, Tile::new(3, 3));
assert!(!precondition(MacroKind::Talk, &mut center)); assert!(!precondition(MacroKind::Talk, &mut center));
} }
// ---------------------------------------------------------------------------------------------
// Row 58: the gym door, in and out
// ---------------------------------------------------------------------------------------------
/// The Pewter Gym as the fly finds it on its doormat: the guide on screen and talked to, the
/// leader and the Jr. Trainer up the room and not drawn.
fn pewter_gym_doormat() -> World {
let mut world = World::room().at(4, 13);
world.map = maps::PEWTER_GYM;
world.size = MapSize { width: 10, height: 14 };
world.facing = Facing::Up;
world.warps = vec![
Warp { x: 4, y: 13, destination_warp: 3, destination_map: LAST_MAP },
Warp { x: 5, y: 13, destination_warp: 3, destination_map: LAST_MAP },
];
world.npcs = vec![Npc { slot: 3, picture: 1, x: 7, y: 10, facing: Facing::Down }];
world.offscreen = vec![
Npc { slot: 1, picture: 2, x: 4, y: 1, facing: Facing::Down },
Npc { slot: 2, picture: 3, x: 3, y: 6, facing: Facing::Right },
];
world.talked.insert(TalkTarget::Sprite(3));
// Pewter's errands are paid, as they were live: the objective is the rung's own place.
world.areas.insert((Amenity::Mart, maps::PEWTER_CITY));
world.areas.insert((Amenity::Center, maps::PEWTER_CITY));
world.objective = Some(Objective {
map: world.map,
tile: None,
warp: None,
edge: None,
target: Some(PlaceKind::Person),
});
world
}
#[test]
fn the_rungs_people_are_in_the_room_when_the_screen_does_not_show_them() {
// Row 58, live for twenty-five minutes: `GO OBJECTIVE` into the Pewter Gym, `GO OUT` straight
// back out, ~200 macro starts per ten brain minutes and no reward at all. From the doormat the
// cartridge draws only the guide, who had been talked to, so the rung's own list was empty:
// `GO OBJECTIVE` had nothing to aim at and `GO OUT` -- whose candidates 12.5 withholds only
// while the rung's person is in the room -- was the pad. BROCK was twelve rows up.
let mut world = pewter_gym_doormat();
let targets = super::palette::objective_targets(&mut world);
assert!(
targets.contains(&(Tile::new(4, 1), TalkTarget::Sprite(1))),
"the leader is one of the rung's people: {targets:?}"
);
assert!(on_the_pad(&mut world, MacroKind::GoObjective), "there is someone to walk to");
assert!(!on_the_pad(&mut world, MacroKind::GoOut), "and the room is not left while he is in it");
// What the base saw, for the record: the drawn sprites alone leave nothing.
world.offscreen.clear();
assert!(super::palette::objective_targets(&mut world).is_empty());
assert!(!on_the_pad(&mut world, MacroKind::GoObjective));
assert!(on_the_pad(&mut world, MacroKind::GoOut), "the undo pair's inside half");
}
#[test]
fn only_the_rung_reads_people_off_the_screen() {
// A sprite outside the window may be a toggleable object the cartridge has switched off, and
// the two read alike from here (`state::offscreen_npcs`). The rung's list is the one reader:
// `GO NPC`, `TALK` and the objects are what they were.
let mut world = pewter_gym_doormat();
world.objective = None;
assert!(super::palette::untalked_people(&mut world).is_empty(), "`GO NPC` sees what is drawn");
world.objective = Some(Objective {
map: world.map,
tile: None,
warp: None,
edge: None,
target: Some(PlaceKind::Object),
});
assert!(
super::palette::objective_targets(&mut world).is_empty(),
"an item ball out of sight and one picked up read alike, so objects are not guessed at"
);
}
#[test]
fn facing_one_of_the_rungs_people_is_the_arrival() {
// A gym names three people and 12.5's "leave out the one ahead" was written for one: in front
// of the leader, `GO OBJECTIVE` still had the Jr. Trainer to walk to, and at the trainer it had
// the leader. The walk is done when any of them is ahead, and `TALK` is the press.
let mut world = pewter_gym_doormat().at(4, 2);
world.facing = Facing::Up;
world.npcs = vec![Npc { slot: 1, picture: 2, x: 4, y: 1, facing: Facing::Down }];
world.offscreen = vec![Npc { slot: 2, picture: 3, x: 3, y: 6, facing: Facing::Right }];
assert!(on_the_pad(&mut world, MacroKind::Talk));
assert!(!on_the_pad(&mut world, MacroKind::GoObjective), "no walk left while facing him");
world.facing = Facing::Left;
assert!(on_the_pad(&mut world, MacroKind::GoObjective), "turned away, the walk is back");
}
#[test]
fn a_challenge_closing_onto_a_few_frames_of_overworld_is_still_a_challenge() {
// Row 59, Route 3's first trainer. The challenge text closes, and for five frames the screen
// is an ordinary overworld with nothing set -- no text, no script, no joypad bit, the battle
// not yet decided -- before `StartTrainerBattle` runs. Decided on the first of them, the walk
// the trainer interrupted walled (11, 6) for the session: the one gap east on Route 3.
let mut world = World::room().at(3, 3);
world.map = 0x00;
world.connections = Connections { north: true, south: false, east: false, west: false };
world.switch = Some((4, Scene::Dialog));
world.scripted_at = Some(4);
let north = TargetKey::Exit(ExitId::Edge(Edge::North));
let mut machine = MacroMachine::new(0x1234_5678);
assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoRoute), Ok(MacroAbort::Done));
// The text closes onto five frames of overworld, and then the battle is decided.
world.scene = Scene::Overworld;
world.scripted = false;
world.scripted_at = None;
world.switch = None;
for _ in 0..5 {
machine.observe_frame(&mut world);
}
world.scene = Scene::Battle { own_turn: false, forced_switch: false };
machine.observe_frame(&mut world);
settle(&mut machine, &mut world);
hand_back(&mut machine, &mut world);
assert!(!world.targets.blocked(world.map, north), "a challenge is not the road refusing");
assert!(world.pushes.is_empty(), "and the gap it was walking through is still ground");
}
#[test]
fn a_trainer_walking_up_teaches_the_ledgers_nothing() {
// The other half of the gym. A walk toward the leader crossed the Jr. Trainer's line of sight;
// the trainer's "!" and walk up took the joypad, which 12.4 reads as the cartridge refusing
// the step, so BROCK went into the blocked ledger for ten brain minutes and the tile into the
// pushed one for the session. What the cartridge does when it gives the joypad back is what
// tells a refusal from a challenge.
let mut world = World::room().at(3, 3);
world.map = 0x00;
world.connections = Connections { north: true, south: false, east: false, west: false };
world.switch = Some((4, Scene::Dialog));
world.scripted_at = Some(4);
let north = TargetKey::Exit(ExitId::Edge(Edge::North));
let mut machine = MacroMachine::new(0x1234_5678);
assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoRoute), Ok(MacroAbort::Done));
// The challenge closes into a battle.
world.scene = Scene::Battle { own_turn: false, forced_switch: false };
machine.observe_frame(&mut world);
settle(&mut machine, &mut world);
// And the battle ends back in the overworld: nothing was refused.
hand_back(&mut machine, &mut world);
assert!(!world.targets.blocked(world.map, north), "a challenge is not the road refusing");
assert!(world.pushes.is_empty(), "and the ground is as walkable as it was");
}

View file

@ -1,4 +1,4 @@
//! The Pokémon Red reward adapter, `pokered-unique8-v6`. //! The Pokémon Red reward adapter, `pokered-unique8-v7`.
//! //!
//! A port of the prototype's `src/reward/pokemon-red.ts`. The gates and budgets //! A port of the prototype's `src/reward/pokemon-red.ts`. The gates and budgets
//! are unchanged; `docs/rewards-learning.md` holds the live rule table and the //! are unchanged; `docs/rewards-learning.md` holds the live rule table and the
@ -6,9 +6,12 @@
//! ladder with the 38 rungs of `docs/design/ladder.md`; v5 adds one reward rule, //! ladder with the 38 rungs of `docs/design/ladder.md`; v5 adds one reward rule,
//! `boundary` (`docs/design/room-escape.md` section 2), which pays the first step //! `boundary` (`docs/design/room-escape.md` section 2), which pays the first step
//! next to and the first step onto each of a map's exits; v6 adds `catch`, the //! next to and the first step onto each of a map's exits; v6 adds `catch`, the
//! operator's decision of 2026-09-22, which pays for keeping a wild Pokémon. //! operator's decision of 2026-09-22, which pays for keeping a wild Pokémon; v7 adds
//! `talk` and `item` and stops `boundary` paying indoors, the operator's decision of
//! 2026-09-23 to pay for engaging with a building rather than for leaving it.
pub mod catalog; pub mod catalog;
pub mod engage;
#[cfg(test)] #[cfg(test)]
pub(crate) mod fake_wram; pub(crate) mod fake_wram;
pub mod macros; pub mod macros;
@ -33,33 +36,39 @@ use symbols::ram;
/// Adapter version, pinned into the checkpoint compatibility string. /// Adapter version, pinned into the checkpoint compatibility string.
/// ///
/// `v6` is the `catch` rule. Bumping it is what makes a `v5` checkpoint a decision /// `v7` is the engagement rules: `talk` and `item` pay, and `boundary` stops paying on an
/// rather than an accident: the compatibility string is compared whole before a /// indoor map (the operator, 2026-09-23). Bumping it is what makes a `v6` checkpoint a
/// restore is attempted, so a `v5` run is refused by default and resumed only when /// decision rather than an accident: the compatibility string is compared whole before a
/// the operator names it in `FLY_ACCEPT_ADAPTERS` /// restore is attempted, so a `v6` run is refused by default and resumed only when the
/// ([`crate::compatibility::RestoreDecision`], `docs/design/flysim.md`). That /// operator names it in `FLY_ACCEPT_ADAPTERS` ([`crate::compatibility::RestoreDecision`],
/// migration is safe in one direction only, and only for this pair: `v5`'s ledger is /// `docs/design/flysim.md`). That migration is safe in one direction only, and only for this
/// a `v6` ledger with the catch counter absent, and an absent counter reads as zero. /// pair: `v6`'s ledger is a `v7` ledger holding no `talk:`, `item:` or `hidden:` keys, and the
/// first sample after the restore seeds the item keys from the cartridge's own bits, so
/// nothing already picked up pays ([`engage::ItemFlags::seed`]).
/// ///
/// (`v5` was the `boundary` rule, and rejected `v4` because a ledger that had never /// (`v6` was the `catch` rule and migrated `v5` the same way: an absent counter reads as
/// zero. `v5` was the `boundary` rule, and rejected `v4` because a ledger that had never
/// recorded a `boundary:` key could not be resumed as though its exits were already /// recorded a `boundary:` key could not be resumed as though its exits were already
/// collected. `v4` was the 38-rung ladder, and rejected `v3` because a stored rank /// collected. `v4` was the 38-rung ladder, and rejected `v3` because a stored rank
/// that meant "4 badges" on the old ladder is not a rung on the new one. Neither of /// that meant "4 badges" on the old ladder is not a rung on the new one. Neither of
/// those is a migration: this one is, because nothing a `v5` ledger holds means /// those is a migration; the last two are, because nothing an older ledger holds means
/// something different under `v6`.) /// something different under the newer rules.)
pub const REWARD_ADAPTER: &str = "pokered-unique8-v6"; pub const REWARD_ADAPTER: &str = "pokered-unique8-v7";
/// Adapter ids whose checkpoints `v6` can read. /// Adapter ids whose checkpoints `v7` can read.
/// ///
/// Exactly one, and it is one because the `catch` rule adds a counter and changes nothing else: /// Exactly one, and it is one because the engagement rules add ledger keys and change nothing
/// a `v5` ledger restores as a `v6` ledger with `catchCounts` empty, and every other byte of the /// else a `v6` state holds: every field keeps its name, shape and meaning, the `talk:` keys start
/// state means what it meant. `v4` is not here -- its `seen` ledger holds no `boundary:` keys, so /// empty (no conversation was ever paid), and the `item:`/`hidden:` keys are seeded from the
/// resuming it would pay a second time for every exit the run had already found -- and neither is /// game's own flags on the first sample, so no pickup made under `v6` pays when a rollback
/// `v3`, whose stored rank is a rung on a different ladder. /// un-takes it. The `boundary:` keys a `v6` run earned indoors stay in the ledger and mean what
/// they meant -- `exit_visited` still reads them. `v5` is not here: the live run is `v6`, and
/// the one migration the operator asked for is the one this adapter tests. `v4` and `v3` stay
/// refused for the reasons [`REWARD_ADAPTER`] gives.
/// ///
/// Listing an id here is necessary but not sufficient: `FLY_ACCEPT_ADAPTERS` must name it too /// Listing an id here is necessary but not sufficient: `FLY_ACCEPT_ADAPTERS` must name it too
/// (`crate::compatibility::decide`, `docs/design/flysim.md`). /// (`crate::compatibility::decide`, `docs/design/flysim.md`).
pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v5"]; pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v6"];
/// The only cartridge semantic rewards are enabled for. Even the canonical /// The only cartridge semantic rewards are enabled for. Even the canonical
/// pret build stays disabled until reviewed; see `docs/rewards-learning.md`. /// pret build stays disabled until reviewed; see `docs/rewards-learning.md`.
@ -89,8 +98,19 @@ pub const SUPPORTED_ROM: &str =
/// empty, which is the truth about a run that was never paid for a catch. That is the /// empty, which is the truth about a run that was never paid for a catch. That is the
/// whole of the documented `v5` -> `v6` migration; see /// whole of the documented `v5` -> `v6` migration; see
/// [`crate::compatibility::RestoreDecision`]. /// [`crate::compatibility::RestoreDecision`].
///
/// *Not* bumped for the engagement rules either. `talk` and `item` key their payouts into the
/// existing `seen` array, the way `boundary` did, and the item seed is marked there too
/// ([`ITEMS_SEEDED`]); a `v6` state is structurally a `v7` state with none of those keys.
pub const STATE_VERSION: u64 = 4; pub const STATE_VERSION: u64 = 4;
/// The `seen` key that says the item keys have been seeded from the cartridge's flags.
///
/// Absent from every `v6` state and from a fresh adapter; the first playable sample that finds
/// it absent writes one `item:`/`hidden:` key per item the game already shows as taken, pays
/// nothing for any of them, and writes this.
const ITEMS_SEEDED: &str = "items:seeded";
/// Catch payouts one species may earn in the lifetime of a run's ledger. /// Catch payouts one species may earn in the lifetime of a run's ledger.
/// ///
/// The same cap and the same reason as the wild-KO rule's three: a species the fly can /// The same cap and the same reason as the wild-KO rule's three: a species the fly can
@ -441,6 +461,13 @@ pub struct PokemonRedReward {
battle: Option<Battle>, battle: Option<Battle>,
mode: String, mode: String,
/// The `talk` rule's frame-to-frame watch. Transient: never checkpointed, cleared by a
/// rollback and by a restore.
talk: engage::TalkWatch,
/// The item bitsets as of the last playable sample, so a pickup is a bit that *rose*.
/// Transient for the same reason.
item_flags: Option<engage::ItemFlags>,
/// Transient, recomputed every sample and never checkpointed. /// Transient, recomputed every sample and never checkpointed.
safe: bool, safe: bool,
progress: u32, progress: u32,
@ -491,6 +518,8 @@ impl PokemonRedReward {
stable: 0, stable: 0,
battle: None, battle: None,
mode: "BOOT".to_string(), mode: "BOOT".to_string(),
talk: engage::TalkWatch::default(),
item_flags: None,
safe: false, safe: false,
progress: 0, progress: 0,
badges: 0, badges: 0,
@ -635,6 +664,10 @@ impl PokemonRedReward {
self.stable = 0; self.stable = 0;
self.battle = None; self.battle = None;
self.safe = false; self.safe = false;
// A conversation or a pickup in flight across a rollback is not paid: the game the
// fly returns to has not had it. What *was* paid stays in `seen` and blocks a replay.
self.talk.clear();
self.item_flags = None;
let keys: Vec<String> = self.wild_wins.keys().cloned().collect(); let keys: Vec<String> = self.wild_wins.keys().cloned().collect();
for key in keys { for key in keys {
self.replay_blocked.insert(&key); self.replay_blocked.insert(&key);
@ -750,11 +783,13 @@ impl PokemonRedReward {
self.boundary(&mut emitted, memory, map, x, y, width, height, true, brain_ms); self.boundary(&mut emitted, memory, map, x, y, width, height, true, brain_ms);
self.initialized = true; self.initialized = true;
} }
self.items(&mut emitted, memory, brain_ms);
let in_battle = memory.read8(ram::wIsInBattle); let in_battle = memory.read8(ram::wIsInBattle);
if in_battle == 1 || in_battle == 2 || in_battle == 255 { if in_battle == 1 || in_battle == 2 || in_battle == 255 {
self.mode = "BATTLE".to_string(); self.mode = "BATTLE".to_string();
self.stable = 0; self.stable = 0;
self.talk.interrupt();
let species_paid = self.counts.get(kind::SPECIES); let species_paid = self.counts.get(kind::SPECIES);
if self.battle.is_none() && in_battle != 255 { if self.battle.is_none() && in_battle != 255 {
self.battle = Some(Battle { self.battle = Some(Battle {
@ -849,6 +884,18 @@ impl PokemonRedReward {
self.catch_counts.insert(key, (paid + 1).min(MAX_CATCH_PAYOUTS)); self.catch_counts.insert(key, (paid + 1).min(MAX_CATCH_PAYOUTS));
} }
} }
// The talk rule (`docs/rewards-learning.md`, the operator 2026-09-23): a conversation
// the fly opened indoors, paid once per (map, object) when its box closes.
if let Some(conversation) = self.talk.observe(memory, map, x, y) {
self.once(
&mut emitted,
&conversation.key(),
kind::TALK,
conversation.label(),
false,
brain_ms,
);
}
let location = format!("{map}:{x}:{y}"); let location = format!("{map}:{x}:{y}");
self.stable = if self.location == location { self.stable + 1 } else { 1 }; self.stable = if self.location == location { self.stable + 1 } else { 1 };
self.location = location.clone(); self.location = location.clone();
@ -1056,6 +1103,11 @@ impl PokemonRedReward {
/// What this is not: a path. No button is chosen here, nothing is planned, and no map /// What this is not: a path. No button is chosen here, nothing is planned, and no map
/// knowledge reaches the readout. It is a reward the fly may or may not find, like every other /// knowledge reaches the readout. It is a reward the fly may or may not find, like every other
/// rule in the catalog. /// rule in the catalog.
///
/// **Indoors it pays nothing** (the operator, 2026-09-23): on a map [`engage::indoor`] calls a
/// building, every key is still written to the ledger -- so [`GameAdapter::exit_visited`]
/// answers exactly what it did, and a door found indoors is found -- but no payout is emitted.
/// The exits of a town, a route, a forest or a cave pay as they always have.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn boundary( fn boundary(
&mut self, &mut self,
@ -1072,6 +1124,7 @@ impl PokemonRedReward {
// Collected first, paid second: the ledger writes need `&mut self` and the table walk // Collected first, paid second: the ledger writes need `&mut self` and the table walk
// needs the sample cache, and the order of the collection is the order of the payouts. // needs the sample cache, and the order of the collection is the order of the payouts.
let mut hits: Vec<(String, bool)> = Vec::new(); let mut hits: Vec<(String, bool)> = Vec::new();
let pays = !engage::indoor(memory.read8(ram::wCurMapTileset));
let warps = memory.read8(ram::wNumberOfWarps).min(MAX_WARP_EVENTS); let warps = memory.read8(ram::wNumberOfWarps).min(MAX_WARP_EVENTS);
for index in 0..u16::from(warps) { for index in 0..u16::from(warps) {
@ -1112,6 +1165,10 @@ impl PokemonRedReward {
continue; continue;
} }
let key = format!("{key}:{}", if on_exit { "on" } else { "near" }); let key = format!("{key}:{}", if on_exit { "on" } else { "near" });
if !pays {
self.seen.insert(&key);
continue;
}
self.once_scaled( self.once_scaled(
emitted, emitted,
&key, &key,
@ -1124,6 +1181,34 @@ impl PokemonRedReward {
} }
} }
/// Pay each item picked up since the last playable sample, once per item for the lifetime of
/// the ledger (`docs/rewards-learning.md`, the operator 2026-09-23).
///
/// The first sample that finds [`ITEMS_SEEDED`] absent -- a fresh adapter, or a `v6` state
/// restored under `v7` -- keys every item the cartridge already shows as taken and pays for
/// none of them. After that a pickup is a bit that rose between two playable samples
/// ([`engage::pickups`]) and pays unless its key is already in `seen`, which is what stops a
/// rollback that un-takes an item from paying for it twice.
fn items(
&mut self,
emitted: &mut Vec<RewardEvent>,
memory: &mut impl MemoryReader,
brain_ms: f64,
) {
let now = engage::ItemFlags::read(memory);
if !self.seen.contains(ITEMS_SEEDED) {
for key in now.seed() {
self.seen.insert(&key);
}
self.seen.insert(ITEMS_SEEDED);
} else if let Some(before) = &self.item_flags {
for pickup in engage::pickups(memory, before, &now) {
self.once(emitted, &pickup.key, kind::ITEM, pickup.label, false, brain_ms);
}
}
self.item_flags = Some(now);
}
pub fn export_state(&self) -> Value { pub fn export_state(&self) -> Value {
json!({ json!({
"version": STATE_VERSION, "version": STATE_VERSION,
@ -1287,6 +1372,8 @@ impl PokemonRedReward {
self.mode = mode.to_string(); self.mode = mode.to_string();
self.progress = progress; self.progress = progress;
self.badges = badges; self.badges = badges;
self.talk.clear();
self.item_flags = None;
Ok(()) Ok(())
} }
} }

View file

@ -105,7 +105,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String {
format!( format!(
"started={} map={:?} party={} battle={} type={} font={:#04x} textbox={:#04x} \ "started={} map={:?} party={} battle={} type={} font={:#04x} textbox={:#04x} \
list={:#04x} cursor=({},{},{},{},{:#04x}) prompt={} joy={} sim={} flags5={:#04x} \ list={:#04x} cursor=({},{},{},{},{:#04x}) prompt={} joy={} sim={} flags5={:#04x} \
flags6={:#04x} move={:#04x} \ flags6={:#04x} move={:#04x} opp={:#04x} \
corners=({:#04x},{:#04x},{:#04x},{:#04x})", corners=({:#04x},{:#04x},{:#04x},{:#04x})",
state::started(memory), state::started(memory),
state::map_size(memory).map(|size| (size.width, size.height)), state::map_size(memory).map(|size| (size.width, size.height)),
@ -126,6 +126,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String {
memory.read8(ram::wStatusFlags5), memory.read8(ram::wStatusFlags5),
memory.read8(ram::wStatusFlags6), memory.read8(ram::wStatusFlags6),
memory.read8(ram::wMovementFlags), memory.read8(ram::wMovementFlags),
memory.read8(state::poke::CUR_OPPONENT),
box_corners[0], box_corners[0],
box_corners[1], box_corners[1],
box_corners[2], box_corners[2],

View file

@ -410,3 +410,18 @@ fn the_start_menus_box_is_read_the_same_way() {
.cursor(2, 11, 0, 7, poke::pad::DOWN | poke::pad::UP | poke::pad::START); .cursor(2, 11, 0, 7, poke::pad::DOWN | poke::pad::UP | poke::pad::START);
assert_eq!(detect(&mut corners), Scene::Unknown, "four corners are not the start menu"); assert_eq!(detect(&mut corners), Scene::Unknown, "four corners are not the start menu");
} }
#[test]
fn a_battle_decided_and_not_yet_begun_is_the_cartridges() {
// Row 58. Between a trainer's challenge closing and the battle screen the transition runs for
// 219 frames with every joypad and script bit clear; `wCurOpponent` is what says a battle has
// been decided. The byte is derived, not generated: it sits between two generated ones.
assert_eq!(poke::CUR_OPPONENT, ram::wIsInBattle + 2, "after wIsInBattle and one flag byte");
assert_eq!(poke::CUR_OPPONENT, ram::wTrainerNo - 4, "and four before wTrainerNo");
let mut wram = Wram::overworld();
assert_eq!(detect(&mut wram), Scene::Overworld);
wram.set(poke::CUR_OPPONENT, 0xcd);
assert_eq!(detect(&mut wram), Scene::Unknown, "OPP_JR_TRAINER_M, decided");
wram.set(poke::CUR_OPPONENT, 0x00);
assert_eq!(detect(&mut wram), Scene::Overworld, "and `EndOfBattle` clears it");
}

View file

@ -132,6 +132,24 @@ pub mod poke {
/// deliberately *not* here: standing on a doormat is an ordinary overworld state, and it is the /// deliberately *not* here: standing on a doormat is an ordinary overworld state, and it is the
/// one `docs/design/room-escape.md` cares most about. /// one `docs/design/room-escape.md` cares most about.
pub const SCRIPTED_MOVEMENT: u8 = 0xc0; pub const SCRIPTED_MOVEMENT: u8 = 0xc0;
/// `wCurOpponent` (row 58): the species of a wild opponent or `OPP_ID_OFFSET` plus a
/// trainer's class, written when a battle is *decided* -- `home/trainers.asm` for a trainer,
/// the encounter check for a wild one -- and cleared by `EndOfBattle` together with
/// `wIsInBattle`. Not in the generated table, so it is derived rather than pinned:
/// `ram/wram.asm` at the pinned commit declares `wIsInBattle:: db`,
/// `wPartyGainExpFlags:: flag_array PARTY_LENGTH` (one byte), `wCurOpponent:: db`,
/// `wBattleType:: db`, `wDamageMultipliers:: db`, `wGymLeaderNo:: db`, `wTrainerNo:: db` in
/// that order, and the table's `wIsInBattle` (`$d057`), `wBattleType` (`$d05a`) and
/// `wTrainerNo` (`$d05d`) sit exactly where that layout puts them, so the byte between is
/// `wBattleType - 1` with both neighbours checked. Measured on the cartridge in the Pewter Gym:
/// zero in the overworld, non-zero from the frame a trainer's challenge closes to the end of
/// the battle, including the 219 frames of the battle transition in between.
pub const CUR_OPPONENT: u16 = super::ram::wBattleType - 1;
/// `constants/ram_constants.asm`: `wStatusFlags7` bit 3, `BIT_TRAINER_BATTLE` (row 61). Set by
/// `CheckFightingMapTrainers` (`home/trainers.asm`) on the frame a trainer sees the player,
/// cleared at `.battleOccurred` (`home/overworld.asm`) once the battle is over -- before the
/// blackout check, so a lost battle clears it too. Nothing else writes it.
pub const TRAINER_BATTLE_STATUS7: u8 = 1 << 3;
/// `constants/battle_constants.asm`: the non-volatile status byte. /// `constants/battle_constants.asm`: the non-volatile status byte.
pub const SLP_MASK: u8 = 0b111; pub const SLP_MASK: u8 = 0b111;
@ -184,6 +202,19 @@ pub mod poke {
pub const SPRITE_BYTES: u16 = 16; pub const SPRITE_BYTES: u16 = 16;
/// `MACRO object_event` stores map coordinates plus four. /// `MACRO object_event` stores map coordinates plus four.
pub const SPRITE_COORD_BIAS: u8 = 4; pub const SPRITE_COORD_BIAS: u8 = 4;
/// `constants/map_object_constants.asm`: `SPRITESTATEDATA1_IMAGEINDEX`, and the `$ff` that
/// `CheckSpriteAvailability` writes there for a sprite it will not draw.
pub const SPRITE_IMAGE_INDEX: u16 = 2;
pub const SPRITE_NOT_DRAWN: u8 = 0xff;
/// `SPRITESTATEDATA2_MOVEMENTBYTE1`, and `WALK` (`$fe`): a movement byte below it is a
/// scripted mover, which `CheckSpriteAvailability` never hides for being off the screen.
pub const SPRITE_MOVEMENT_BYTE: u16 = 6;
pub const MOVEMENT_WALK: u8 = 0xfe;
/// `CheckSpriteAvailability`'s window, in map tiles past the player's own coordinate:
/// `SCREEN_HEIGHT / 2 - 1` rows and `SCREEN_WIDTH / 2 - 1` columns, compared against the
/// sprite's *biased* coordinate.
pub const DRAWN_ROWS: u8 = 8;
pub const DRAWN_COLUMNS: u8 = 9;
/// `constants/map_data_constants.asm`: `wCurMapConnections` bits. /// `constants/map_data_constants.asm`: `wCurMapConnections` bits.
pub const CONNECTION_EAST: u8 = 1; pub const CONNECTION_EAST: u8 = 1;
@ -206,6 +237,54 @@ pub mod poke {
/// this fixed point. /// this fixed point.
pub const PLAYER_SCREEN_X: i32 = 8; pub const PLAYER_SCREEN_X: i32 = 8;
pub const PLAYER_SCREEN_Y: i32 = 9; pub const PLAYER_SCREEN_Y: i32 = 9;
/// The cartridge's move table and the battle engine's answers to it (row 60,
/// `docs/design/macros.md` 12.23).
pub mod moves {
/// `data/moves/moves.asm`: `Moves` opens `SECTION "Battle Engine 7"`, which
/// `layout.link` places first in ROM bank `$0E`, so the table starts at `$0E:$4000`.
/// Six bytes a row (`MOVE_LENGTH`): animation (the move id itself), effect, power,
/// type, accuracy, PP, rows in move-id order from `POUND` (1).
pub const TABLE_BANK: u8 = 0x0e;
pub const TABLE_ADDRESS: u16 = 0x4000;
pub const ROW_BYTES: u16 = 6;
/// `constants/move_constants.asm`: `NUM_ATTACKS`, `STRUGGLE` (`$a5`) the last.
pub const LAST_MOVE: u8 = 0xa5;
/// `constants/move_effect_constants.asm`: the stat-stage effects, each run in stage
/// order ATTACK, DEFENSE, SPEED, SPECIAL, ACCURACY, EVASION.
pub const ATTACK_UP1: u8 = 0x0a;
pub const EVASION_UP1: u8 = 0x0f;
pub const ATTACK_DOWN1: u8 = 0x12;
pub const EVASION_DOWN1: u8 = 0x17;
pub const SLEEP: u8 = 0x20;
pub const ATTACK_UP2: u8 = 0x32;
pub const EVASION_UP2: u8 = 0x37;
pub const ATTACK_DOWN2: u8 = 0x3a;
pub const EVASION_DOWN2: u8 = 0x3f;
pub const POISON: u8 = 0x42;
pub const PARALYZE: u8 = 0x43;
/// `constants/battle_constants.asm`: a stage byte is 1 (-6) to `MAX_STAT_LEVEL` 13 (+6),
/// 7 normal; `MAX_STAT_VALUE` 999. The first four stages have a stat behind them
/// (`wBattleMonAttack` onwards, big-endian words); accuracy and evasion do not.
pub const MIN_STAGE: u8 = 1;
pub const MAX_STAGE: u8 = 13;
pub const STATS_WITH_VALUES: u8 = 4;
pub const MIN_STAT: u16 = 1;
pub const MAX_STAT: u16 = 999;
/// `wEnemyBattleStatus2` bits: `PROTECTED_BY_MIST` 1, `HAS_SUBSTITUTE_UP` 4,
/// `NEEDS_TO_RECHARGE` 5.
pub const MIST: u8 = 1 << 1;
pub const SUBSTITUTE: u8 = 1 << 4;
pub const RECHARGE: u8 = 1 << 5;
/// `constants/type_constants.asm`.
pub const TYPE_POISON: u8 = 0x03;
pub const TYPE_GROUND: u8 = 0x04;
pub const TYPE_ELECTRIC: u8 = 0x17;
}
} }
fn read(memory: &mut dyn MemoryReader, address: u16) -> u8 { fn read(memory: &mut dyn MemoryReader, address: u16) -> u8 {
@ -280,12 +359,22 @@ pub fn started(memory: &mut dyn MemoryReader) -> bool {
} }
/// Whether the player's buttons reach the player: no ignored joypad, no simulated input, no /// Whether the player's buttons reach the player: no ignored joypad, no simulated input, no
/// scripted movement, no warp in flight, not mid-ledge-hop. /// scripted movement, no warp in flight, not mid-ledge-hop, no battle decided and not yet begun.
/// ///
/// The masks are the reward adapter's own scripted gate, minus the door bits — see /// The masks are the reward adapter's own scripted gate, minus the door bits — see
/// [`poke::SCRIPTED_MOVEMENT`]. /// [`poke::SCRIPTED_MOVEMENT`].
///
/// **A battle decided is the cartridge's** (row 58). Between a trainer's challenge closing and the
/// battle screen, the battle transition runs for 219 frames with every joypad and script bit
/// clear, so the seam read an overworld the fly could walk in: the pad was dealt, a walk toward
/// the gym leader pressed into an animation, gave up after three refused steps, and put the
/// leader into the blocked ledger for ten brain minutes -- and the Jr. Trainer's conversation read
/// as over, so the trainer the fly was about to lose to went into the talked ledger for the
/// session. [`poke::CUR_OPPONENT`] is set on the frame the battle is decided and cleared with the
/// battle's own end.
pub fn controllable(memory: &mut dyn MemoryReader) -> bool { pub fn controllable(memory: &mut dyn MemoryReader) -> bool {
read(memory, ram::wJoyIgnore) == 0 read(memory, poke::CUR_OPPONENT) == 0
&& read(memory, ram::wJoyIgnore) == 0
&& read(memory, ram::wSimulatedJoypadStatesIndex) == 0 && read(memory, ram::wSimulatedJoypadStatesIndex) == 0
&& read(memory, ram::wStatusFlags5) & poke::SCRIPTED_STATUS5 == 0 && read(memory, ram::wStatusFlags5) & poke::SCRIPTED_STATUS5 == 0
&& read(memory, ram::wStatusFlags6) & poke::SCRIPTED_STATUS6 == 0 && read(memory, ram::wStatusFlags6) & poke::SCRIPTED_STATUS6 == 0
@ -589,6 +678,114 @@ fn enemy_mon(memory: &mut dyn MemoryReader) -> Option<EnemyMon> {
}) })
} }
/// One row of the cartridge's move table (`data/moves/moves.asm`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MoveData {
pub id: u8,
pub effect: u8,
pub power: u8,
pub kind: u8,
pub accuracy: u8,
pub pp: u8,
}
/// Move `id`'s row of the move table, read from the cartridge image.
///
/// `None` when the seam has no cartridge behind it, when `id` is not a move, or when the row does
/// not open with its own id -- every row of `Moves` does (`move`'s first byte is the animation,
/// "interchangeable with move id"), so a table that is not where the disassembly puts it answers
/// nothing rather than a neighbour's effect.
pub fn move_data(memory: &mut dyn MemoryReader, id: u8) -> Option<MoveData> {
use poke::moves::{LAST_MOVE, ROW_BYTES, TABLE_ADDRESS, TABLE_BANK};
if id == 0 || id > LAST_MOVE {
return None;
}
let base = TABLE_ADDRESS + u16::from(id - 1) * ROW_BYTES;
let mut row = [0u8; 6];
for (offset, byte) in row.iter_mut().enumerate() {
*byte = memory.read_rom(TABLE_BANK, base + offset as u16)?;
}
if row[0] != id {
return None;
}
Some(MoveData {
id,
effect: row[1],
power: row[2],
kind: row[3],
accuracy: row[4],
pp: row[5],
})
}
/// Whether the battle engine will answer the fly's move `id` with nothing at all, on this frame.
///
/// Row 60 (`docs/design/macros.md` 12.23): Squirtle's TAIL WHIP against a Pidgey whose DEFENSE
/// was already at -6 printed "Nothing happened!" 183 times on Route 1. These are the refusals the
/// effect routines in `engine/battle/effects.asm` make on bytes that are already in WRAM when the
/// move is chosen, for a move that deals no damage (a move with power is always attempted (type immunity, e.g. TACKLE against GASTLY, is not answered here)):
///
/// - a stat-raising effect (`StatModifierUpEffect`): the user's stage is already +6, or the stat
/// itself is already 999;
/// - a stat-lowering effect (`StatModifierDownEffect`, `MoveHitTest`): the target has a
/// substitute or Mist, its stage is already -6, or the stat itself is already 1;
/// - `SleepEffect`: the target already has a status and is not recharging;
/// - `PoisonEffect`: a substitute, a status, or a Poison type;
/// - `ParalyzeEffect`: a status, or an Electric move against a Ground type.
///
/// `Some(false)` for every other move, which the cartridge may still miss -- a miss is a roll,
/// and this answers only what is already decided. `None` outside a battle this module
/// understands, when the move table cannot be read, or when a stage byte is out of its range:
/// a refusal this module cannot read is not one it reports.
pub fn move_without_effect(memory: &mut dyn MemoryReader, id: u8) -> Option<bool> {
use poke::moves::*;
in_battle(memory)?;
let data = move_data(memory, id)?;
if data.power != 0 {
return Some(false);
}
let stage = |memory: &mut dyn MemoryReader, base: u16, stat: u8| -> Option<u8> {
Some(read(memory, base + u16::from(stat))).filter(|stage| (MIN_STAGE..=MAX_STAGE).contains(stage))
};
let value = |memory: &mut dyn MemoryReader, base: u16, stat: u8| -> Option<u16> {
(stat < STATS_WITH_VALUES).then(|| word_be(memory, base + 2 * u16::from(stat)))
};
let effect = data.effect;
let raised = match effect {
ATTACK_UP1..=EVASION_UP1 => Some(effect - ATTACK_UP1),
ATTACK_UP2..=EVASION_UP2 => Some(effect - ATTACK_UP2),
_ => None,
};
if let Some(stat) = raised {
let at = stage(memory, ram::wPlayerMonStatMods, stat)?;
return Some(at >= MAX_STAGE || value(memory, ram::wBattleMonAttack, stat) == Some(MAX_STAT));
}
let target = read(memory, ram::wEnemyBattleStatus2);
let lowered = match effect {
ATTACK_DOWN1..=EVASION_DOWN1 => Some(effect - ATTACK_DOWN1),
ATTACK_DOWN2..=EVASION_DOWN2 => Some(effect - ATTACK_DOWN2),
_ => None,
};
if let Some(stat) = lowered {
let at = stage(memory, ram::wEnemyMonStatMods, stat)?;
return Some(
target & (SUBSTITUTE | MIST) != 0
|| at <= MIN_STAGE
|| value(memory, ram::wEnemyMonAttack, stat) == Some(MIN_STAT),
);
}
let status = read(memory, ram::wEnemyMonStatus);
let types = [read(memory, ram::wEnemyMonType1), read(memory, ram::wEnemyMonType1 + 1)];
Some(match effect {
SLEEP => status != 0 && target & RECHARGE == 0,
POISON => target & SUBSTITUTE != 0 || status != 0 || types.contains(&TYPE_POISON),
PARALYZE => {
status != 0 || (data.kind == TYPE_ELECTRIC && types.contains(&TYPE_GROUND))
}
_ => false,
})
}
/// Whether a text box is open, and whether the bottom-of-screen dialogue box is the one drawn. /// Whether a text box is open, and whether the bottom-of-screen dialogue box is the one drawn.
/// ///
/// `open` is `wFontLoaded`'s bit 0, which `DisplayTextIDInit` sets for every text display — the /// `open` is `wFontLoaded`'s bit 0, which `DisplayTextIDInit` sets for every text display — the
@ -1045,6 +1242,78 @@ pub fn npcs(memory: &mut dyn MemoryReader) -> Vec<Npc> {
npcs npcs
} }
/// The people and objects of the current map the cartridge is not drawing **only because they are
/// off the screen** (row 58).
///
/// [`npcs`] reports what is drawn, and the Pewter Gym showed what that costs: from the gym's
/// doormat at (4, 13) BROCK at (4, 1) and the Jr. Trainer at (3, 6) are both outside the window, so
/// the macros saw one person in the room -- the guide, already talked to -- and concluded the
/// room held nothing the ladder wanted.
///
/// `CheckSpriteAvailability` (`engine/overworld/movement.asm`) writes `$ff` into a sprite's image
/// index for three reasons: it is a toggleable object switched off, it is outside the window, or the
/// tile under it is a text box's (a tile id past the map tileset). The window is a pure function of
/// bytes this crate already reads -- `wYCoord`, `wXCoord` and the sprite's own biased `MAPY` /
/// `MAPX` -- so a sprite the cartridge hides and whose coordinates lie **outside** that window is
/// one it would hide for that reason whatever else were true, and its coordinates are still the
/// map's: a sprite the cartridge is not updating does not move. A sprite hidden **inside** the
/// window is hidden for another reason and is not reported. A scripted mover (movement byte below
/// `WALK`) skips the window test altogether, so its `$ff` is never the screen's and it is never
/// reported either.
///
/// What this cannot tell is the first reason from the second for a sprite outside the window: a
/// toggleable object that is off reads the same as one that is merely far away. That is named, not
/// guessed: [`crate::pokemon_red::macros::palette::objective_targets`] is the one reader, and the
/// ladder's places that name a person are Oak's lab and the gyms, of which only the lab and Viridian
/// Gym carry toggleable people (`data/maps/toggleable_objects.asm`).
pub fn offscreen_npcs(memory: &mut dyn MemoryReader) -> Vec<Npc> {
let Some(size) = map_size(memory) else { return Vec::new() };
let player_y = read(memory, ram::wYCoord);
let player_x = read(memory, ram::wXCoord);
if player_x >= size.width || player_y >= size.height {
return Vec::new();
}
// `CheckSpriteAvailability`, one axis: `cp b / jr z, skip / jr nc, invisible / add n / cp b /
// jr c, invisible` against the biased coordinate `b`.
let drawn = |own: u8, sprite: u8, reach: u8| {
sprite == own || (own < sprite && u16::from(sprite) <= u16::from(own) + u16::from(reach))
};
let count = read(memory, ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
let mut out = Vec::new();
for slot in 1..=count {
let data1 = ram::wSpriteStateData1 + u16::from(slot) * poke::SPRITE_BYTES;
let data2 = ram::wSpriteStateData2 + u16::from(slot) * poke::SPRITE_BYTES;
let picture = read(memory, data1);
if picture == 0 || read(memory, data1 + poke::SPRITE_IMAGE_INDEX) != poke::SPRITE_NOT_DRAWN
{
continue;
}
if read(memory, data2 + poke::SPRITE_MOVEMENT_BYTE) < poke::MOVEMENT_WALK {
continue;
}
let y = read(memory, data2 + 4);
let x = read(memory, data2 + 5);
if y < poke::SPRITE_COORD_BIAS || x < poke::SPRITE_COORD_BIAS {
continue;
}
let (map_x, map_y) = (x - poke::SPRITE_COORD_BIAS, y - poke::SPRITE_COORD_BIAS);
if map_x >= size.width || map_y >= size.height {
continue;
}
if drawn(player_y, y, poke::DRAWN_ROWS) && drawn(player_x, x, poke::DRAWN_COLUMNS) {
continue;
}
out.push(Npc {
slot,
picture,
x: map_x,
y: map_y,
facing: facing_from(read(memory, data1 + 9)),
});
}
out
}
/// The current tileset's list of passable tile ids, terminator included. /// The current tileset's list of passable tile ids, terminator included.
/// ///
/// `CheckTilePassable` walks the list at `wTilesetCollisionPtr` — a little-endian pointer into the /// `CheckTilePassable` walks the list at `wTilesetCollisionPtr` — a little-endian pointer into the
@ -1577,8 +1846,14 @@ impl<'a> PokeState<'a> {
} }
impl GameState for PokeState<'_> { impl GameState for PokeState<'_> {
/// [`super::scene::detect`], except that an overworld frame inside a trainer's challenge is
/// the cartridge's ([`trainer_engaged`], row 61): section 12.13's `Unknown` with no text box,
/// an empty pad the fly waits out, and no frame a held push-back is decided on.
fn scene(&mut self) -> Scene { fn scene(&mut self) -> Scene {
super::scene::detect(self.memory) match super::scene::detect(self.memory) {
Scene::Overworld if trainer_engaged(self.memory) => Scene::Unknown,
scene => scene,
}
} }
fn player(&mut self) -> Option<Player> { fn player(&mut self) -> Option<Player> {
@ -1625,6 +1900,10 @@ impl GameState for PokeState<'_> {
npcs(self.memory) npcs(self.memory)
} }
fn offscreen_npcs(&mut self) -> Vec<Npc> {
offscreen_npcs(self.memory)
}
fn signs(&mut self) -> Vec<Sign> { fn signs(&mut self) -> Vec<Sign> {
signs(self.memory) signs(self.memory)
} }
@ -1642,6 +1921,27 @@ impl GameState for PokeState<'_> {
} }
} }
/// Whether a trainer who saw the player is between its "!" and the end of its battle (row 61).
///
/// Two stretches of that window read as an overworld the fly owned, both measured in Viridian
/// Forest: the "!" bubble, about sixty frames, drawn before `CheckFightingMapTrainers` sets
/// `wJoyIgnore`; and five frames after the challenge text, because
/// `DisplayEnemyTrainerTextAndStartBattle` (`home/trainers.asm`) clears `wJoyIgnore` before the
/// text and calls `StartTrainerBattle`, which writes `wCurOpponent`, only after the text's
/// close-down has redrawn the map. About sixty-six frames per engagement, every bit
/// [`controllable`] reads clear. The push-back a walk earned when the trainer took the joypad (row
/// 58's held entry) was written on the first frame after the text: the one free tile beside the
/// trainer, in the only corridor to the forest's north gate, walled for the session.
///
/// **The macros' reading only.** [`controllable`] and [`super::scene::detect`] are shared with
/// the reward adapter (the talk payout's "ready" test) and do not change; [`PokeState`]'s own
/// `scene` and `scripted` read this beside them. In macros mode the feed's `game.scene` is the
/// palette's, so it reads `unknown` on these frames, as the contract has it for a frame the
/// cartridge is driving.
pub fn trainer_engaged(memory: &mut dyn MemoryReader) -> bool {
read(memory, ram::wStatusFlags7) & poke::TRAINER_BATTLE_STATUS7 != 0
}
/// The cartridge tables on their defaults, and the exploration ledger wired through. /// The cartridge tables on their defaults, and the exploration ledger wired through.
/// ///
/// `pokemon_red/macros/cartridge.rs` defaults every [`MacroState`] method and every default /// `pokemon_red/macros/cartridge.rs` defaults every [`MacroState`] method and every default
@ -1655,7 +1955,7 @@ impl GameState for PokeState<'_> {
/// taken rather than at the nearest door (`docs/design/macros.md` section 3). /// taken rather than at the nearest door (`docs/design/macros.md` section 3).
impl MacroState for PokeState<'_> { impl MacroState for PokeState<'_> {
fn scripted(&mut self) -> bool { fn scripted(&mut self) -> bool {
!controllable(self.memory) !controllable(self.memory) || trainer_engaged(self.memory)
} }
fn text_open(&mut self) -> bool { fn text_open(&mut self) -> bool {
@ -1670,6 +1970,10 @@ impl MacroState for PokeState<'_> {
yes_no_prompt(self.memory) yes_no_prompt(self.memory)
} }
fn move_without_effect(&mut self, id: u8) -> bool {
move_without_effect(self.memory, id).unwrap_or(false)
}
/// The whole loaded map's walkability, from the cache when it is for this map /// The whole loaded map's walkability, from the cache when it is for this map
/// (`docs/design/macros.md` section 15). /// (`docs/design/macros.md` section 15).
/// ///

View file

@ -1040,3 +1040,216 @@ fn a_state_with_no_cache_still_answers_and_a_state_with_no_cartridge_answers_non
// Which is the frame the window predicate is for. // Which is the frame the window predicate is for.
assert_eq!(state.walkable(3, 6), Walkable::No); assert_eq!(state.walkable(3, 6), Walkable::No);
} }
#[test]
fn a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map() {
// Row 58, the Pewter Gym from its doormat at (4, 13). The cartridge draws the guide; BROCK at
// (4, 1) and the Jr. Trainer at (3, 6) are outside `CheckSpriteAvailability`'s window, so it
// writes `$ff` into their image index and `npcs` -- which reports what is drawn -- skips them.
const STAY: u8 = 0xff;
let mut wram = Wram::overworld();
wram.map(0x36, 5, 7, 4, 13)
.npc(3, 0x2b, 7, 10, 0x00)
.npc_undrawn(1, 0x1f, 4, 1, STAY)
.npc_undrawn(2, 0x0e, 3, 6, STAY)
// Undrawn *inside* the window: switched off, or under a text box -- not the screen's doing.
.npc_undrawn(4, 0x05, 5, 11, STAY)
// Undrawn outside it, but a scripted mover, which the window test never hides.
.npc_undrawn(5, 0x05, 8, 1, 0x00);
let drawn: Vec<u8> = npcs(&mut wram).iter().map(|npc| npc.slot).collect();
assert_eq!(drawn, vec![3]);
let off: Vec<(u8, u8, u8)> =
offscreen_npcs(&mut wram).iter().map(|npc| (npc.slot, npc.x, npc.y)).collect();
assert_eq!(off, vec![(1, 4, 1), (2, 3, 6)], "the leader and the trainer, where they stand");
// Walk up the room and the trainer is inside the window: a `$ff` there is not the screen's.
wram.map(0x36, 5, 7, 4, 8);
let off: Vec<u8> = offscreen_npcs(&mut wram).iter().map(|npc| npc.slot).collect();
assert_eq!(off, vec![1], "only the leader is still off the screen from (4, 8)");
}
/// Row 60's cartridge rows, as `data/moves/moves.asm` has them: `(id, effect, power, type)`.
const TACKLE: (u8, u8, u8, u8) = (0x21, 0x00, 35, 0x00);
const TAIL_WHIP: (u8, u8, u8, u8) = (0x27, 0x13, 0, 0x00);
const GROWL: (u8, u8, u8, u8) = (0x2d, 0x12, 0, 0x00);
const SCREECH: (u8, u8, u8, u8) = (0x67, 0x3b, 0, 0x00);
const WITHDRAW: (u8, u8, u8, u8) = (0x6e, 0x0b, 0, 0x15);
const SAND_ATTACK: (u8, u8, u8, u8) = (0x1c, 0x16, 0, 0x00);
const SLEEP_POWDER: (u8, u8, u8, u8) = (0x4f, 0x20, 0, 0x16);
const POISONPOWDER: (u8, u8, u8, u8) = (0x4d, 0x42, 0, 0x03);
const THUNDER_WAVE: (u8, u8, u8, u8) = (0x56, 0x43, 0, 0x17);
/// `AURORA_BEAM`: a damaging move with a stat side effect, which always does something.
const AURORA_BEAM: (u8, u8, u8, u8) = (0x3e, 0x44, 65, 0x19);
fn stage_battle() -> Wram {
let mut wram = Wram::new();
wram.battle_mon(0, 0xb1, 5, 8, 20, 0, &[(0x21, 35), (0x27, 30)])
.enemy_mon(0x24, 2, 13, 13)
.battle(1)
.normal_stages()
.move_table(&[
TACKLE,
TAIL_WHIP,
GROWL,
SCREECH,
WITHDRAW,
SAND_ATTACK,
SLEEP_POWDER,
POISONPOWDER,
THUNDER_WAVE,
AURORA_BEAM,
]);
wram
}
#[test]
fn the_move_table_is_read_by_id_and_checked_against_its_own_first_byte() {
let mut wram = stage_battle();
let tail_whip = move_data(&mut wram, TAIL_WHIP.0).expect("a row");
assert_eq!((tail_whip.effect, tail_whip.power), (0x13, 0));
assert_eq!(move_data(&mut wram, 0), None, "no move zero");
assert_eq!(move_data(&mut wram, 0xa6), None, "past STRUGGLE");
assert_eq!(move_data(&mut wram, 0x22), None, "a row the cartridge image does not answer");
// A table that is not where the disassembly says: a row that does not open with its own id
// is somebody else's row, and it is not read as this one.
let mut shifted = Wram::new();
shifted.move_table(&[(0x28, 0x13, 0, 0)]);
let base = poke::moves::TABLE_ADDRESS + 0x26 * poke::moves::ROW_BYTES;
for offset in 0..6 {
let byte = shifted.read_rom(poke::moves::TABLE_BANK, base + 6 + offset).unwrap();
shifted.rom_byte(poke::moves::TABLE_BANK, base + offset, byte);
}
assert_eq!(move_data(&mut shifted, 0x27), None);
assert!(move_data(&mut shifted, 0x28).is_some());
}
#[test]
fn tail_whip_does_nothing_at_minus_six_and_something_before() {
// Row 60: the Pidgey's DEFENSE stage walked down from 7 to 1 by TAIL WHIP after TAIL WHIP.
let mut wram = stage_battle();
for stage in 2..=7 {
wram.set(ram::wEnemyMonStatMods + 1, stage);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(false), "stage {stage}");
}
wram.set(ram::wEnemyMonStatMods + 1, 1);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
// The -2 variant reads the same stage.
assert_eq!(move_without_effect(&mut wram, SCREECH.0), Some(true));
// TACKLE deals damage whatever the stages say.
assert_eq!(move_without_effect(&mut wram, TACKLE.0), Some(false));
// And a damaging move with a stat side effect is never "nothing".
wram.set(ram::wEnemyMonStatMods + 3, 1);
assert_eq!(move_without_effect(&mut wram, AURORA_BEAM.0), Some(false));
// GROWL reads ATTACK's stage, not DEFENSE's.
assert_eq!(move_without_effect(&mut wram, GROWL.0), Some(false));
wram.set(ram::wEnemyMonStatMods, 1);
assert_eq!(move_without_effect(&mut wram, GROWL.0), Some(true));
// SAND-ATTACK reads ACCURACY's, which has no stat value behind it.
assert_eq!(move_without_effect(&mut wram, SAND_ATTACK.0), Some(false));
wram.set(ram::wEnemyMonStatMods + 4, 1);
assert_eq!(move_without_effect(&mut wram, SAND_ATTACK.0), Some(true));
}
#[test]
fn a_stat_already_at_one_or_nine_hundred_ninety_nine_refuses_before_the_stage_does() {
// `StatModifierDownEffect` restores the stage and prints "Nothing happened!" when the stat
// itself is already 1 -- a level-2 Pidgey's DEFENSE gets there before -6.
let mut wram = stage_battle();
wram.set(ram::wEnemyMonStatMods + 1, 4).set_word_be(ram::wEnemyMonAttack + 2, 1);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
wram.set_word_be(ram::wEnemyMonAttack + 2, 2);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(false));
// Raising: +6, or a stat of 999.
wram.set(ram::wPlayerMonStatMods + 1, 12);
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(false));
wram.set(ram::wPlayerMonStatMods + 1, 13);
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(true));
wram.set(ram::wPlayerMonStatMods + 1, 9).set_word_be(ram::wBattleMonAttack + 2, 999);
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(true));
}
#[test]
fn mist_and_a_substitute_turn_a_stat_lowering_move_away() {
let mut wram = stage_battle();
wram.set(ram::wEnemyBattleStatus2, poke::moves::MIST);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
wram.set(ram::wEnemyBattleStatus2, poke::moves::SUBSTITUTE);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), Some(true));
assert_eq!(move_without_effect(&mut wram, POISONPOWDER.0), Some(true));
// A raise is the user's own business.
assert_eq!(move_without_effect(&mut wram, WITHDRAW.0), Some(false));
}
#[test]
fn a_status_move_against_a_target_it_cannot_affect_does_nothing() {
let mut wram = stage_battle();
for id in [SLEEP_POWDER.0, POISONPOWDER.0, THUNDER_WAVE.0] {
assert_eq!(move_without_effect(&mut wram, id), Some(false), "healthy target, {id:#04x}");
}
// Any status: already asleep, poisoned, paralysed.
wram.set(ram::wEnemyMonStatus, 1 << 6);
for id in [SLEEP_POWDER.0, POISONPOWDER.0, THUNDER_WAVE.0] {
assert_eq!(move_without_effect(&mut wram, id), Some(true), "statused target, {id:#04x}");
}
// A target that must recharge is put to sleep whatever its status (`SleepEffect`).
wram.set(ram::wEnemyBattleStatus2, poke::moves::RECHARGE);
assert_eq!(move_without_effect(&mut wram, SLEEP_POWDER.0), Some(false));
// Types: a Poison type is not poisoned; a Ground type is not paralysed by an Electric move.
let mut typed = stage_battle();
typed.set(ram::wEnemyMonType1 + 1, poke::moves::TYPE_POISON);
assert_eq!(move_without_effect(&mut typed, POISONPOWDER.0), Some(true));
typed.set(ram::wEnemyMonType1 + 1, 0).set(ram::wEnemyMonType1, poke::moves::TYPE_GROUND);
assert_eq!(move_without_effect(&mut typed, THUNDER_WAVE.0), Some(true));
assert_eq!(move_without_effect(&mut typed, POISONPOWDER.0), Some(false));
}
#[test]
fn a_refusal_that_cannot_be_read_is_not_reported() {
// Out of battle, no cartridge behind the seam, or a stage byte out of its 1..=13 range.
let mut wram = stage_battle();
wram.set(ram::wIsInBattle, 0);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), None);
let mut bare = Wram::new();
bare.battle(1).normal_stages().set(ram::wEnemyMonStatMods + 1, 1);
assert_eq!(move_without_effect(&mut bare, TAIL_WHIP.0), None, "no move table");
let mut wram = stage_battle();
wram.set(ram::wEnemyMonStatMods + 1, 0);
assert_eq!(move_without_effect(&mut wram, TAIL_WHIP.0), None);
// And the seam's own answer is "leave the button where it was".
let mut unread = Wram::new();
assert!(!PokeState::new(&mut unread).move_without_effect(TAIL_WHIP.0));
let mut read = stage_battle();
read.set(ram::wEnemyMonStatMods + 1, 1);
assert!(PokeState::new(&mut read).move_without_effect(TAIL_WHIP.0));
}
#[test]
fn a_trainers_challenge_is_the_cartridges_until_its_battle_is_over() {
use crate::pokemon_red::macros::state::GameState;
// Row 61. A trainer who saw the fly: its "!" bubble (before `wJoyIgnore` is set) and the five
// frames after its text (before `wCurOpponent` is) have every bit `controllable` reads clear.
// The macros read them as the cartridge's; the shared readings the reward adapter uses do not
// move.
let mut wram = Wram::overworld();
assert_eq!(PokeState::new(&mut wram).scene(), Scene::Overworld);
assert!(!PokeState::new(&mut wram).scripted());
wram.set(ram::wStatusFlags7, poke::TRAINER_BATTLE_STATUS7);
assert!(trainer_engaged(&mut wram));
assert_eq!(PokeState::new(&mut wram).scene(), Scene::Unknown, "inside the challenge");
assert!(PokeState::new(&mut wram).scripted(), "and the fly is not its own master");
assert!(controllable(&mut wram), "the adapter's gate is unchanged");
assert_eq!(crate::pokemon_red::scene::detect(&mut wram), Scene::Overworld, "and the shared scene");
// The challenge's own text is still a conversation to advance.
wram.dialogue_box();
assert_eq!(PokeState::new(&mut wram).scene(), Scene::Dialog);
// `.battleOccurred` clears the bit; the other bits of the byte are not a challenge:
// `BIT_NO_MAP_MUSIC` after a rival, `BIT_USE_CUR_MAP_SCRIPT` from a trainer talked to.
let mut after = Wram::overworld();
after.set(ram::wStatusFlags7, (1 << 1) | (1 << 4) | (1 << 7));
assert!(!trainer_engaged(&mut after));
assert_eq!(PokeState::new(&mut after).scene(), Scene::Overworld);
assert!(!PokeState::new(&mut after).scripted());
}

View file

@ -23,29 +23,38 @@ pub mod ram {
pub const wMaxMenuItem: u16 = 0xcc28; // 52264 pub const wMaxMenuItem: u16 = 0xcc28; // 52264
pub const wMenuWatchedKeys: u16 = 0xcc29; // 52265 pub const wMenuWatchedKeys: u16 = 0xcc29; // 52265
pub const wPlayerMonNumber: u16 = 0xcc2f; // 52271 pub const wPlayerMonNumber: u16 = 0xcc2f; // 52271
pub const wPlayerMonStatMods: u16 = 0xcd1a; // 52506
pub const wEnemyMonStatMods: u16 = 0xcd2e; // 52526
pub const wSimulatedJoypadStatesIndex: u16 = 0xcd38; // 52536 pub const wSimulatedJoypadStatesIndex: u16 = 0xcd38; // 52536
pub const wMiscFlags: u16 = 0xcd60; // 52576 pub const wMiscFlags: u16 = 0xcd60; // 52576
pub const wJoyIgnore: u16 = 0xcd6b; // 52587 pub const wJoyIgnore: u16 = 0xcd6b; // 52587
pub const wNumMovesMinusOne: u16 = 0xcd6c; // 52588 pub const wNumMovesMinusOne: u16 = 0xcd6c; // 52588
pub const wBattleResult: u16 = 0xcf0b; // 53003 pub const wBattleResult: u16 = 0xcf0b; // 53003
pub const wSpriteIndex: u16 = 0xcf13; // 53011
pub const wItemList: u16 = 0xcf7b; // 53115 pub const wItemList: u16 = 0xcf7b; // 53115
pub const wListMenuID: u16 = 0xcf94; // 53140 pub const wListMenuID: u16 = 0xcf94; // 53140
pub const wFontLoaded: u16 = 0xcfc4; // 53188 pub const wFontLoaded: u16 = 0xcfc4; // 53188
pub const wWalkCounter: u16 = 0xcfc5; // 53189
pub const wEnemyMonSpecies: u16 = 0xcfe5; // 53221 pub const wEnemyMonSpecies: u16 = 0xcfe5; // 53221
pub const wEnemyMonHP: u16 = 0xcfe6; // 53222 pub const wEnemyMonHP: u16 = 0xcfe6; // 53222
pub const wEnemyMonStatus: u16 = 0xcfe9; // 53225
pub const wEnemyMonType1: u16 = 0xcfea; // 53226
pub const wEnemyMonLevel: u16 = 0xcff3; // 53235 pub const wEnemyMonLevel: u16 = 0xcff3; // 53235
pub const wEnemyMonMaxHP: u16 = 0xcff4; // 53236 pub const wEnemyMonMaxHP: u16 = 0xcff4; // 53236
pub const wEnemyMonAttack: u16 = 0xcff6; // 53238
pub const wBattleMonSpecies: u16 = 0xd014; // 53268 pub const wBattleMonSpecies: u16 = 0xd014; // 53268
pub const wBattleMonHP: u16 = 0xd015; // 53269 pub const wBattleMonHP: u16 = 0xd015; // 53269
pub const wBattleMonStatus: u16 = 0xd018; // 53272 pub const wBattleMonStatus: u16 = 0xd018; // 53272
pub const wBattleMonMoves: u16 = 0xd01c; // 53276 pub const wBattleMonMoves: u16 = 0xd01c; // 53276
pub const wBattleMonLevel: u16 = 0xd022; // 53282 pub const wBattleMonLevel: u16 = 0xd022; // 53282
pub const wBattleMonMaxHP: u16 = 0xd023; // 53283 pub const wBattleMonMaxHP: u16 = 0xd023; // 53283
pub const wBattleMonAttack: u16 = 0xd025; // 53285
pub const wBattleMonPP: u16 = 0xd02d; // 53293 pub const wBattleMonPP: u16 = 0xd02d; // 53293
pub const wTrainerClass: u16 = 0xd031; // 53297 pub const wTrainerClass: u16 = 0xd031; // 53297
pub const wIsInBattle: u16 = 0xd057; // 53335 pub const wIsInBattle: u16 = 0xd057; // 53335
pub const wBattleType: u16 = 0xd05a; // 53338 pub const wBattleType: u16 = 0xd05a; // 53338
pub const wTrainerNo: u16 = 0xd05d; // 53341 pub const wTrainerNo: u16 = 0xd05d; // 53341
pub const wEnemyBattleStatus2: u16 = 0xd068; // 53352
pub const wPartyMenuTypeOrMessageID: u16 = 0xd07d; // 53373 pub const wPartyMenuTypeOrMessageID: u16 = 0xd07d; // 53373
pub const wCapturedMonSpecies: u16 = 0xd11c; // 53532 pub const wCapturedMonSpecies: u16 = 0xd11c; // 53532
pub const wForcePlayerToChooseMon: u16 = 0xd11f; // 53535 pub const wForcePlayerToChooseMon: u16 = 0xd11f; // 53535
@ -71,11 +80,15 @@ pub mod ram {
pub const wSignCoords: u16 = 0xd4b1; // 54449 pub const wSignCoords: u16 = 0xd4b1; // 54449
pub const wSignTextIDs: u16 = 0xd4d1; // 54481 pub const wSignTextIDs: u16 = 0xd4d1; // 54481
pub const wNumSprites: u16 = 0xd4e1; // 54497 pub const wNumSprites: u16 = 0xd4e1; // 54497
pub const wMapSpriteExtraData: u16 = 0xd504; // 54532
pub const wTilesetBank: u16 = 0xd52b; // 54571 pub const wTilesetBank: u16 = 0xd52b; // 54571
pub const wTilesetBlocksPtr: u16 = 0xd52c; // 54572 pub const wTilesetBlocksPtr: u16 = 0xd52c; // 54572
pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576 pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576
pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578 pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578
pub const wNumHoFTeams: u16 = 0xd5a2; // 54690 pub const wNumHoFTeams: u16 = 0xd5a2; // 54690
pub const wToggleableObjectFlags: u16 = 0xd5a6; // 54694
pub const wToggleableObjectList: u16 = 0xd5ce; // 54734
pub const wObtainedHiddenItemsFlags: u16 = 0xd6f0; // 55024
pub const wStatusFlags5: u16 = 0xd730; // 55088 pub const wStatusFlags5: u16 = 0xd730; // 55088
pub const wStatusFlags6: u16 = 0xd732; // 55090 pub const wStatusFlags6: u16 = 0xd732; // 55090
pub const wStatusFlags7: u16 = 0xd733; // 55091 pub const wStatusFlags7: u16 = 0xd733; // 55091

View file

@ -865,8 +865,8 @@ fn the_recent_ticker_keeps_the_newest_eight_events_newest_first() {
#[test] #[test]
fn the_adapter_reports_its_identity_and_pinned_rom() { fn the_adapter_reports_its_identity_and_pinned_rom() {
let reward = PokemonRedReward::new(); let reward = PokemonRedReward::new();
assert_eq!(reward.id(), "pokered-unique8-v6"); assert_eq!(reward.id(), "pokered-unique8-v7");
assert_eq!(reward.migrates_from(), ["pokered-unique8-v5"]); assert_eq!(reward.migrates_from(), ["pokered-unique8-v6"]);
assert!(reward.rom_allowed(SUPPORTED_ROM)); assert!(reward.rom_allowed(SUPPORTED_ROM));
assert!(!reward.rom_allowed( assert!(!reward.rom_allowed(
"5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b" "5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b"
@ -1356,3 +1356,541 @@ fn a_rung_earned_out_of_order_does_not_skip_the_ones_under_it() {
f.visit(3, 3); f.visit(3, 3);
assert_eq!(f.reward.rank(), 9); assert_eq!(f.reward.rank(), 9);
} }
// --- Engagement rewards (the operator, 2026-09-23) -----------------------------------------------
/// `constants/tileset_constants.asm`: a gym, a house, a mart, the forest and a cave.
const GYM: u8 = 7;
const HOUSE: u8 = 8;
const MART: u8 = 2;
const FOREST: u8 = 3;
const CAVERN: u8 = 17;
/// `wSpriteStateData1`'s facing byte: `SPRITE_FACING_DOWN`, `_UP`, `_LEFT`, `_RIGHT`.
const FACING_DOWN: u8 = 0x00;
const FACING_UP: u8 = 0x04;
const FACING_LEFT: u8 = 0x08;
impl Fixture {
/// Stand on `map`, drawn with `tileset`, with no counter tiles in the tileset header.
fn on_map(&mut self, map: u8, tileset: u8) {
self.memory.set(ram::wCurMap, map);
self.memory.set(ram::wCurMapTileset, tileset);
for index in 0..3 {
self.memory.set(ram::wTilesetTalkingOverTiles + index, 0xff);
}
}
/// A visible sprite in `slot` at map tile `(x, y)`, stored plus four as `object_event` emits
/// it, with `extra` in its `wMapSpriteExtraData` entry.
fn sprite(&mut self, slot: u8, x: u8, y: u8, extra: (u8, u8)) {
let count = self.memory.bytes[ram::wNumSprites as usize].max(slot);
self.memory.set(ram::wNumSprites, count);
let data1 = ram::wSpriteStateData1 + u16::from(slot) * 16;
let data2 = ram::wSpriteStateData2 + u16::from(slot) * 16;
self.memory.set(data1, 1);
self.memory.set(data1 + 2, 0);
self.memory.set(data2 + 4, y + 4);
self.memory.set(data2 + 5, x + 4);
let entry = ram::wMapSpriteExtraData + (u16::from(slot) - 1) * 2;
self.memory.set(entry, extra.0);
self.memory.set(entry + 1, extra.1);
}
fn face(&mut self, facing: u8) {
self.memory.set(ram::wSpriteStateData1 + 9, facing);
}
/// `DisplayTextIDInit`'s `TextBoxBorder` at screen (0, 12)-(19, 17), the box every text id
/// but the start menu's is drawn in; or the map's own tiles again once it is gone.
fn dialogue_box(&mut self, drawn: bool) {
use super::state::poke::frame;
let at = |x: u16, y: u16| ram::wTileMap + y * 20 + x;
for y in 12..=17u16 {
for x in 0..20u16 {
let tile = if !drawn {
0
} else {
match (x, y) {
(0, 12) => frame::TOP_LEFT,
(19, 12) => frame::TOP_RIGHT,
(0, 17) => frame::BOTTOM_LEFT,
(19, 17) => frame::BOTTOM_RIGHT,
(_, 12) | (_, 17) => frame::HORIZONTAL,
(0, _) | (19, _) => frame::VERTICAL,
_ => 0x7f,
}
};
self.memory.set(at(x, y), tile);
}
}
}
/// The box opening: the font bit, the border, and -- `delay` samples later, the way the
/// cartridge loads the font's tiles first -- `DisplayTextID`'s argument.
fn open_box(&mut self, argument: u8, delay: usize) -> Vec<RewardEvent> {
self.memory.set(ram::wFontLoaded, 1);
self.dialogue_box(true);
let mut events = Vec::new();
for _ in 0..delay {
events.extend(self.sample());
}
self.memory.set(ram::wSpriteIndex, argument);
events.extend(self.sample());
events
}
fn close_box(&mut self) -> Vec<RewardEvent> {
self.memory.set(ram::wFontLoaded, 0);
self.dialogue_box(false);
self.sample()
}
/// One conversation as the cartridge draws it: a sample with the box closed (the frame the A
/// press was read on), `DisplayTextIDInit` setting the font bit and `DisplayTextID` copying
/// `argument` into `wSpriteIndex`, a few frames of text, and `CloseTextDisplay`.
fn talk(&mut self, argument: u8) -> Vec<RewardEvent> {
let mut events = self.sample();
events.extend(self.open_box(argument, 20));
events.extend(self.sample());
events.extend(self.close_box());
events
}
fn talk_events(&mut self, argument: u8) -> Vec<RewardEvent> {
self.talk(argument).into_iter().filter(|event| event.kind == kind::TALK).collect()
}
/// `wToggleableObjectList` for this map: `(sprite slot, global index)` pairs and `$ff`.
fn toggle_list(&mut self, entries: &[(u8, u8)]) {
for (index, (slot, global)) in entries.iter().enumerate() {
self.memory.set(ram::wToggleableObjectList + index as u16 * 2, *slot);
self.memory.set(ram::wToggleableObjectList + index as u16 * 2 + 1, *global);
}
self.memory.set(ram::wToggleableObjectList + entries.len() as u16 * 2, 0xff);
}
fn set_bit(&mut self, base: u16, index: u16, on: bool) {
let address = base + index / 8;
let mask = 1 << (index % 8);
let byte = self.memory.bytes[address as usize];
self.memory.set(address, if on { byte | mask } else { byte & !mask });
}
fn item_events(&mut self) -> Vec<RewardEvent> {
self.sample().into_iter().filter(|event| event.kind == kind::ITEM).collect()
}
}
#[test]
fn indoors_is_the_cartridges_building_tilesets_and_nothing_else() {
// CheckIfInOutsideMap's outside (OVERWORLD, PLATEAU) and BikeRidingTilesets (OVERWORLD,
// FOREST, UNDERGROUND, SHIP_PORT, CAVERN) are the two tables; indoor is neither.
let outdoor = [0, 3, 11, 14, 17, 23];
for tileset in 0..24u8 {
assert_eq!(engage::indoor(tileset), !outdoor.contains(&tileset), "tileset {tileset}");
}
assert!(!engage::indoor(24), "a tileset past the table is not a building");
assert!(!engage::indoor(0xff));
}
#[test]
fn a_conversation_the_fly_opens_indoors_pays_once_when_its_box_closes() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
// Nothing on the frames the box is open: the payout waits for it to close.
let mut events = f.sample();
events.extend(f.open_box(1, 20));
assert_eq!(count_of_kind(&events, kind::TALK), 0, "not while the box is open");
let events = f.close_box();
assert_eq!(kinds(&events), ["talk"]);
assert_eq!(events[0].value, 0.10);
assert_eq!(events[0].stimulation_ms, 100);
assert_eq!(labels(&events), [format!("TALKED TO #1 IN AREA {}", maps::PEWTER_GYM)]);
// Talking to the same person again, as often as the fly likes, is not a farm.
for _ in 0..5 {
assert!(f.talk_events(1).is_empty(), "one payout per (map, object) for the ledger's life");
}
// A second person on the same map is a second key.
f.sprite(2, 4, 5, (0, 0));
f.face(FACING_LEFT);
assert_eq!(kinds(&f.talk_events(2)), ["talk"]);
// A sign is a text id past the sprite slots, on the tile the player faces.
f.memory.set(ram::wNumSigns, 1);
f.memory.set(ram::wSignCoords, 6);
f.memory.set(ram::wSignCoords + 1, 5);
f.memory.set(ram::wSignTextIDs, 7);
f.face(FACING_DOWN);
let sign = f.talk_events(7);
assert_eq!(labels(&sign), [format!("READ SIGN #7 IN AREA {}", maps::PEWTER_GYM)]);
assert!(f.talk_events(7).is_empty());
assert_eq!(f.reward.statistics().counts[kind::TALK], 3);
}
#[test]
fn the_same_slot_on_another_indoor_map_is_another_conversation() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
f.on_map(maps::OAKS_LAB, HOUSE);
f.visit(5, 5);
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
}
#[test]
fn talking_outdoors_in_a_forest_or_in_a_cave_pays_nothing() {
for (map, tileset) in [
(maps::PEWTER_CITY, 0),
(maps::VIRIDIAN_FOREST, FOREST),
(maps::MT_MOON_1F, CAVERN),
] {
let mut f = Fixture::booted();
f.on_map(map, tileset);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
assert!(f.talk_events(1).is_empty(), "map {map}, tileset {tileset}");
}
}
#[test]
fn text_the_fly_did_not_open_pays_nothing() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
// A script that took the joypad before it drew the box: a trainer walking up, a guard.
f.memory.set(ram::wJoyIgnore, 0xff);
assert!(f.talk_events(1).is_empty(), "the joypad was the cartridge's");
f.memory.set(ram::wJoyIgnore, 0);
// Simulated input, and scripted movement.
f.memory.set(ram::wSimulatedJoypadStatesIndex, 3);
assert!(f.talk_events(1).is_empty(), "the buttons were simulated");
f.memory.set(ram::wSimulatedJoypadStatesIndex, 0);
f.memory.set(ram::wStatusFlags5, 0x80);
assert!(f.talk_events(1).is_empty(), "the movement was scripted");
f.memory.set(ram::wStatusFlags5, 0);
// A trigger tile: the box opens on the frame a step ends, with the walk counter still
// running on the sample before it -- the overworld never reads A mid-step.
f.memory.set(ram::wWalkCounter, 1);
assert!(f.talk_events(1).is_empty(), "a step onto a trigger tile is not a press");
f.memory.set(ram::wWalkCounter, 0);
// Text about someone the fly is not facing: a script naming a sprite across the room.
f.sprite(2, 9, 9, (0, 0));
assert!(f.talk_events(2).is_empty(), "not the thing in front");
// A text id that is no sign in front of the fly.
assert!(f.talk_events(9).is_empty(), "no sign there");
// The start menu is text id 0.
assert!(f.talk_events(0).is_empty(), "the start menu is not a conversation");
// A script that opens the box with the joypad taken and hands it back mid-conversation:
// the frames after it are the fly's, but the box did not open on one of them.
f.memory.set(ram::wJoyIgnore, 0xff);
f.sample();
f.open_box(1, 0);
f.memory.set(ram::wJoyIgnore, 0);
f.sample();
f.sample();
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0, "no open edge after a ready frame");
// START in front of someone: the menu's box is not the dialogue box, and `wSpriteIndex`
// still names the last person spoken to for the frames before the menu's own id arrives.
f.memory.set(ram::wSpriteIndex, 1);
f.sample();
f.memory.set(ram::wFontLoaded, 1);
for _ in 0..20 {
f.sample();
}
f.memory.set(ram::wSpriteIndex, 0);
f.sample();
f.memory.set(ram::wFontLoaded, 0);
assert_eq!(count_of_kind(&f.sample(), kind::TALK), 0, "the start menu is not a conversation");
// The argument never arrives inside the window: nothing is guessed.
f.memory.set(ram::wSpriteIndex, 0);
f.sample();
f.open_box(1, 60);
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0, "a second of frames, then no more");
// None of that was recorded: the person is still worth one conversation.
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
}
#[test]
fn a_conversation_that_ends_somewhere_else_pays_nothing() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
f.sample();
f.open_box(1, 20);
// The script warped the fly out while the box was up.
f.on_map(maps::PEWTER_CITY, 0);
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0);
// A rollback mid-conversation, likewise: the watch is transient.
f.on_map(maps::PEWTER_GYM, GYM);
f.sample();
f.open_box(1, 20);
f.reward.clear_transient();
assert_eq!(count_of_kind(&f.close_box(), kind::TALK), 0);
}
#[test]
fn an_item_ball_is_an_item_not_a_conversation() {
let mut f = Fixture::booted();
f.on_map(maps::OAKS_LAB, HOUSE);
f.visit(5, 5);
f.sprite(1, 5, 4, (0x14, 0));
f.face(FACING_UP);
assert!(f.talk_events(1).is_empty());
// A trainer is a person: `(class, number)`, and numbers start at one.
f.sprite(2, 4, 5, (0xcb, 2));
f.face(FACING_LEFT);
assert_eq!(kinds(&f.talk_events(2)), ["talk"]);
}
#[test]
fn a_clerk_across_a_counter_is_in_reach_only_where_the_tileset_has_counters() {
let mut f = Fixture::booted();
f.on_map(maps::VIRIDIAN_MART, MART);
f.visit(5, 5);
f.sprite(1, 5, 3, (0, 0));
f.face(FACING_UP);
assert!(f.talk_events(1).is_empty(), "two tiles away with no counter tiles in the header");
f.memory.set(ram::wTilesetTalkingOverTiles, 0x18);
assert_eq!(kinds(&f.talk_events(1)), ["talk"], "IsSpriteOrSignInFrontOfPlayer's long range");
}
#[test]
fn a_rollback_or_a_restore_cannot_replay_a_conversation() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
f.reward.clear_transient();
let state = f.reward.export_state();
assert!(
state["seen"]
.as_array()
.unwrap()
.contains(&json!(format!("talk:{}:sprite:1", maps::PEWTER_GYM))),
"the ledger is the checkpointed `seen` set, not the macros' session ledger"
);
let mut restored = PokemonRedReward::new();
restored.import_state(&state).unwrap();
f.reward = restored;
f.visit(5, 5);
assert!(f.talk_events(1).is_empty());
assert_eq!(f.reward.statistics().counts[kind::TALK], 1);
}
#[test]
fn an_item_ball_pays_once_when_its_bit_rises_and_never_again() {
let mut f = Fixture::booted();
f.on_map(maps::VIRIDIAN_FOREST, FOREST);
f.sprite(3, 5, 4, (0x14, 0)); // a Potion
f.toggle_list(&[(3, 0x2a)]);
f.visit(5, 5);
// PickUpItem: GiveItem, then HideObject sets the ball's global bit.
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
let events = f.item_events();
assert_eq!(labels(&events), ["FOUND ITEM #20"]);
assert_eq!(events[0].value, 0.15);
assert_eq!(events[0].stimulation_ms, 120);
assert!(f.item_events().is_empty(), "a bit that stays set pays once");
// A rollback to a slot where the ball is still there, and the fly takes it again.
f.reward.clear_transient();
f.set_bit(ram::wToggleableObjectFlags, 0x2a, false);
f.sample();
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
assert!(f.item_events().is_empty(), "once per item for the run");
assert_eq!(f.reward.statistics().counts[kind::ITEM], 1);
}
#[test]
fn only_an_item_balls_bit_pays_and_only_after_a_pickup() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_CITY, 0);
f.sprite(1, 2, 2, (0, 0)); // a person a script hides
f.sprite(2, 3, 3, (0xcb, 1)); // a trainer
f.sprite(4, 6, 6, (0x14, 0)); // a ball
f.toggle_list(&[(1, 0x03), (2, 0x04), (4, 0x05)]);
f.visit(5, 5);
f.set_bit(ram::wToggleableObjectFlags, 0x03, true);
f.set_bit(ram::wToggleableObjectFlags, 0x04, true);
assert!(f.item_events().is_empty(), "a hidden person or trainer is not an item");
// A bag too full to take the ball leaves the bit clear, and nothing pays.
assert!(f.item_events().is_empty());
f.set_bit(ram::wToggleableObjectFlags, 0x05, true);
assert_eq!(kinds(&f.item_events()), ["item"]);
}
#[test]
fn a_hidden_item_pays_once_per_index() {
let mut f = Fixture::booted();
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, true);
assert_eq!(labels(&f.item_events()), ["FOUND A HIDDEN ITEM"]);
assert!(f.item_events().is_empty());
f.set_bit(ram::wObtainedHiddenItemsFlags, 18, true);
assert_eq!(kinds(&f.item_events()), ["item"]);
f.reward.clear_transient();
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, false);
f.sample();
f.set_bit(ram::wObtainedHiddenItemsFlags, 17, true);
assert!(f.item_events().is_empty(), "a rollback cannot replay a hidden item");
}
#[test]
fn items_already_taken_are_seeded_and_the_two_script_shown_balls_are_not() {
let mut f = Fixture::new();
f.memory.set(ram::wCurMap, maps::REDS_HOUSE_2F);
f.set_bit(ram::wToggleableObjectFlags, 0x10, true);
f.set_bit(ram::wToggleableObjectFlags, 0x87, true);
f.set_bit(ram::wToggleableObjectFlags, 0x88, true);
f.set_bit(ram::wObtainedHiddenItemsFlags, 4, true);
assert!(f.item_events().is_empty(), "the seed pays nothing");
let seen = f.reward.export_state()["seen"].clone();
let seen: Vec<&str> = seen.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
for key in ["item:16", "hidden:4", "items:seeded"] {
assert!(seen.contains(&key), "{key}");
}
assert!(!seen.contains(&"item:135") && !seen.contains(&"item:136"));
// Giovanni beaten: the script shows the Silph Scope's ball (its bit clears), and the fly
// takes it.
f.on_map(0x8a, 22);
f.sprite(9, 25, 2, (0x48, 0));
f.toggle_list(&[(9, 0x87)]);
f.sample();
f.set_bit(ram::wToggleableObjectFlags, 0x87, false);
f.sample();
f.set_bit(ram::wToggleableObjectFlags, 0x87, true);
assert_eq!(kinds(&f.item_events()), ["item"]);
}
#[test]
fn boundary_pays_nothing_indoors_but_still_records_the_exit() {
let mut f = Fixture::booted();
f.on_map(maps::REDS_HOUSE_1F, 1);
f.warps(&[(4, 4)]);
assert!(boundary_values(&f.visit(3, 4)).is_empty(), "no payout beside an indoor door");
assert!(boundary_values(&f.visit(4, 4)).is_empty(), "nor on it");
assert!(
f.reward.exit_visited(MapExit::Warp { map: maps::REDS_HOUSE_1F, x: 4, y: 4 }),
"the ledger still knows the door, so the macros see what they always saw"
);
assert_eq!(f.reward.statistics().counts[kind::BOUNDARY], 0);
// Outdoors, in the forest and in a cave, exits pay exactly what they did.
let outdoors =
[(maps::PALLET_TOWN, 0), (maps::VIRIDIAN_FOREST, FOREST), (maps::MT_MOON_1F, CAVERN)];
for (map, tileset) in outdoors {
f.on_map(map, tileset);
assert_eq!(boundary_values(&f.visit(3, 4)), [0.05], "map {map}");
assert_eq!(boundary_values(&f.visit(4, 4)), [0.10], "map {map}");
}
}
#[test]
fn a_v6_state_restores_under_v7_with_empty_talk_and_seeded_item_ledgers() {
// A v6 run: some play, a pickup the v6 adapter did not pay for, and a v6 export -- which is
// a v7 export without any of the keys v7 writes.
let mut f = Fixture::booted();
f.on_map(maps::VIRIDIAN_FOREST, FOREST);
f.sprite(3, 5, 4, (0x14, 0));
f.toggle_list(&[(3, 0x2a)]);
f.visit(5, 5);
f.catch(0xb0, Some(3));
let mut v6 = f.reward.export_state();
let seen: Vec<Value> = v6["seen"]
.as_array()
.unwrap()
.iter()
.filter(|key| {
let key = key.as_str().unwrap();
!(key.starts_with("talk:") || key.starts_with("item") || key.starts_with("hidden:"))
})
.cloned()
.collect();
v6["seen"] = json!(seen);
v6["counts"].as_object_mut().unwrap().remove("talk");
v6["counts"].as_object_mut().unwrap().remove("item");
assert_eq!(v6["version"], json!(STATE_VERSION), "v6 and v7 share a schema version");
// Under v6 the fly took the ball.
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, true);
let mut restored = PokemonRedReward::new();
restored.import_state(&v6).unwrap();
assert_eq!(restored.statistics().counts[kind::TALK], 0);
assert_eq!(restored.statistics().counts[kind::ITEM], 0);
assert_eq!(restored.statistics().counts[kind::CATCH], 1, "nothing else moves");
f.reward = restored;
assert!(f.sample().is_empty(), "no retroactive payout for anything taken under v6");
// A rollback to a v6-era slot where the ball is still on the ground: taking it again is
// the same pickup, and it does not pay.
f.reward.clear_transient();
f.set_bit(ram::wToggleableObjectFlags, 0x2a, false);
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, false);
f.sample();
f.set_bit(ram::wToggleableObjectFlags, 0x2a, true);
f.set_bit(ram::wObtainedHiddenItemsFlags, 9, true);
assert!(f.item_events().is_empty());
// The talk ledger starts empty: the first conversation under v7 pays.
f.on_map(maps::PEWTER_GYM, GYM);
f.visit(5, 5);
f.sprite(1, 5, 4, (0, 0));
f.face(FACING_UP);
assert_eq!(kinds(&f.talk_events(1)), ["talk"]);
}
#[test]
fn a_warp_is_classified_by_the_header_still_loaded_while_the_map_id_has_moved_on() {
// `WarpFound2` writes the destination into `wCurMap` and then plays the map-change sound for
// thirty-odd frames before `EnterMap` loads the new header (`tests/rom_engage.rs` measured
// it at Route 2's gate door). On those frames the tileset and the warp table are still the
// map the fly is leaving, and the exit it is standing on is that map's.
let mut f = Fixture::booted();
f.on_map(maps::ROUTE_2, 0);
f.warps(&[(3, 11)]);
f.visit(3, 10);
// Town -> building: the id is the gate's, the header is still Route 2's, and Route 2's
// door is an outdoor exit, so its on-exit half pays as it always did.
f.memory.set(ram::wCurMap, 0x2f);
assert_eq!(boundary_values(&f.visit(3, 11)), [0.10]);
// Building -> town: the id is Pewter's, the header still the museum's, whose door is an
// indoor exit and pays nothing.
f.on_map(0x34, 10);
f.warps(&[(10, 7)]);
f.visit(10, 6);
f.memory.set(ram::wCurMap, maps::PEWTER_CITY);
assert!(boundary_values(&f.visit(10, 7)).is_empty());
}

View file

@ -15,10 +15,10 @@
use std::time::Instant; use std::time::Instant;
use flybrain_gb::adapter::MemoryReader; use flybrain_gb::adapter::{MapExit, MemoryReader};
use flybrain_gb::emulator::{AUDIO_SILENCE_LEVEL, CPU_TICKS_PER_SECOND}; use flybrain_gb::emulator::{AUDIO_SILENCE_LEVEL, CPU_TICKS_PER_SECOND};
use flybrain_gb::pokemon_red::symbols::ram; use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM}; use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM, engage};
use flybrain_gb::{ use flybrain_gb::{
DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, FRAMEBUFFER_LEN, GameAdapter, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, FRAMEBUFFER_LEN, GameAdapter,
buttons, buttons,
@ -444,7 +444,10 @@ fn read_u32(bytes: &[u8], offset: &mut usize) -> Option<u32> {
/// The boundary rule, against the cartridge rather than a synthetic trace. /// The boundary rule, against the cartridge rather than a synthetic trace.
/// ///
/// `docs/design/room-escape.md` section 2, Verification: "from the bedroom archive, the first /// `docs/design/room-escape.md` section 2, Verification: "from the bedroom archive, the first
/// payouts are boundary events near the stairs". Two things are checked here that no synthetic /// payouts are boundary events near the stairs". Since `pokered-unique8-v7` the bedroom is
/// *indoors* (`engage::indoor`: tileset `REDS_HOUSE_2`, neither outside nor bike-ridable), so
/// the same walk now proves the other half of the rule: the stairs enter the ledger where they
/// always did, and nothing is paid for them. Three things are checked here that no synthetic
/// WRAM trace can check: /// WRAM trace can check:
/// ///
/// 1. **the warp table layout.** `RedsHouse2F_Object` declares exactly one warp, /// 1. **the warp table layout.** `RedsHouse2F_Object` declares exactly one warp,
@ -453,9 +456,11 @@ fn read_u32(bytes: &[u8], offset: &mut usize) -> Option<u32> {
/// layout were X-then-Y, or the stride were not four, this assertion is what fails. /// layout were X-then-Y, or the stride were not four, this assertion is what fails.
/// 2. **that the rule fires at the right place.** The fly spawns at (3, 6) with the stairs at /// 2. **that the rule fires at the right place.** The fly spawns at (3, 6) with the stairs at
/// (7, 1), four tiles and five rows away, so the stairs are not baselined by the first /// (7, 1), four tiles and five rows away, so the stairs are not baselined by the first
/// playable sample and have to be walked to. /// playable sample and have to be walked to -- and the ledger records them from beside them.
/// 3. **that the cartridge calls the bedroom a building**: `wCurMapTileset` reads `REDS_HOUSE_2`
/// on the running game, so the rule pays nothing there.
#[test] #[test]
fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() { fn the_boundary_rule_records_the_bedroom_stairs_and_pays_nothing_indoors() {
let mut emulator = skip_without_rom!(boot()); let mut emulator = skip_without_rom!(boot());
assert_eq!(emulator.rom_sha256(), SUPPORTED_ROM, "FLY_ROM is not the pinned cartridge"); assert_eq!(emulator.rom_sha256(), SUPPORTED_ROM, "FLY_ROM is not the pinned cartridge");
let mut adapter = PokemonRedReward::new(); let mut adapter = PokemonRedReward::new();
@ -498,6 +503,8 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() {
0, 0,
"an indoor map has no connected edges" "an indoor map has no connected edges"
); );
assert_eq!(emulator.read_wram(ram::wCurMapTileset), 4, "REDS_HOUSE_2");
assert!(engage::indoor(4), "and the rule calls it a building");
let (spawn_x, spawn_y) = let (spawn_x, spawn_y) =
(emulator.read_wram(ram::wXCoord), emulator.read_wram(ram::wYCoord)); (emulator.read_wram(ram::wXCoord), emulator.read_wram(ram::wYCoord));
assert!( assert!(
@ -509,6 +516,7 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() {
let mut seed: u64 = 0x5eed_1234_5678_9abc; let mut seed: u64 = 0x5eed_1234_5678_9abc;
let mut boundary: Vec<(u8, u8, f64)> = Vec::new(); let mut boundary: Vec<(u8, u8, f64)> = Vec::new();
let mut kinds: Vec<&'static str> = Vec::new(); let mut kinds: Vec<&'static str> = Vec::new();
let mut found_at: Option<(u8, u8)> = None;
for frame in 0..24_000u32 { for frame in 0..24_000u32 {
seed = seed seed = seed
.wrapping_mul(6_364_136_223_846_793_005) .wrapping_mul(6_364_136_223_846_793_005)
@ -531,26 +539,19 @@ fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() {
boundary.push((x, y, event.value)); boundary.push((x, y, event.value));
} }
} }
if found_at.is_none() && adapter.exit_visited(MapExit::Warp { map: 0x26, x: 7, y: 1 }) {
found_at = Some((x, y));
}
if adapter.map_id() != Some(0x26) { if adapter.map_id() != Some(0x26) {
break; break;
} }
} }
eprintln!("boundary: payouts in the bedroom {boundary:?}, all kinds {kinds:?}"); eprintln!("boundary: payouts in the bedroom {boundary:?}, all kinds {kinds:?}");
let (x, y, value) = *boundary.first().expect("the stairs were never found"); let (x, y) = found_at.expect("the stairs were never found");
assert!( assert!(
x.abs_diff(7) + y.abs_diff(1) <= 1, x.abs_diff(7) + y.abs_diff(1) <= 1,
"the first boundary payout was at ({x}, {y}), not next to the stairs at (7, 1)" "the stairs entered the ledger at ({x}, {y}), not next to the stairs at (7, 1)"
); );
assert!( assert!(boundary.is_empty(), "an indoor exit pays nothing, got {boundary:?}");
value == 0.05 || value == 0.10,
"a boundary payout is the adjacent value or twice it, got {value}"
);
assert!(
boundary.len() <= 2,
"one warp can pay at most twice in a lifetime, got {boundary:?}"
);
for (x, y, _) in &boundary {
assert!(x.abs_diff(7) + y.abs_diff(1) <= 1, "payout away from the stairs at ({x}, {y})");
}
} }

View file

@ -14,8 +14,9 @@ Where this crate narrows or extends the draft, the difference is listed under
sections 2 to 11 is audited against this code, with the test that proves it, in sections 2 to 11 is audited against this code, with the test that proves it, in
`docs/design/session-framework/bus-conformance.md`. `docs/design/session-framework/bus-conformance.md`.
Nothing in the crate is specific to a game, a brain or a stream. It is a workspace member and Nothing in the crate is specific to a game, a brain or a stream. It is a workspace member;
no other crate depends on it yet. `flysim` embeds a router for the feed (`FLY_FEED_VIA=bus`, `flysim::feedbus`) and `fly-edge`
subscribes to it (`docs/design/flybus.md`, "Feed over the bus").
## Layout ## Layout

View file

@ -30,12 +30,14 @@ cuda = ["flybrain-core/cuda"]
[dependencies] [dependencies]
flybrain-core = { path = "../flybrain-core" } flybrain-core = { path = "../flybrain-core" }
flybrain-gb = { path = "../flybrain-gb" } flybrain-gb = { path = "../flybrain-gb" }
flybus = { path = "../flybus" }
anyhow = "1.0" anyhow = "1.0"
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2 = { workspace = true }
tokio = { version = "1", features = [ tokio = { version = "1", features = [
"rt-multi-thread", "rt-multi-thread",
"net", "net",

View file

@ -70,19 +70,20 @@ use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use flybrain_core::agent::{ use flybrain_core::agent::{
AgentConfig, NeuralAgent, RewardEvent as NeuralReward, TickOptions, AgentConfig, NeuralAgent,
}; };
use flybrain_core::dataset::load_brain_dataset_from_dir; use flybrain_core::dataset::load_brain_dataset_from_dir;
use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask}; use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
use flybrain_core::lif::SweepPlan; use flybrain_core::lif::SweepPlan;
use flybrain_gb::adapter::GameAdapter; use flybrain_gb::adapter::GameAdapter;
use flybrain_gb::pokemon_red::PokemonRedReward; use flybrain_gb::pokemon_red::PokemonRedReward;
use flybrain_gb::ratchet::Ratchet; use flybrain_gb::ratchet::Ratchet;
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons}; use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons};
use flysim::config::Config; use flysim::config::Config;
use flysim::frame::{Executed, FrameObserver, LegacyFrame, Parts};
use flysim::macros::{MacroLayer, OutcomeCounts, Silence, macro_layer}; use flysim::macros::{MacroLayer, OutcomeCounts, Silence, macro_layer};
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
use flysim::trace::FrameTrace;
/// `constants/map_constants.asm`: Red's bedroom, where a cold boot ends up. /// `constants/map_constants.asm`: Red's bedroom, where a cold boot ends up.
const REDS_HOUSE_2F: u32 = 0x26; const REDS_HOUSE_2F: u32 = 0x26;
@ -103,25 +104,12 @@ fn emulator(rom: &[u8]) -> Emulator {
.expect("binjgb should accept the cartridge") .expect("binjgb should accept the cartridge")
} }
/// The neural half of a ratchet recovery, exactly as `simloop.rs` wires it. /// Whether a macro owned the buttons of a frame: read as the executor hands the mask over.
struct AgentRecovery<'a> { struct MacroOwned(bool);
agent: &'a mut NeuralAgent,
}
impl NeuralRecovery for AgentRecovery<'_> { impl FrameObserver for MacroOwned {
fn clear_decoder_holds(&mut self) { fn executed(&mut self, _frame: &LegacyFrame, parts: &mut Parts<'_>, _executed: &Executed) {
let ms = self.agent.network.ms; self.0 = parts.macros.as_deref().is_some_and(|layer| layer.running().is_some());
self.agent.decoder.clear_holds(ms);
}
fn clear_eligibility(&mut self) {
let ms = self.agent.network.ms;
self.agent.network.plasticity.clear_eligibility(ms);
}
fn set_visual_frame(&mut self, frame: &[u8]) {
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
self.agent.network.set_visual_frame(frame, width, height);
} }
} }
@ -244,11 +232,9 @@ fn boot_to_bedroom(rom: &[u8]) -> Vec<u8> {
/// One arm: `hours` brain hours of the sim loop's frame order, unthrottled. /// One arm: `hours` brain hours of the sim loop's frame order, unthrottled.
/// ///
/// The order is `simloop.rs`'s (steps 2 to 10), as `NeuralAgent::tick` expresses it: the frame and /// The frame is `flysim::frame::LegacyFrame`, the one the stream runs, so the arm measures the
/// the payouts handed to a tick are the ones the previous tick's buttons produced. The macro layer /// wiring under test rather than a second implementation of it. (Before FND-01 the arms ticked the
/// is consulted at exactly the two points the loop consults it — after the decode, before the /// brain through `NeuralAgent::tick`, one frame behind the stream's order.)
/// buttons reach the emulator, and after the frame and its payouts — so the arm measures the
/// wiring under test rather than a second implementation of it.
fn run_arm( fn run_arm(
rom: &[u8], rom: &[u8],
data: &Arc<flybrain_core::dataset::BrainDataset>, data: &Arc<flybrain_core::dataset::BrainDataset>,
@ -270,48 +256,39 @@ fn run_arm(
.or(preset.exclusive.as_ref()) .or(preset.exclusive.as_ref())
.expect("the preset has a group") .expect("the preset has a group")
.hold_ms; .hold_ms;
let blocked_ms = preset.exclusive.as_ref().expect("the preset has an exclusive group").blocked_ms;
let mut agent_config = AgentConfig::with_decoder(preset); let mut agent_config = AgentConfig::with_decoder(preset);
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy()); let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
let mut arm = Arm { mode: mode.as_str(), ..Arm::default() }; let mut arm = Arm { mode: mode.as_str(), ..Arm::default() };
match start { if let Start::Fresh { warmup_ms, .. } = start {
Start::Fresh { state, warmup_ms } => { agent_config.warmup_ms = *warmup_ms;
emulator.import_state(state).expect("the booted state should import");
agent_config.warmup_ms = *warmup_ms;
}
Start::Live { checkpoint } => {
emulator
.import_state(&checkpoint.runtime.emulator)
.expect("the checkpoint's emulator state should import");
adapter
.import_state(&checkpoint.runtime.reward)
.expect("the checkpoint's reward ledger should import");
let snapshot = (!checkpoint.runtime.ratchet_game.is_empty()).then(|| {
flybrain_gb::ratchet::Snapshot {
game: checkpoint.runtime.ratchet_game.clone(),
frame: checkpoint.runtime.ratchet_frame.clone(),
}
});
ratchet
.import(Some(checkpoint.runtime.ratchet), snapshot, adapter.rank_ladder().len())
.expect("the checkpoint's ratchet state should import");
}
} }
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent"); let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
if threads > 1 { if threads > 1 {
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan")); agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
} }
let mut frame = LegacyFrame::new()
.with_trace(FrameTrace::from_env().expect("FLY_TRACE should name a writable file"));
match start { match start {
Start::Fresh { .. } => { Start::Fresh { state, warmup_ms: _ } => {
agent.warmup(Some(emulator.framebuffer())).expect("warm-up"); emulator.import_state(state).expect("the booted state should import");
} frame.frame_buffer.copy_from_slice(emulator.framebuffer());
Start::Live { checkpoint } => { agent.warmup(Some(&frame.frame_buffer)).expect("warm-up");
agent.import_state(&checkpoint.agent).expect("the checkpoint's agent should import");
let (width, height) = (agent.frame.width, agent.frame.height);
agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height);
} }
// The stream's own restore, into the stream's own frame.
Start::Live { checkpoint } => frame
.restore(
&mut Parts {
agent: &mut agent,
emulator: &mut emulator,
adapter: &mut adapter,
ratchet: &mut ratchet,
macros: None,
},
checkpoint,
)
.expect("the checkpoint should restore"),
} }
// The layer under test, built the way the sim loop builds it: from the configuration, so raw // The layer under test, built the way the sim loop builds it: from the configuration, so raw
@ -328,136 +305,62 @@ fn run_arm(
let began_ms = agent.network.ms; let began_ms = agent.network.ms;
let until = began_ms + hours * HOUR_MS; let until = began_ms + hours * HOUR_MS;
let mut frame = emulator.framebuffer().to_vec();
let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new();
let mut location = adapter.location();
let mut blocked_since_ms = began_ms;
let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string);
let mut rank = adapter.progress().rank; let mut rank = adapter.progress().rank;
let tiles_at_start = adapter.progress().unique_locations; let tiles_at_start = adapter.progress().unique_locations;
arm.rungs.push((rank, adapter.progress().rank_label, 0.0)); arm.rungs.push((rank, adapter.progress().rank_label, 0.0));
// The scene has not been observed yet, so the first frame is decided on an empty palette, // One observation before the first frame, as the sim loop takes after a restore, so frame one
// which presses nothing. That is one frame, and it is the honest starting state. // is decided on a real palette.
if let Some(layer) = macros.as_mut() { if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter); let ledger = AdapterLedger(&adapter);
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms); let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
} }
while agent.network.ms < until { while agent.network.ms < until {
let rewards: Vec<NeuralReward> = payouts let mut parts = Parts {
.iter() agent: &mut agent,
.map(|event| { emulator: &mut emulator,
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms)) adapter: &mut adapter,
}) ratchet: &mut ratchet,
.collect(); macros: macros.as_mut(),
let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true }; };
let mut owned = MacroOwned(false);
// The blocked-direction cooldown's input, as `simloop.rs` computes it. let transition = frame.transition(&mut parts, &mut owned).expect("a frame");
let ms = agent.network.ms; let ms = transition.ms;
let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms) let executed = &transition.executed;
.then(|| agent.decoder.current().map(str::to_string)) if let Some(layer) = parts.macros.as_deref() {
.flatten(); if let Some(silence) = executed.silence {
// The scene's own macro buttons, from the palette the previous frame's `observe` dealt:
// the same mask the sim loop passes (`docs/design/macros.md` section 12). `None` in the
// raw arm, which has no layer and no macro group at all.
let bound = macros.as_ref().map(MacroLayer::bound_channels);
let result = agent
.tick_bound(&frame, &options, blocked.as_deref(), bound.as_deref())
.expect("a tick");
let held = agent.decoder.current().map(str::to_string);
if held != held_channel {
held_channel = held;
blocked_since_ms = ms;
}
// Step 4, with the layer in the middle of it in macros mode and absent in raw mode.
let ms = agent.network.ms;
let mut mask = to_button_mask(&result.active);
if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter);
let decision = layer.decide(&result.active, mask, ms, &mut emulator, &ledger);
mask = decision.mask;
if let Some(silence) = decision.silence {
*arm.silence.entry(silence.label()).or_insert(0) += 1; *arm.silence.entry(silence.label()).or_insert(0) += 1;
} }
for event in decision.events.iter().filter(|event| event.outcome.is_none()) { for event in executed.events.iter().filter(|event| event.outcome.is_none()) {
*arm.by_rank.entry(event.slot).or_insert(0) += 1; *arm.by_rank.entry(event.slot).or_insert(0) += 1;
} }
if layer.running().is_some() { if owned.0 {
arm.macro_frames += 1; arm.macro_frames += 1;
} }
*arm.scenes.entry(layer.scene_name()).or_insert(0) += 1;
} }
if mask == 0 { if executed.mask == 0 {
arm.idle_frames += 1; arm.idle_frames += 1;
} }
emulator.set_buttons(mask as u8);
emulator.run_frame().expect("a frame should complete");
arm.frames += 1; arm.frames += 1;
frame.copy_from_slice(emulator.framebuffer()); for event in &transition.evaluated.rewards {
// Steps 7 to 9, then the scene.
payouts = adapter.sample(&mut emulator, ms);
for event in &payouts {
arm.reward += event.value; arm.reward += event.value;
*arm.payouts.entry(event.kind).or_insert(0) += 1; *arm.payouts.entry(event.kind).or_insert(0) += 1;
} }
if let Some(layer) = macros.as_mut() { arm.digest = hash(arm.digest, u64::from(executed.mask));
let ledger = AdapterLedger(&adapter); if let Some((map, x, y)) = frame.location {
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
*arm.scenes.entry(layer.scene_name()).or_insert(0) += 1;
}
let now = adapter.location();
if now.is_some() && now != location {
location = now;
blocked_since_ms = ms;
}
arm.digest = hash(arm.digest, u64::from(mask));
if let Some((map, x, y)) = location {
arm.digest = hash(arm.digest, u64::from(map) << 32 | u64::from(x) << 16 | u64::from(y)); arm.digest = hash(arm.digest, u64::from(map) << 32 | u64::from(x) << 16 | u64::from(y));
} }
// Step 10: the ratchet, with the adapter's own policy. let progress = transition.evaluated.progress;
let progress = adapter.progress();
if progress.rank != rank { if progress.rank != rank {
rank = progress.rank; rank = progress.rank;
arm.rungs.push((rank, progress.rank_label, ms - began_ms)); arm.rungs.push((rank, progress.rank_label, ms - began_ms));
} }
let safe = adapter.safe_for_snapshot(); let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best; if boundary.rollback.is_some() {
let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot {
game: emulator.export_state().expect("state export"),
frame: frame.clone(),
});
let recover = ratchet.observe_with_game_over(
safe,
u64::from(progress.rank),
progress.unique_locations as u64,
ms as u64,
adapter.game_over(),
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
);
if recover {
let snapshot = flybrain_gb::ratchet::Snapshot {
game: ratchet.game().expect("a recovery has a snapshot").to_vec(),
frame: ratchet.frame().expect("a recovery has a framebuffer").to_vec(),
};
let restored = {
let mut neural = AgentRecovery { agent: &mut agent };
recover_game(&mut emulator, &mut adapter, &mut neural, &snapshot)
.expect("recovering the game")
};
frame.copy_from_slice(&restored);
emulator.set_buttons(0);
arm.recoveries += 1; arm.recoveries += 1;
location = adapter.location();
held_channel = None;
blocked_since_ms = ms;
// The sim loop abandons a running macro on a rollback, and so does this.
if let Some(layer) = macros.as_mut() {
layer.cancel(ms);
}
} }
} }

View file

@ -77,7 +77,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use flybrain_core::agent::{ use flybrain_core::agent::{
AgentConfig, GAMEBOY_MS_PER_FRAME, NeuralAgent, RewardEvent as NeuralReward, TickOptions, AgentConfig, GAMEBOY_MS_PER_FRAME, NeuralAgent,
}; };
use flybrain_core::dataset::load_brain_dataset_from_dir; use flybrain_core::dataset::load_brain_dataset_from_dir;
use flybrain_core::decoder::gameboy::{GAMEBOY_BUTTONS, gameboy_decoder_config, to_button_mask}; use flybrain_core::decoder::gameboy::{GAMEBOY_BUTTONS, gameboy_decoder_config, to_button_mask};
@ -88,8 +88,8 @@ use flybrain_gb::adapter::{GameAdapter, MemoryReader};
use flybrain_gb::pokemon_red::symbols::ram; use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM}; use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM};
use flybrain_gb::ratchet::Ratchet; use flybrain_gb::ratchet::Ratchet;
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons}; use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, buttons};
use flysim::frame::{FrameObserver, FramePhase, LegacyFrame, Parts};
/// `constants/map_constants.asm`: Red's bedroom and the ground floor of his house. /// `constants/map_constants.asm`: Red's bedroom and the ground floor of his house.
const REDS_HOUSE_2F: u32 = 0x26; const REDS_HOUSE_2F: u32 = 0x26;
@ -496,28 +496,6 @@ fn print_table(title: &str, cells: &BTreeMap<(String, u64, u64, u64), Cell>, roo
// The real brain // The real brain
// ------------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------------
/// The neural half of a ratchet recovery, exactly as `flysim::simloop` wires it.
struct AgentRecovery<'a> {
agent: &'a mut NeuralAgent,
}
impl NeuralRecovery for AgentRecovery<'_> {
fn clear_decoder_holds(&mut self) {
let ms = self.agent.network.ms;
self.agent.decoder.clear_holds(ms);
}
fn clear_eligibility(&mut self) {
let ms = self.agent.network.ms;
self.agent.network.plasticity.clear_eligibility(ms);
}
fn set_visual_frame(&mut self, frame: &[u8]) {
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
self.agent.network.set_visual_frame(frame, width, height);
}
}
/// Where one brain run starts. /// Where one brain run starts.
enum Start<'a> { enum Start<'a> {
/// A save state and the map it stands in: a fresh fly, warmed up here. /// A save state and the map it stands in: a fresh fly, warmed up here.
@ -666,6 +644,97 @@ fn survey(rom: &[u8], state: &[u8]) -> Survey {
(reachable, exits) (reachable, exits)
} }
/// The room-escape instrumentation inside the stream's frame: the decoder as it stood before
/// the decode and after it, read at the one point between the two.
struct Escape<'a> {
/// The readout before this frame's decode.
before: Option<flybrain_core::decoder::DecoderState>,
hold_start: (Option<(u32, u32, u32)>, f64),
run_winner: Option<String>,
run_length: f64,
start_map: u32,
exits: &'a BTreeMap<(u32, u32), Vec<&'static str>>,
hold_ms: f64,
trace: Trace,
}
impl FrameObserver for Escape<'_> {
fn after(&mut self, phase: FramePhase, agent: &mut NeuralAgent) {
if phase == FramePhase::Ticked {
self.before = Some(agent.decoder.export_state());
}
}
fn before_execute(&mut self, frame: &LegacyFrame, parts: &mut Parts<'_>, _active: &[String]) {
let Some(before) = self.before.take() else { return };
let agent = &*parts.agent;
let after = agent.decoder.export_state();
if after.next_decision == before.next_decision {
return;
}
let ms = agent.network.ms;
let location = frame.location;
let trace = &mut self.trace;
trace.decisions += 1;
let winner = after.current.clone().expect("a decision names a winner");
// The raw argmax, recomputed from the decoder's own inputs: the rates the decode saw, the
// calibrated baseline, and the fatigue as it stood *before* the decision. Comparing it
// with the winner is what "the incumbent won by hysteresis" means.
let adjusted = |channel: &str| {
let index = DIRECTIONS.iter().position(|name| *name == channel).expect("a direction");
let role = ROLES[index];
let rate = agent.network.rates.get_or_zero(role);
let base = after.baseline.get_or_zero(role);
(rate + 1.0) / (base + 1.0) / (1.0 + before.fatigue.get_or_zero(channel))
};
let mut argmax = DIRECTIONS[0];
for channel in DIRECTIONS.iter().skip(1) {
if adjusted(channel) > adjusted(argmax) {
argmax = channel;
}
}
if argmax != winner {
trace.hysteresis_holds += 1;
}
if let Some(name) = DIRECTIONS.iter().find(|name| **name == winner) {
*trace.wins.entry(name).or_insert(0) += 1;
}
if self.run_winner.as_deref() == Some(winner.as_str()) {
self.run_length += 1.0;
} else {
if self.run_length > 0.0 {
trace.runs.push(self.run_length);
}
self.run_winner = Some(winner.clone());
self.run_length = 1.0;
}
// Was the hold that just ended a wall bump? The location now against the location at the
// previous decision, for the direction that was held in between.
if let (Some(previous), Some(held)) = (self.hold_start.0, before.current.as_deref())
&& ms - self.hold_start.1 >= self.hold_ms
&& location == Some(previous)
&& let Some(name) = DIRECTIONS.iter().find(|name| **name == held)
{
*trace.blocked_holds.entry(name).or_insert(0) += 1;
}
self.hold_start = (location, ms);
// The decision this whole exercise is about: standing on a tile one press from leaving,
// did the readout choose that press? Only on the starting map: the bedroom has walkable
// tiles at the same coordinates and they are not these exits.
if let Some(leaving) = location
.filter(|(map, _, _)| *map == self.start_map)
.and_then(|(_, x, y)| self.exits.get(&(x, y)))
{
trace.exit_decisions += 1;
if leaving.iter().any(|direction| *direction == winner) {
trace.exit_decisions_taken += 1;
}
}
}
}
/// One instrumented brain run: the real network, the real readout, the real adapter, and -- from a /// One instrumented brain run: the real network, the real readout, the real adapter, and -- from a
/// live checkpoint -- the real reward ledger and the real ratchet. /// live checkpoint -- the real reward ledger and the real ratchet.
/// ///
@ -697,174 +766,81 @@ fn brain_trace(
let mut emulator = emulator(rom); let mut emulator = emulator(rom);
let mut adapter = PokemonRedReward::new(); let mut adapter = PokemonRedReward::new();
let mut agent_config = AgentConfig::with_decoder(config.clone()); let mut agent_config = AgentConfig::with_decoder(config.clone());
let (hold_ms, blocked_ms) = { let hold_ms =
let group = config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group"); config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group").hold_ms;
(group.hold_ms, group.blocked_ms)
};
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy()); let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
let mut trace = Trace::default(); let mut trace = Trace::default();
if let Start::Fresh { warmup_ms, .. } = &start {
let start_map = match &start { agent_config.warmup_ms = *warmup_ms;
Start::Fresh { state, map, warmup_ms } => { }
emulator.import_state(state).expect("the starting state should import");
agent_config.warmup_ms = *warmup_ms;
*map
}
Start::Live { checkpoint, .. } => {
emulator
.import_state(&checkpoint.runtime.emulator)
.expect("the checkpoint's emulator state should import");
adapter
.import_state(&checkpoint.runtime.reward)
.expect("the checkpoint's reward ledger should import");
let snapshot = (!checkpoint.runtime.ratchet_game.is_empty()).then(|| {
flybrain_gb::ratchet::Snapshot {
game: checkpoint.runtime.ratchet_game.clone(),
frame: checkpoint.runtime.ratchet_frame.clone(),
}
});
ratchet
.import(Some(checkpoint.runtime.ratchet), snapshot, adapter.rank_ladder().len())
.expect("the checkpoint's ratchet state should import");
u32::from(emulator.read8(ram::wCurMap))
}
};
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent"); let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
if threads > 1 { if threads > 1 {
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan")); agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
} }
match &start { // The stream's own frame (`flysim::frame::LegacyFrame`), in raw mode: no macro layer.
Start::Fresh { .. } => agent.warmup(Some(emulator.framebuffer())).expect("warm-up"), let mut frame = LegacyFrame::new();
Start::Live { checkpoint, rng } => { let start_map = match &start {
let mut state = checkpoint.agent.clone(); Start::Fresh { state, map, .. } => {
state.network.rng = *rng; emulator.import_state(state).expect("the starting state should import");
agent.import_state(&state).expect("the checkpoint's agent state should import"); frame.frame_buffer.copy_from_slice(emulator.framebuffer());
let (width, height) = (agent.frame.width, agent.frame.height); agent.warmup(Some(&frame.frame_buffer)).expect("warm-up");
agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height); *map
} }
} Start::Live { checkpoint, rng } => {
let mut checkpoint = (*checkpoint).clone();
checkpoint.agent.network.rng = *rng;
frame
.restore(
&mut Parts {
agent: &mut agent,
emulator: &mut emulator,
adapter: &mut adapter,
ratchet: &mut ratchet,
macros: None,
},
&checkpoint,
)
.expect("the checkpoint should restore");
u32::from(emulator.read8(ram::wCurMap))
}
};
trace.maps.push(start_map); trace.maps.push(start_map);
let began_ms = agent.network.ms; let began_ms = agent.network.ms;
let until = began_ms + minutes * 60_000.0; let until = began_ms + minutes * 60_000.0;
// The sim loop's own order (`simloop.rs`, steps 2 to 10), as `NeuralAgent::tick` expresses it: let mut escape = Escape {
// the frame and the payouts handed to a tick are the ones the previous tick's buttons produced. before: None,
let mut frame = emulator.framebuffer().to_vec(); hold_start: (frame.location, began_ms),
let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new(); run_winner: None,
let mut location = adapter.location(); run_length: 0.0,
// The blocked-direction cooldown's window, restarted by a move *or* by a new winner, exactly start_map,
// as `simloop.rs` restarts it: a direction that has just won has not had a hold to move in yet. exits,
let mut blocked_since_ms = began_ms; hold_ms,
let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string); trace,
let mut hold_start = (location, began_ms); };
let mut run_winner: Option<String> = None;
let mut run_length = 0.0f64;
// Reported on its own: the longest stretch with no movement at all, whatever was held. // Reported on its own: the longest stretch with no movement at all, whatever was held.
let mut still_since_ms = began_ms; let mut still_since_ms = began_ms;
let tiles_at_start = adapter.progress().unique_locations; let tiles_at_start = adapter.progress().unique_locations;
trace.tiles = tiles_at_start; escape.trace.tiles = tiles_at_start;
while agent.network.ms < until { while agent.network.ms < until {
let rewards: Vec<NeuralReward> = payouts let mut parts = Parts {
.iter() agent: &mut agent,
.map(|event| { emulator: &mut emulator,
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms)) adapter: &mut adapter,
}) ratchet: &mut ratchet,
.collect(); macros: None,
let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true }; };
let location_before = frame.location;
let transition = frame.transition(&mut parts, &mut escape).expect("a frame");
let ms = transition.ms;
let trace = &mut escape.trace;
trace.reward += transition.evaluated.rewards.iter().map(|event| event.value).sum::<f64>();
trace.tiles = transition.evaluated.progress.unique_locations;
// The blocked-direction cooldown's input, computed the way `simloop.rs` computes it: the let location = frame.location;
// channel the readout is holding, once the adapter's location has stood still for a whole if location != location_before {
// hold. `blocked_ms == 0` is the rule switched off, and reports nothing.
let ms = agent.network.ms;
let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms)
.then(|| agent.decoder.current().map(str::to_string))
.flatten();
let before = agent.decoder.export_state();
let result = agent.tick_blocked(&frame, &options, blocked.as_deref()).expect("a tick");
let after = agent.decoder.export_state();
let held = agent.decoder.current().map(str::to_string);
if held != held_channel {
held_channel = held;
blocked_since_ms = ms;
}
if after.next_decision != before.next_decision {
trace.decisions += 1;
let winner = after.current.clone().expect("a decision names a winner");
// The raw argmax, recomputed from the decoder's own inputs: the rates the decode saw
// (`tick` decodes on the post-step rates and nothing changes them afterwards), the
// calibrated baseline, and the fatigue as it stood *before* the decision. Comparing it
// with the winner is what "the incumbent won by hysteresis" means.
let adjusted = |channel: &str| {
let index =
DIRECTIONS.iter().position(|name| *name == channel).expect("a direction");
let role = ROLES[index];
let rate = agent.network.rates.get_or_zero(role);
let base = after.baseline.get_or_zero(role);
(rate + 1.0) / (base + 1.0) / (1.0 + before.fatigue.get_or_zero(channel))
};
let mut argmax = DIRECTIONS[0];
for channel in DIRECTIONS.iter().skip(1) {
if adjusted(channel) > adjusted(argmax) {
argmax = channel;
}
}
if argmax != winner {
trace.hysteresis_holds += 1;
}
if let Some(name) = DIRECTIONS.iter().find(|name| **name == winner) {
*trace.wins.entry(name).or_insert(0) += 1;
}
if run_winner.as_deref() == Some(winner.as_str()) {
run_length += 1.0;
} else {
if run_length > 0.0 {
trace.runs.push(run_length);
}
run_winner = Some(winner.clone());
run_length = 1.0;
}
// Was the hold that just ended a wall bump? The location now against the location at
// the previous decision, for the direction that was held in between.
if let (Some(previous), Some(held)) = (hold_start.0, before.current.as_deref())
&& ms - hold_start.1 >= hold_ms
&& location == Some(previous)
&& let Some(name) = DIRECTIONS.iter().find(|name| **name == held)
{
*trace.blocked_holds.entry(name).or_insert(0) += 1;
}
hold_start = (location, ms);
// The decision this whole exercise is about: standing on a tile one press from
// leaving, did the readout choose that press? Only on the starting map: the bedroom
// has walkable tiles at the same coordinates and they are not these exits.
if let Some(leaving) = location
.filter(|(map, _, _)| *map == start_map)
.and_then(|(_, x, y)| exits.get(&(x, y)))
{
trace.exit_decisions += 1;
if leaving.iter().any(|direction| *direction == winner) {
trace.exit_decisions_taken += 1;
}
}
}
emulator.set_buttons(to_button_mask(&result.active) as u8);
emulator.run_frame().expect("a frame should complete");
frame.copy_from_slice(emulator.framebuffer());
let ms = agent.network.ms;
payouts = adapter.sample(&mut emulator, ms);
trace.reward += payouts.iter().map(|event| event.value).sum::<f64>();
trace.tiles = adapter.progress().unique_locations;
let now = adapter.location();
if now.is_some() && now != location {
location = now;
blocked_since_ms = ms;
still_since_ms = ms; still_since_ms = ms;
} }
trace.longest_still_ms = trace.longest_still_ms.max(ms - still_since_ms); trace.longest_still_ms = trace.longest_still_ms.max(ms - still_since_ms);
@ -876,40 +852,15 @@ fn brain_trace(
*trace.exit_tile_frames.entry(tile).or_insert(0) += 1; *trace.exit_tile_frames.entry(tile).or_insert(0) += 1;
} }
// Step 10: the ratchet, with the adapter's own policy and the checkpoint's own budget. // The ratchet, with the adapter's own policy and the checkpoint's own budget.
let progress = adapter.progress(); let progress = transition.evaluated.progress;
let safe = adapter.safe_for_snapshot(); let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best; if boundary.rollback.is_some() {
let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot { escape.trace.recoveries += 1;
game: emulator.export_state().expect("state export"),
frame: frame.clone(),
});
let recover = ratchet.observe(
safe,
u64::from(progress.rank),
progress.unique_locations as u64,
ms as u64,
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
);
if recover {
let snapshot = flybrain_gb::ratchet::Snapshot {
game: ratchet.game().expect("a recovery has a snapshot").to_vec(),
frame: ratchet.frame().expect("a recovery has a framebuffer").to_vec(),
};
let restored = {
let mut neural = AgentRecovery { agent: &mut agent };
recover_game(&mut emulator, &mut adapter, &mut neural, &snapshot)
.expect("recovering the game")
};
frame.copy_from_slice(&restored);
emulator.set_buttons(0);
trace.recoveries += 1;
location = adapter.location();
held_channel = None;
blocked_since_ms = ms;
still_since_ms = ms; still_since_ms = ms;
hold_start = (location, ms); escape.hold_start = (frame.location, ms);
} }
let trace = &mut escape.trace;
let Some(map) = adapter.map_id() else { continue }; let Some(map) = adapter.map_id() else { continue };
if trace.maps.last() != Some(&map) { if trace.maps.last() != Some(&map) {
@ -925,6 +876,7 @@ fn brain_trace(
} }
} }
} }
let Escape { mut trace, run_length, .. } = escape;
if run_length > 0.0 { if run_length > 0.0 {
trace.runs.push(run_length); trace.runs.push(run_length);
} }

View file

@ -189,11 +189,12 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
println!("- scene `{scene:?}`, player {player:?}, map {}x{}", size.width, size.height); println!("- scene `{scene:?}`, player {player:?}, map {}x{}", size.width, size.height);
println!("- objective: {:?}", state.objective()); println!("- objective: {:?}", state.objective());
if let Some(objective) = state.objective() { if let Some(objective) = state.objective() {
let from = palette::region_here(state)
.unwrap_or(geography::Region::whole(player.map));
println!( println!(
"- `next_hop({:?}, {:#04x})` = {:?}, neighbours {:?}", "- `next_step({from:?}, {:#04x})` = {:?}, neighbours {:?}",
geography::region_at(player.map, player.y),
objective.map, objective.map,
geography::next_hop(geography::region_at(player.map, player.y), objective.map), geography::next_step(from, objective.map),
geography::neighbours(player.map) geography::neighbours(player.map)
); );
} }
@ -808,6 +809,8 @@ fn accept_survey(
// [box not drawn, box drawn] x [press refused, press honoured], over every frame whose cursor // [box not drawn, box drawn] x [press refused, press honoured], over every frame whose cursor
// bytes say "the move list" -- which is the whole of what the seam read before row 50. // bytes say "the move list" -- which is the whole of what the seam read before row 50.
let mut readings = [[0usize; 2]; 2]; let mut readings = [[0usize; 2]; 2];
// The stream's frame (`flysim::frame::LegacyFrame`), behind the stub readout.
let mut legacy = flysim::frame::LegacyFrame::new();
println!("\n## Row 50: every battle frame, pressed at\n"); println!("\n## Row 50: every battle frame, pressed at\n");
println!("```"); println!("```");
@ -823,18 +826,9 @@ fn accept_survey(
} }
let bound = layer.bound_channels(); let bound = layer.bound_channels();
let active = decoder.decode_bound(&rates(hot), *ms, false, None, Some(&bound)); let active = decoder.decode_bound(&rates(hot), *ms, false, None, Some(&bound));
let mask = { legacy.execute(Some(&mut *layer), &active, 0, *ms, gb, &*adapter);
let ledger = AdapterLedger(adapter);
layer.decide(&active, 0, *ms, gb, &ledger).mask
};
gb.set_buttons(mask as u8);
gb.run_frame().expect("a frame should complete");
*ms += MS_PER_FRAME; *ms += MS_PER_FRAME;
adapter.sample(gb, *ms); legacy.stub_advance(Some(&mut *layer), gb, adapter, *ms).expect("a frame should complete");
{
let ledger = AdapterLedger(adapter);
let _ = layer.observe(gb, &ledger, *ms);
}
let Some((name, own_turn, forced)) = battle_reading(gb, adapter) else { continue }; let Some((name, own_turn, forced)) = battle_reading(gb, adapter) else { continue };
let geom = move_cursor_geometry(gb); let geom = move_cursor_geometry(gb);
@ -1353,11 +1347,16 @@ fn dialog_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64
/// (comma-separated names) presses those buttons whenever they are dealt. /// (comma-separated names) presses those buttons whenever they are dealt.
/// ///
/// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette /// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette
fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) { fn route_survey(
gb: &mut Emulator,
adapter: &mut PokemonRedReward,
ms: &mut f64,
checkpoint: &flysim::store::Checkpoint,
) {
use flybrain_gb::MacroPalette; use flybrain_gb::MacroPalette;
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, TargetKey}; use flybrain_gb::pokemon_red::macros::cartridge::{FACINGS, MacroState, TalkTarget, TargetKey};
use flybrain_gb::pokemon_red::macros::path::Way; use flybrain_gb::pokemon_red::macros::path::Way;
use flybrain_gb::pokemon_red::macros::{PokemonPalette, palette, path}; use flybrain_gb::pokemon_red::macros::{PokemonPalette, Tile, palette, path};
let budget = env_usize("FLY_PROBE_FRAMES", 240_000); let budget = env_usize("FLY_PROBE_FRAMES", 240_000);
let mut rng = env_usize("FLY_PROBE_RNG", 20_260_923) as u32 | 1; let mut rng = env_usize("FLY_PROBE_RNG", 20_260_923) as u32 | 1;
@ -1379,6 +1378,21 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
let mut outcomes: BTreeMap<String, u64> = BTreeMap::new(); let mut outcomes: BTreeMap<String, u64> = BTreeMap::new();
let mut single_refusals = 0u32; let mut single_refusals = 0u32;
let mut caught_at: Option<usize> = None; let mut caught_at: Option<usize> = None;
// `FLY_PROBE_CATCH_MAP=54` with `FLY_PROBE_CATCH_ENTRIES=4` reads the frame the fly is given
// the buttons back on its fourth arrival on map 54 (row 58: the pad in and out of one door).
let catch_map: Option<u8> =
std::env::var("FLY_PROBE_CATCH_MAP").ok().and_then(|value| value.parse().ok());
let catch_entries = env_usize("FLY_PROBE_CATCH_ENTRIES", 4);
let mut entries = 0usize;
let mut arrived_at = 0usize;
let mut last_map: Option<u8> = None;
let save_rank: Option<u32> =
std::env::var("FLY_PROBE_SAVE_RANK").ok().and_then(|value| value.parse().ok());
let mut saved = false;
// `FLY_PROBE_CATCH_FRAME=41600` reads the first frame at or after that one on which the fly
// has the buttons and no macro is running (row 61: the frame a walk in the forest refused).
let catch_frame: Option<usize> =
std::env::var("FLY_PROBE_CATCH_FRAME").ok().and_then(|value| value.parse().ok());
// `FLY_PROBE_HOLD=right:96,up:32` holds raw directions first and prints where the fly is // `FLY_PROBE_HOLD=right:96,up:32` holds raw directions first and prints where the fly is
// every eight frames: what the cartridge does with a press, before any macro is asked. // every eight frames: what the cartridge does with a press, before any macro is asked.
@ -1426,6 +1440,9 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
let pad = format!("{:?} {names:?}", observed.scene); let pad = format!("{:?} {names:?}", observed.scene);
if pad != last_pad { if pad != last_pad {
println!("f{frame:<6} {:?} pad {pad}", player.map(|p| (p.map, p.x, p.y))); println!("f{frame:<6} {:?} pad {pad}", player.map(|p| (p.map, p.x, p.y)));
if let Some(line) = battle_line(gb) {
println!(" {line}");
}
last_pad = pad; last_pad = pad;
} }
let mut mask = 0u8; let mut mask = 0u8;
@ -1476,7 +1493,12 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
} }
} }
if let Some((name, outcome)) = macros.take_finished() { if let Some((name, outcome)) = macros.take_finished() {
*outcomes.entry(format!("{name} {outcome:?}")).or_default() += 1; *outcomes
.entry(format!(
"{name} {outcome:?} on {:?}",
state::player(gb).map(|p| p.map)
))
.or_default() += 1;
if !matches!(outcome, flybrain_gb::Outcome::Done) { if !matches!(outcome, flybrain_gb::Outcome::Done) {
println!( println!(
"f{frame:<6} {:?} {name} {outcome:?}", "f{frame:<6} {:?} {name} {outcome:?}",
@ -1487,23 +1509,78 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
since_decision += 1; since_decision += 1;
if frame < trace_frames { if frame < trace_frames {
println!( println!(
" t{frame:<5} {:?} mask {mask:#04x} running {:?} marks {:?} stood {}", " t{frame:<5} {:?} mask {mask:#04x} running {:?} marks {:?} stood {} | {}",
state::player(gb).map(|p| (p.map, p.x, p.y, p.facing)), state::player(gb).map(|p| (p.map, p.x, p.y, p.facing)),
macros.running(), macros.running(),
macros.fences().1, macros.fences().1,
macros.stood() macros.stood(),
scene::why_unknown(gb)
); );
} }
gb.set_buttons(mask); gb.set_buttons(mask);
gb.run_frame().expect("a frame should complete"); gb.run_frame().expect("a frame should complete");
*ms += MS_PER_FRAME; *ms += MS_PER_FRAME;
adapter.sample(gb, *ms); adapter.sample(gb, *ms);
// `FLY_PROBE_SAVE_RANK=11` with `FLY_PROBE_SAVE` writes the first frame at or past that
// rung where the fly has the buttons in the overworld and no macro is running: a state the
// stream could be restored into next (row 59, after the badge), carried forward by the
// survey from the source checkpoint. The session ledgers are not in it, as they are in no
// checkpoint.
if let Some(want) = save_rank
&& !saved
&& adapter.progress().rank >= want
&& !running
&& matches!(observed.scene, flybrain_gb::SceneId::Overworld)
&& state::controllable(gb)
&& let Some(path) = std::env::var_os("FLY_PROBE_SAVE")
{
saved = true;
let bytes = save_state(checkpoint, gb, adapter, frame, &path);
println!(
"f{frame:<6} {:?} saved rank {} to `{}` ({bytes} bytes)",
state::player(gb).map(|p| (p.map, p.x, p.y)),
adapter.progress().rank,
std::path::Path::new(&path).display()
);
}
if single_refusals >= catch_after { if single_refusals >= catch_after {
caught_at = Some(frame); caught_at = Some(frame);
break; break;
} }
if catch_frame.is_some_and(|at| frame >= at)
&& !running
&& matches!(observed.scene, flybrain_gb::SceneId::Overworld)
&& !observed.bindings.is_empty()
{
caught_at = Some(frame);
break;
}
if let (Some(want), Some(player)) = (catch_map, player) {
if player.map == want && last_map != Some(want) {
entries += 1;
arrived_at = frame;
}
last_map = Some(player.map);
// Forty frames in: the first frames on a new map byte still carry the old map's
// warps (the tear 12.16 names), and a reading there says nothing about the room.
if player.map == want
&& entries >= catch_entries
&& frame >= arrived_at + 40
&& !running
&& matches!(observed.scene, flybrain_gb::SceneId::Overworld)
&& !observed.bindings.is_empty()
{
caught_at = Some(frame);
break;
}
}
} }
println!("```\n"); println!("```\n");
let progress = adapter.progress();
println!(
"- at the end: rank {} ({}), badges {}, unique tiles {}",
progress.rank, progress.rank_label, progress.counter, progress.unique_locations
);
println!("- refusals: {refusals:?}"); println!("- refusals: {refusals:?}");
println!("- outcomes: {outcomes:?}"); println!("- outcomes: {outcomes:?}");
let Some(frame) = caught_at else { let Some(frame) = caught_at else {
@ -1512,8 +1589,15 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
return; return;
}; };
println!( println!(
"\n## Caught on frame {frame} ({:.1} brain minutes): one button, refused twenty holds running\n", "\n## Caught on frame {frame} ({:.1} brain minutes): {}\n",
frame as f64 * MS_PER_FRAME / 60_000.0 frame as f64 * MS_PER_FRAME / 60_000.0,
if single_refusals >= catch_after {
"one button, refused twenty holds running".to_string()
} else if catch_frame.is_some_and(|at| frame >= at) {
format!("the first free frame at or after {catch_frame:?}")
} else {
format!("arrival {entries} on map {catch_map:?}")
}
); );
let (pushed, frontiers) = macros.fences(); let (pushed, frontiers) = macros.fences();
println!("- pushed tiles (no window): {pushed:?}"); println!("- pushed tiles (no window): {pushed:?}");
@ -1525,6 +1609,18 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
println!("- objective: {:?}", state.objective()); println!("- objective: {:?}", state.objective());
println!("- `objective_goals`: {:?}", palette::objective_goals(state)); println!("- `objective_goals`: {:?}", palette::objective_goals(state));
println!("- `objective_targets`: {:?}", palette::objective_targets(state)); println!("- `objective_targets`: {:?}", palette::objective_targets(state));
let drawn = path::person_targets(state);
for (tile, target) in drawn.iter().copied().chain(path::offscreen_person_targets(state)) {
println!(
" - person {target:?} at ({:2},{:2}) {}: talked {}, blocked {}, reached {}",
tile.x,
tile.y,
if drawn.contains(&(tile, target)) { "drawn" } else { "off the screen" },
state.talked(target),
state.blocked(TargetKey::Thing(target)),
state.reached(TargetKey::Thing(target))
);
}
println!("- `untalked_people`: {:?}", palette::untalked_people(state)); println!("- `untalked_people`: {:?}", palette::untalked_people(state));
println!("- `untalked_objects`: {:?}", palette::untalked_objects(state)); println!("- `untalked_objects`: {:?}", palette::untalked_objects(state));
println!("- `facing_untalked`: {}", palette::facing_untalked(state)); println!("- `facing_untalked`: {}", palette::facing_untalked(state));
@ -1549,16 +1645,46 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
reach reach
); );
} }
println!("\n### The fly's own neighbourhood (pushed = `P`, player = `@`)\n\n```"); // A room small enough to print whole is printed whole, with its people on it (row 58:
for y in player.y.saturating_sub(3)..=player.y.saturating_add(3) { // the gym's leader is twelve rows from the door).
let row: String = (player.x.saturating_sub(6)..=player.x.saturating_add(6)) let size = state.map_size().expect("a loaded map");
// `FLY_PROBE_WHOLE=1` prints a bigger map whole too (row 61: the forest is 34 by 48).
let whole = (size.width <= 24 && size.height <= 24)
|| std::env::var("FLY_PROBE_WHOLE").is_ok_and(|value| value == "1");
let people: Vec<(Tile, TalkTarget)> = path::person_targets(state)
.into_iter()
.chain(path::offscreen_person_targets(state))
.collect();
let (rows, columns) = if whole {
(0..=size.height - 1, 0..=size.width - 1)
} else {
(
player.y.saturating_sub(3)..=player.y.saturating_add(3),
player.x.saturating_sub(6)..=player.x.saturating_add(6),
)
};
let grid = state.map_grid();
println!(
"\n### The fly's {} (pushed = `P`, player = `@`, a person = `N`; grid {})\n\n```",
if whole { "whole map" } else { "own neighbourhood" },
grid.is_some()
);
for y in rows {
let row: String = columns
.clone()
.map(|x| { .map(|x| {
let walk = match grid.as_deref() {
Some(grid) => grid.walkable(x, y),
None => state.walkable(x, y),
};
if x == player.x && y == player.y { if x == player.x && y == player.y {
'@' '@'
} else if people.iter().any(|(tile, _)| *tile == Tile::new(x, y)) {
'N'
} else if state.pushed_tile(x, y) { } else if state.pushed_tile(x, y) {
'P' 'P'
} else { } else {
match state.walkable(x, y) { match walk {
flybrain_gb::pokemon_red::macros::state::Walkable::Yes => '.', flybrain_gb::pokemon_red::macros::state::Walkable::Yes => '.',
flybrain_gb::pokemon_red::macros::state::Walkable::No => '#', flybrain_gb::pokemon_red::macros::state::Walkable::No => '#',
flybrain_gb::pokemon_red::macros::state::Walkable::Unknown => '?', flybrain_gb::pokemon_red::macros::state::Walkable::Unknown => '?',
@ -1566,9 +1692,14 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
} }
}) })
.collect(); .collect();
println!("y{y:2} x{:2}.. {row}", player.x.saturating_sub(6)); println!("y{y:2} x{:2}.. {row}", if whole { 0 } else { player.x.saturating_sub(6) });
} }
println!("```"); println!("```");
for (tile, target) in &people {
let aims: Vec<Tile> = FACINGS.iter().filter_map(|facing| tile.step(*facing)).collect();
let reach = path::route(state, &aims).map(|route| route.goal);
println!("- a route to {target:?} at ({:2},{:2}): {reach:?}", tile.x, tile.y);
}
}); });
} }
@ -1589,6 +1720,28 @@ fn separator_table(classes: &BTreeMap<(bool, bool, bool), u64>) -> String {
out out
} }
/// This state as a `FLYSIM01` checkpoint: the agent half is the source checkpoint's, unchanged --
/// the release box's own run carried forward by the stub, not a synthesised save -- and `.local/`
/// is not tracked, exactly as every other checkpoint in this workspace. Returns the size written.
fn save_state(
checkpoint: &flysim::store::Checkpoint,
gb: &mut Emulator,
adapter: &PokemonRedReward,
frame: usize,
path: &std::ffi::OsStr,
) -> usize {
let mut runtime = checkpoint.runtime.clone();
runtime.emulator = gb.export_state().expect("the emulator should export");
runtime.reward = adapter.export_state();
runtime.framebuffer = gb.framebuffer().to_vec();
runtime.emulator_frame = frame as u64;
let bytes =
flysim::store::encode(&checkpoint.agent, &runtime).expect("the envelope should encode");
flysim::store::write_atomic(std::path::Path::new(path), &bytes)
.expect("the checkpoint should be writable");
bytes.len()
}
fn main() { fn main() {
let Some(path) = std::env::var_os("FLY_ROM") else { let Some(path) = std::env::var_os("FLY_ROM") else {
println!("FLY_ROM is not set, so there is nothing to probe."); println!("FLY_ROM is not set, so there is nothing to probe.");
@ -1680,7 +1833,7 @@ fn main() {
// Row 57's pad survey: earn the session's ledgers from the checkpoint with the real palette, // Row 57's pad survey: earn the session's ledgers from the checkpoint with the real palette,
// and read the frame the pad comes down to one refusing button on. // and read the frame the pad comes down to one refusing button on.
if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "route") { if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "route") {
route_survey(&mut gb, &mut adapter, &mut ms); route_survey(&mut gb, &mut adapter, &mut ms, &checkpoint);
return; return;
} }
@ -1710,6 +1863,8 @@ fn main() {
let mut noattack = 0usize; let mut noattack = 0usize;
let mut before = (0u8, 0u8, 0u8); let mut before = (0u8, 0u8, 0u8);
let mut surveyed = 0usize; let mut surveyed = 0usize;
// The stream's frame (`flysim::frame::LegacyFrame`), behind the stub readout.
let mut legacy = flysim::frame::LegacyFrame::new();
for frame in 0..budget { for frame in 0..budget {
let bursting = ms < next_burst + BURST_MS; let bursting = ms < next_burst + BURST_MS;
let hot = bursting.then(|| channels[(burst / HOLDS_PER_SLOT) % channels.len()]); let hot = bursting.then(|| channels[(burst / HOLDS_PER_SLOT) % channels.len()]);
@ -1719,18 +1874,11 @@ fn main() {
} }
let bound = layer.bound_channels(); let bound = layer.bound_channels();
let active = decoder.decode_bound(&rates(hot), ms, false, None, Some(&bound)); let active = decoder.decode_bound(&rates(hot), ms, false, None, Some(&bound));
let mask = { legacy.execute(Some(&mut layer), &active, 0, ms, &mut gb, &adapter);
let ledger = AdapterLedger(&adapter);
layer.decide(&active, 0, ms, &mut gb, &ledger).mask
};
gb.set_buttons(mask as u8);
gb.run_frame().expect("a frame should complete");
ms += MS_PER_FRAME; ms += MS_PER_FRAME;
adapter.sample(&mut gb, ms); legacy
{ .stub_advance(Some(&mut layer), &mut gb, &mut adapter, ms)
let ledger = AdapterLedger(&adapter); .expect("a frame should complete");
let _ = layer.observe(&mut gb, &ledger, ms);
}
if catch_script if catch_script
&& gb.read8(ram::wSimulatedJoypadStatesIndex) != 0 && gb.read8(ram::wSimulatedJoypadStatesIndex) != 0
&& gb.read8(ram::wCurMap) == 1 && gb.read8(ram::wCurMap) == 1
@ -1798,19 +1946,10 @@ fn main() {
// release box's own run carried forward by the stub, not a synthesised save -- and // release box's own run carried forward by the stub, not a synthesised save -- and
// `.local/` is not tracked, exactly as every other checkpoint in this workspace. // `.local/` is not tracked, exactly as every other checkpoint in this workspace.
if let Some(save) = std::env::var_os("FLY_PROBE_SAVE") { if let Some(save) = std::env::var_os("FLY_PROBE_SAVE") {
let mut runtime = checkpoint.runtime.clone(); let bytes = save_state(&checkpoint, &mut gb, &adapter, frame, &save);
runtime.emulator = gb.export_state().expect("the emulator should export");
runtime.reward = adapter.export_state();
runtime.framebuffer = gb.framebuffer().to_vec();
runtime.emulator_frame = frame as u64;
let bytes = flysim::store::encode(&checkpoint.agent, &runtime)
.expect("the envelope should encode");
flysim::store::write_atomic(std::path::Path::new(&save), &bytes)
.expect("the checkpoint should be writable");
println!( println!(
"\nWrote this state to `{}` ({} bytes).", "\nWrote this state to `{}` ({bytes} bytes).",
std::path::Path::new(&save).display(), std::path::Path::new(&save).display()
bytes.len()
); );
} }
// The survey method on the one question the fix turns on: **what does the // The survey method on the one question the fix turns on: **what does the
@ -1976,3 +2115,36 @@ fn main() {
), ),
} }
} }
/// Row 60: the battle bytes a `MOVE n` button's effect rests on, in one line -- the fly's moves
/// with PP and whether the cartridge would answer each with nothing, both sides' stat stages
/// (7 is normal, 1 is -6), the enemy's stats, status and HP.
fn battle_line(gb: &mut Emulator) -> Option<String> {
let battle = state::battle(gb)?;
let own = battle.own?;
let moves: Vec<String> = own
.moves
.iter()
.flatten()
.map(|entry| {
let nothing = state::move_without_effect(gb, entry.id);
let row = state::move_data(gb, entry.id).map(|data| (data.effect, data.power));
format!("{:#04x} pp{} row{row:?} nothing={nothing:?}", entry.id, entry.pp)
})
.collect();
let stages = |gb: &mut Emulator, base: u16| -> Vec<u8> { (0..6).map(|i| gb.read8(base + i)).collect() };
let own_stages = stages(gb, ram::wPlayerMonStatMods);
let enemy_stages = stages(gb, ram::wEnemyMonStatMods);
let enemy_stats: Vec<u16> = (0..4)
.map(|i| u16::from(gb.read8(ram::wEnemyMonAttack + 2 * i)) * 256 + u16::from(gb.read8(ram::wEnemyMonAttack + 2 * i + 1)))
.collect();
Some(format!(
"menu={:?} own hp {}/{} moves [{}] stages {own_stages:?} | enemy {:?} stages {enemy_stages:?} stats {enemy_stats:?} status {:#04x}",
battle.menu,
own.hp,
own.max_hp,
moves.join(", "),
battle.enemy.map(|enemy| (enemy.species, enemy.level, enemy.hp, enemy.max_hp)),
gb.read8(ram::wEnemyMonStatus),
))
}

View file

@ -36,9 +36,11 @@
//! | `FLY_TRAP_THREADS` | 4 | sweep threads | //! | `FLY_TRAP_THREADS` | 4 | sweep threads |
//! | `FLY_TRAP_SEED` | 20260917 | seeds the palette | //! | `FLY_TRAP_SEED` | 20260917 | seeds the palette |
//! | `FLY_TRAP_SEED_*` | unset | rebuilds session ledgers a restore starts empty: `PUSHED`, `EXHAUSTED`, `TALKED`, `BLOCKED`, `STOOD` (`examples/support/ledgers.rs`, row 57) | //! | `FLY_TRAP_SEED_*` | unset | rebuilds session ledgers a restore starts empty: `PUSHED`, `EXHAUSTED`, `TALKED`, `BLOCKED`, `STOOD` (`examples/support/ledgers.rs`, row 57) |
//! | `FLY_TRACE` | unset | a path: the stream's per-frame trace of this run (`flysim::trace`), comparable with the service's own |
//! //!
//! The frame order is `simloop.rs`'s, as `examples/palette_bench.rs` expresses it, so what this //! The frame is `flysim::frame::LegacyFrame`, the one the stream runs, restored the way the stream
//! measures is the loop that ships rather than a second implementation of it. Without a //! restores it, so what this measures is the loop that ships rather than a second implementation
//! of it; `FLY_TRACE` records it in the stream's own trace format. Without a
//! checkpoint it refuses rather than booting the intro: a trap hunt is about a state the stream //! checkpoint it refuses rather than booting the intro: a trap hunt is about a state the stream
//! was actually in. //! was actually in.
@ -46,18 +48,19 @@ use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use flybrain_core::agent::{AgentConfig, NeuralAgent, RewardEvent as NeuralReward, TickOptions}; use flybrain_core::agent::{AgentConfig, NeuralAgent};
use flybrain_core::dataset::load_brain_dataset_from_dir; use flybrain_core::dataset::load_brain_dataset_from_dir;
use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask}; use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
use flybrain_core::lif::SweepPlan; use flybrain_core::lif::SweepPlan;
use flybrain_gb::adapter::GameAdapter; use flybrain_gb::adapter::GameAdapter;
use flybrain_gb::pokemon_red::PokemonRedReward; use flybrain_gb::pokemon_red::PokemonRedReward;
use flybrain_gb::ratchet::Ratchet; use flybrain_gb::ratchet::Ratchet;
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator}; use flybrain_gb::{AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator};
use flysim::config::Config; use flysim::config::Config;
use flysim::frame::{Executed, FrameObserver, LegacyFrame, Parts};
use flysim::macros::{MacroLayer, macro_layer}; use flysim::macros::{MacroLayer, macro_layer};
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
use flysim::trace::FrameTrace;
#[path = "support/ledgers.rs"] #[path = "support/ledgers.rs"]
mod ledgers; mod ledgers;
@ -154,28 +157,6 @@ fn env_usize(name: &str, default: usize) -> usize {
std::env::var(name).ok().and_then(|value| value.parse().ok()).unwrap_or(default) std::env::var(name).ok().and_then(|value| value.parse().ok()).unwrap_or(default)
} }
/// The neural half of a ratchet recovery, exactly as `simloop.rs` wires it.
struct AgentRecovery<'a> {
agent: &'a mut NeuralAgent,
}
impl NeuralRecovery for AgentRecovery<'_> {
fn clear_decoder_holds(&mut self) {
let ms = self.agent.network.ms;
self.agent.decoder.clear_holds(ms);
}
fn clear_eligibility(&mut self) {
let ms = self.agent.network.ms;
self.agent.network.plasticity.clear_eligibility(ms);
}
fn set_visual_frame(&mut self, frame: &[u8]) {
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
self.agent.network.set_visual_frame(frame, width, height);
}
}
/// Where the fly stood on one frame, and what it started on it. /// Where the fly stood on one frame, and what it started on it.
/// A refused macro and the `(map, x, y)` it was refused on. /// A refused macro and the `(map, x, y)` it was refused on.
type RefusedAt = (&'static str, Option<(u32, u32, u32)>); type RefusedAt = (&'static str, Option<(u32, u32, u32)>);
@ -247,6 +228,12 @@ struct Trace {
/// The pad's composition, measured rather than read off the table: "which sub-state offers /// The pad's composition, measured rather than read off the table: "which sub-state offers
/// `BACK`" is a question about the build under test and not about the document. /// `BACK`" is a question about the build under test and not about the document.
battle_pads: BTreeMap<&'static str, BTreeSet<String>>, battle_pads: BTreeMap<&'static str, BTreeSet<String>>,
/// Row 60: every battle as `(frames, macro starts, won)`, the one running, payouts by kind,
/// and `MOVE n` starts against how many chose a move the cartridge answers with nothing.
battles: Vec<(u64, u64, bool)>,
battle_now: Option<(u64, u64, bool)>,
payouts_by_kind: BTreeMap<&'static str, (u64, f64)>,
move_starts: (u64, u64),
wall_seconds: f64, wall_seconds: f64,
} }
@ -306,6 +293,161 @@ struct Trap {
macros: usize, macros: usize,
} }
/// The hunt's look inside the stream's frame (`flysim::frame`): what it reads before and after
/// the executor decides, and the stub readout. Everything else is the frame's own order.
struct Hunt {
stub: bool,
stub_hold: usize,
stub_next_ms: f64,
hold_ms: f64,
running: Option<Running>,
scene_run: (&'static str, u64, f64),
/// Read before `decide`, because `decide` is what starts the macro whose scene this is.
dialog_map: Option<u8>,
battle_sub: Option<&'static str>,
trace: Trace,
}
impl FrameObserver for Hunt {
/// The brain is still ticked -- the frame order, the plasticity and the cost are the run's --
/// and only the *readout* is replaced, so a stub run and a brain run differ in who chooses and
/// in nothing else.
fn readout(&mut self, ms: f64, bound: Option<&[String]>, active: &mut Vec<String>) {
if !self.stub {
return;
}
let hot = STUB_CHANNELS[(self.stub_hold / STUB_HOLDS_PER_CHANNEL) % STUB_CHANNELS.len()];
if ms >= self.stub_next_ms {
self.stub_next_ms = ms + self.hold_ms;
self.stub_hold += 1;
}
*active = bound.unwrap_or_default().iter().filter(|channel| *channel == hot).cloned().collect();
}
fn before_execute(&mut self, _frame: &LegacyFrame, parts: &mut Parts<'_>, _active: &[String]) {
let layer_scene = parts.macros.as_deref().map_or("", MacroLayer::scene_name);
self.dialog_map = (layer_scene == "dialog" || layer_scene == "unknown")
.then(|| flybrain_gb::pokemon_red::state::player(parts.emulator).map(|p| p.map))
.flatten();
self.battle_sub = battle_sub_state(parts.emulator);
if let Some(sub) = self.battle_sub {
*self.trace.battle_frames.entry(sub).or_insert(0) += 1;
let pad = self.trace.battle_pads.entry(sub).or_default();
for channel in parts.macros.as_deref().map(MacroLayer::bound_channels).unwrap_or_default() {
pad.insert(channel);
}
}
}
fn executed(&mut self, frame: &LegacyFrame, parts: &mut Parts<'_>, executed: &Executed) {
let ms = parts.agent.network.ms;
let location = frame.location;
let trace = &mut self.trace;
for event in &executed.events {
match event.outcome {
None => {
trace.starts.push((ms, event.name));
// Which press answered a box, and on which map: 991 `YES` in twenty brain
// minutes is a fact about one conversation, and this is what says which.
if let Some(map) = self.dialog_map {
*trace.dialog_macros.entry((event.name, map)).or_insert(0) += 1;
}
if let Some(sub) = self.battle_sub {
*trace.battle_starts.entry((event.name, sub)).or_insert(0) += 1;
}
if let Some(battle) = trace.battle_now.as_mut() {
battle.1 += 1;
}
if let Some(slot) = event.name.strip_prefix("MOVE ") {
trace.move_starts.0 += 1;
let id = flybrain_gb::pokemon_red::state::battle(parts.emulator)
.and_then(|battle| battle.own)
.zip(slot.parse::<usize>().ok())
.and_then(|(own, slot)| own.moves.get(slot - 1).copied().flatten())
.map(|entry| entry.id);
if id.is_some_and(|id| {
flybrain_gb::pokemon_red::state::move_without_effect(parts.emulator, id)
== Some(true)
}) {
trace.move_starts.1 += 1;
}
}
self.running = Some(Running {
name: event.name,
from: location,
tiles: location.into_iter().collect(),
frames: 0,
reach: 0,
});
}
Some(outcome) => {
*trace.outcomes.entry(outcome.as_str()).or_insert(0) += 1;
if outcome.as_str() == "refused" {
*trace.refusals.entry(event.name).or_insert(0) += 1;
let key = Some((event.name, location));
trace.refusal_run = if trace.refusal_run.0 == key {
(key, trace.refusal_run.1 + 1)
} else {
(key, 1)
};
if trace.refusal_run.1 > trace.longest_refusal_run.0 {
trace.longest_refusal_run = (trace.refusal_run.1, event.name);
}
}
if let Some(run) = self.running.take() {
let net = match (run.from, location) {
(Some((map, x, y)), Some((at, ax, ay))) if map == at => {
ax.abs_diff(x) + ay.abs_diff(y)
}
_ => 0,
};
trace.episodes.push(Episode {
name: run.name,
outcome: outcome.as_str(),
frames: run.frames,
tiles: run.tiles.len(),
net,
reach: run.reach,
});
}
}
}
}
{
let text = flybrain_gb::pokemon_red::state::text_box(parts.emulator);
let (corners, border) = flybrain_gb::pokemon_red::state::dialog_border(parts.emulator);
match (text.open, corners, border) {
(true, true, true) => trace.font_corners_border += 1,
(true, true, false) => trace.font_corners_no_border += 1,
(true, false, _) => trace.font_no_corners += 1,
(false, true, _) => trace.corners_no_font += 1,
(false, false, _) => {}
}
}
if let Some(layer) = parts.macros.as_deref() {
let name = layer.scene_name();
*trace.scenes.entry(name).or_insert(0) += 1;
if name == self.scene_run.0 {
self.scene_run.1 += 1;
} else {
self.scene_run = (name, 1, ms);
}
let longest = trace.longest_scene.entry(name).or_insert((0, 0.0));
if self.scene_run.1 > longest.0 {
*longest = (self.scene_run.1, self.scene_run.2 - trace.began_ms);
}
// Where the text box is, which is the half the scene histogram could not say.
if name == "dialog" || name == "unknown" {
let where_ = flybrain_gb::pokemon_red::state::player(parts.emulator)
.map(|player| (player.map, player.x, player.y));
if let Some(key) = where_ {
*trace.dialog_frames.entry(key).or_insert(0) += 1;
}
}
}
}
}
/// Run `minutes` brain minutes of the sim loop's frame order from `checkpoint`, recording where /// Run `minutes` brain minutes of the sim loop's frame order from `checkpoint`, recording where
/// the fly stood and what it started. /// the fly stood and what it started.
fn run( fn run(
@ -319,8 +461,6 @@ fn run(
) -> Trace { ) -> Trace {
let began_wall = std::time::Instant::now(); let began_wall = std::time::Instant::now();
let stub = std::env::var("FLY_TRAP_STUB").is_ok_and(|value| value == "1"); let stub = std::env::var("FLY_TRAP_STUB").is_ok_and(|value| value == "1");
let mut stub_hold = 0usize;
let mut stub_next_ms = f64::NEG_INFINITY;
let mut emulator = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES) let mut emulator = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
.expect("binjgb should accept the cartridge"); .expect("binjgb should accept the cartridge");
let mut adapter = PokemonRedReward::new(); let mut adapter = PokemonRedReward::new();
@ -333,34 +473,28 @@ fn run(
.or(preset.exclusive.as_ref()) .or(preset.exclusive.as_ref())
.expect("the preset has a group") .expect("the preset has a group")
.hold_ms; .hold_ms;
let blocked_ms =
preset.exclusive.as_ref().expect("the preset has an exclusive group").blocked_ms;
let agent_config = AgentConfig::with_decoder(preset); let agent_config = AgentConfig::with_decoder(preset);
let mut ratchet = Ratchet::with_policy(adapter.recovery_policy()); let mut ratchet = Ratchet::with_policy(adapter.recovery_policy());
emulator
.import_state(&checkpoint.runtime.emulator)
.expect("the checkpoint's emulator state should import");
adapter
.import_state(&checkpoint.runtime.reward)
.expect("the checkpoint's reward ledger should import");
let snapshot = (!checkpoint.runtime.ratchet_game.is_empty()).then(|| {
flybrain_gb::ratchet::Snapshot {
game: checkpoint.runtime.ratchet_game.clone(),
frame: checkpoint.runtime.ratchet_frame.clone(),
}
});
ratchet
.import(Some(checkpoint.runtime.ratchet), snapshot, adapter.rank_ladder().len())
.expect("the checkpoint's ratchet state should import");
let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent"); let mut agent = NeuralAgent::new(Arc::clone(data), agent_config).expect("a valid agent");
if threads > 1 { if threads > 1 {
agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan")); agent.set_sweep_plan(SweepPlan::with_threads(threads).expect("a sweep plan"));
} }
agent.import_state(&checkpoint.agent).expect("the checkpoint's agent should import"); // The stream's own restore, into the stream's own frame: a fresh process's readout transient
let (width, height) = (agent.frame.width, agent.frame.height); // (`restore: legacy-transient-reset`), which is what the fly has after the service restarts.
agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height); let mut frame = LegacyFrame::new()
.with_trace(FrameTrace::from_env().expect("FLY_TRACE should name a writable file"));
frame
.restore(
&mut Parts {
agent: &mut agent,
emulator: &mut emulator,
adapter: &mut adapter,
ratchet: &mut ratchet,
macros: None,
},
checkpoint,
)
.expect("the checkpoint should restore");
let mut config = Config::default(); let mut config = Config::default();
config.loop_.game = "pokemon-red".to_string(); config.loop_.game = "pokemon-red".to_string();
@ -385,222 +519,113 @@ fn run(
let began_ms = agent.network.ms; let began_ms = agent.network.ms;
let until = began_ms + minutes * MINUTE_MS; let until = began_ms + minutes * MINUTE_MS;
let mut frame = emulator.framebuffer().to_vec(); let rank = adapter.progress().rank;
let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new();
let mut location = adapter.location();
let mut blocked_since_ms = began_ms;
let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string);
let mut rank = adapter.progress().rank;
let mut running: Option<Running> = None;
// A periodic one-liner for a run that is going nowhere: what the fly is standing on, what it // A periodic one-liner for a run that is going nowhere: what the fly is standing on, what it
// faces, and which text box the detector is looking at. Off unless asked for, because it is a // faces, and which text box the detector is looking at. Off unless asked for, because it is a
// diagnostic and the tables above are the report. // diagnostic and the tables above are the report.
let trace_every_ms = env_f64("FLY_TRAP_TRACE_SECONDS", 0.0) * 1000.0; let trace_every_ms = env_f64("FLY_TRAP_TRACE_SECONDS", 0.0) * 1000.0;
let mut next_trace = began_ms; let mut next_trace = began_ms;
// The scene of the frames in a row, for "stuck in a text box" against "in and out of one". let mut hunt = Hunt {
let mut scene_run: (&'static str, u64, f64) = ("", 0, began_ms); stub,
let mut trace = Trace { stub_hold: 0,
steps: Vec::new(), stub_next_ms: f64::NEG_INFINITY,
starts: Vec::new(), hold_ms,
episodes: Vec::new(), running: None,
began_ms, // The scene of the frames in a row, for "stuck in a text box" against "in and out of one".
ended_ms: began_ms, scene_run: ("", 0, began_ms),
frames: 0, dialog_map: None,
recoveries: 0, battle_sub: None,
rungs: vec![(rank, adapter.progress().rank_label, 0.0)], trace: Trace {
outcomes: BTreeMap::new(), steps: Vec::new(),
scenes: BTreeMap::new(), starts: Vec::new(),
dialog_frames: BTreeMap::new(), episodes: Vec::new(),
dialog_macros: BTreeMap::new(), began_ms,
ended_in: ("", String::new()), ended_ms: began_ms,
longest_scene: BTreeMap::new(), frames: 0,
ended_why: String::new(), recoveries: 0,
ended_grid: String::new(), rungs: vec![(rank, adapter.progress().rank_label, 0.0)],
font_corners_border: 0, outcomes: BTreeMap::new(),
font_corners_no_border: 0, scenes: BTreeMap::new(),
font_no_corners: 0, dialog_frames: BTreeMap::new(),
corners_no_font: 0, dialog_macros: BTreeMap::new(),
battle_frames: BTreeMap::new(), ended_in: ("", String::new()),
battle_starts: BTreeMap::new(), longest_scene: BTreeMap::new(),
battle_pads: BTreeMap::new(), ended_why: String::new(),
wall_seconds: 0.0, ended_grid: String::new(),
seeded: seeded_note, font_corners_border: 0,
refusals: BTreeMap::new(), font_corners_no_border: 0,
refusal_run: (None, 0), font_no_corners: 0,
longest_refusal_run: (0, ""), corners_no_font: 0,
battle_frames: BTreeMap::new(),
battle_starts: BTreeMap::new(),
battle_pads: BTreeMap::new(),
battles: Vec::new(),
battle_now: None,
payouts_by_kind: BTreeMap::new(),
move_starts: (0, 0),
wall_seconds: 0.0,
seeded: seeded_note,
refusals: BTreeMap::new(),
refusal_run: (None, 0),
longest_refusal_run: (0, ""),
},
}; };
let mut rank = rank;
// One observation before the first frame, as the sim loop takes after a restore.
if let Some(layer) = macros.as_mut() { if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter); let ledger = AdapterLedger(&adapter);
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms); let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
} }
while agent.network.ms < until { while agent.network.ms < until {
let rewards: Vec<NeuralReward> = payouts let mut parts = Parts {
.iter() agent: &mut agent,
.map(|event| { emulator: &mut emulator,
NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms)) adapter: &mut adapter,
}) ratchet: &mut ratchet,
.collect(); macros: macros.as_mut(),
let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true };
let ms = agent.network.ms;
let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms)
.then(|| agent.decoder.current().map(str::to_string))
.flatten();
let bound = macros.as_ref().map(MacroLayer::bound_channels);
let result = agent
.tick_bound(&frame, &options, blocked.as_deref(), bound.as_deref())
.expect("a tick");
// The brain is still ticked — the frame order, the plasticity and the cost are the run's —
// and only the *readout* is replaced, so a stub run and a brain run differ in who chooses
// and in nothing else.
let active: Vec<String> = if stub {
let hot = STUB_CHANNELS[(stub_hold / STUB_HOLDS_PER_CHANNEL) % STUB_CHANNELS.len()];
if ms >= stub_next_ms {
stub_next_ms = ms + hold_ms;
stub_hold += 1;
}
bound
.as_deref()
.unwrap_or_default()
.iter()
.filter(|channel| *channel == hot)
.cloned()
.collect()
} else {
result.active.clone()
}; };
let held = agent.decoder.current().map(str::to_string); let transition = frame.transition(&mut parts, &mut hunt).expect("a frame");
if held != held_channel { let ms = transition.ms;
held_channel = held; hunt.trace.frames += 1;
blocked_since_ms = ms; for payout in &transition.evaluated.rewards {
} let entry = hunt.trace.payouts_by_kind.entry(payout.kind).or_insert((0, 0.0));
*entry = (entry.0 + 1, entry.1 + payout.value);
let ms = agent.network.ms;
let mut mask = to_button_mask(&active);
// Read before `decide`, because `decide` is what starts the macro whose scene this is.
let layer_scene = macros.as_ref().map_or("", MacroLayer::scene_name);
let dialog_map = (layer_scene == "dialog" || layer_scene == "unknown")
.then(|| flybrain_gb::pokemon_red::state::player(&mut emulator).map(|p| p.map))
.flatten();
let battle_sub = battle_sub_state(&mut emulator);
if let Some(sub) = battle_sub {
*trace.battle_frames.entry(sub).or_insert(0) += 1;
let pad = trace.battle_pads.entry(sub).or_default();
for channel in bound.as_deref().unwrap_or_default() {
pad.insert(channel.clone());
}
}
if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter);
let decision = layer.decide(&active, mask, ms, &mut emulator, &ledger);
mask = decision.mask;
for event in &decision.events {
match event.outcome {
None => {
trace.starts.push((ms, event.name));
// Which press answered a box, and on which map: 991 `YES` in twenty brain
// minutes is a fact about one conversation, and this is what says which.
if let Some(map) = dialog_map {
*trace.dialog_macros.entry((event.name, map)).or_insert(0) += 1;
}
if let Some(sub) = battle_sub {
*trace.battle_starts.entry((event.name, sub)).or_insert(0) += 1;
}
running = Some(Running {
name: event.name,
from: location,
tiles: location.into_iter().collect(),
frames: 0,
reach: 0,
});
}
Some(outcome) => {
*trace.outcomes.entry(outcome.as_str()).or_insert(0) += 1;
if outcome.as_str() == "refused" {
*trace.refusals.entry(event.name).or_insert(0) += 1;
let key = Some((event.name, location));
trace.refusal_run = if trace.refusal_run.0 == key {
(key, trace.refusal_run.1 + 1)
} else {
(key, 1)
};
if trace.refusal_run.1 > trace.longest_refusal_run.0 {
trace.longest_refusal_run = (trace.refusal_run.1, event.name);
}
}
if let Some(run) = running.take() {
let net = match (run.from, location) {
(Some((map, x, y)), Some((at, ax, ay))) if map == at => {
ax.abs_diff(x) + ay.abs_diff(y)
}
_ => 0,
};
trace.episodes.push(Episode {
name: run.name,
outcome: outcome.as_str(),
frames: run.frames,
tiles: run.tiles.len(),
net,
reach: run.reach,
});
}
}
}
}
} }
{ {
let text = flybrain_gb::pokemon_red::state::text_box(&mut emulator); use flybrain_gb::MemoryReader;
let (corners, border) = let trace = &mut hunt.trace;
flybrain_gb::pokemon_red::state::dialog_border(&mut emulator); let fighting =
match (text.open, corners, border) { parts.emulator.read8(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle) != 0;
(true, true, true) => trace.font_corners_border += 1, let won = transition
(true, true, false) => trace.font_corners_no_border += 1, .evaluated
(true, false, _) => trace.font_no_corners += 1, .rewards
(false, true, _) => trace.corners_no_font += 1, .iter()
(false, false, _) => {} .any(|payout| matches!(payout.kind, "battle" | "trainer"));
} match (fighting, trace.battle_now.as_mut()) {
} (true, Some(battle)) => {
if let Some(layer) = macros.as_ref() { battle.0 += 1;
let name = layer.scene_name(); battle.2 |= won;
*trace.scenes.entry(name).or_insert(0) += 1;
if name == scene_run.0 {
scene_run.1 += 1;
} else {
scene_run = (name, 1, ms);
}
let longest = trace.longest_scene.entry(name).or_insert((0, 0.0));
if scene_run.1 > longest.0 {
*longest = (scene_run.1, scene_run.2 - began_ms);
}
// Where the text box is, which is the half the scene histogram could not say.
if name == "dialog" || name == "unknown" {
let where_ = flybrain_gb::pokemon_red::state::player(&mut emulator)
.map(|player| (player.map, player.x, player.y));
if let Some(key) = where_ {
*trace.dialog_frames.entry(key).or_insert(0) += 1;
} }
(true, None) => trace.battle_now = Some((1, 0, won)),
(false, Some(_)) => {
let mut battle = trace.battle_now.take().expect("a battle");
battle.2 |= won;
trace.battles.push(battle);
}
(false, None) => {}
} }
} }
emulator.set_buttons(mask as u8);
emulator.run_frame().expect("a frame should complete");
trace.frames += 1;
frame.copy_from_slice(emulator.framebuffer());
payouts = adapter.sample(&mut emulator, ms);
if let Some(layer) = macros.as_mut() {
let ledger = AdapterLedger(&adapter);
let _ = layer.observe(&mut emulator, &ledger, agent.network.ms);
}
if trace_every_ms > 0.0 && ms >= next_trace { if trace_every_ms > 0.0 && ms >= next_trace {
next_trace = ms + trace_every_ms; next_trace = ms + trace_every_ms;
let scene = macros.as_ref().map_or("", MacroLayer::scene_name); let scene = parts.macros.as_deref().map_or("", MacroLayer::scene_name);
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile}; use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile};
// Read before the state borrows the emulator: this is the same call the state makes, // Read before the state borrows the emulator: this is the same call the state makes,
// and the only one that can say *which* refusal a frame is. // and the only one that can say *which* refusal a frame is.
let refusal = flybrain_gb::pokemon_red::state::map_grid(&mut emulator).err(); let refusal = flybrain_gb::pokemon_red::state::map_grid(parts.emulator).err();
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator); let mut state = flybrain_gb::pokemon_red::state::PokeState::new(parts.emulator);
let state: &mut dyn MacroState = &mut state; let state: &mut dyn MacroState = &mut state;
let player = state.player(); let player = state.player();
let ahead = player.and_then(|player| { let ahead = player.and_then(|player| {
@ -608,22 +633,18 @@ fn run(
flybrain_gb::pokemon_red::macros::path::target_at(state, ahead) flybrain_gb::pokemon_red::macros::path::target_at(state, ahead)
}); });
let ground = grid_line(state, player, refusal); let ground = grid_line(state, player, refusal);
let why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator); let why = flybrain_gb::pokemon_red::scene::why_unknown(parts.emulator);
println!( println!(
"trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}\n {ground}", "trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}\n {ground}",
(ms - began_ms) / MINUTE_MS (ms - began_ms) / MINUTE_MS
); );
} }
let now = adapter.location(); let location = frame.location;
if now.is_some() && now != location {
location = now;
blocked_since_ms = ms;
}
if let Some((map, x, y)) = location { if let Some((map, x, y)) = location {
trace.steps.push((ms, map, x, y)); hunt.trace.steps.push((ms, map, x, y));
} }
if let Some(run) = running.as_mut() { if let Some(run) = hunt.running.as_mut() {
run.frames += 1; run.frames += 1;
if let Some(at) = location { if let Some(at) = location {
run.tiles.insert(at); run.tiles.insert(at);
@ -635,47 +656,18 @@ fn run(
} }
} }
let progress = adapter.progress(); let progress = transition.evaluated.progress;
if progress.rank != rank { if progress.rank != rank {
rank = progress.rank; rank = progress.rank;
trace.rungs.push((rank, progress.rank_label, ms - began_ms)); hunt.trace.rungs.push((rank, progress.rank_label, ms - began_ms));
} }
let safe = adapter.safe_for_snapshot(); let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary");
let capture_due = safe && u64::from(progress.rank) > ratchet.state.best; if boundary.rollback.is_some() {
let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot { hunt.trace.recoveries += 1;
game: emulator.export_state().expect("state export"), hunt.running = None;
frame: frame.clone(),
});
let recover = ratchet.observe_with_game_over(
safe,
u64::from(progress.rank),
progress.unique_locations as u64,
ms as u64,
adapter.game_over(),
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
);
if recover {
let snapshot = flybrain_gb::ratchet::Snapshot {
game: ratchet.game().expect("a recovery has a snapshot").to_vec(),
frame: ratchet.frame().expect("a recovery has a framebuffer").to_vec(),
};
let restored = {
let mut neural = AgentRecovery { agent: &mut agent };
recover_game(&mut emulator, &mut adapter, &mut neural, &snapshot)
.expect("recovering the game")
};
frame.copy_from_slice(&restored);
emulator.set_buttons(0);
trace.recoveries += 1;
location = adapter.location();
held_channel = None;
blocked_since_ms = ms;
if let Some(layer) = macros.as_mut() {
layer.cancel(ms);
}
running = None;
} }
} }
let mut trace = hunt.trace;
trace.ended_in = ( trace.ended_in = (
macros.as_ref().map_or("", MacroLayer::scene_name), macros.as_ref().map_or("", MacroLayer::scene_name),
adapter.mode().to_string(), adapter.mode().to_string(),
@ -987,6 +979,7 @@ fn main() {
println!("| {name} | {sub} | {n} |"); println!("| {name} | {sub} | {n} |");
} }
} }
battle_report(&trace);
println!("\n| scene | frames | longest run | run began (brain min) |"); println!("\n| scene | frames | longest run | run began (brain min) |");
println!("| --- | ---: | ---: | ---: |"); println!("| --- | ---: | ---: | ---: |");
for (scene, frames) in &trace.scenes { for (scene, frames) in &trace.scenes {
@ -1017,3 +1010,43 @@ fn main() {
WINDOW_MS / 1000.0 WINDOW_MS / 1000.0
); );
} }
/// Row 60's numbers: payouts by kind, every battle's length and whether it paid a win, and the
/// `MOVE n` starts that chose a move the cartridge answers with nothing.
fn battle_report(trace: &Trace) {
println!("\n| payout kind | n | total |");
println!("| --- | ---: | ---: |");
for (kind, (n, total)) in &trace.payouts_by_kind {
println!("| {kind} | {n} | {total:.2} |");
}
let mut lengths: Vec<u64> = trace.battles.iter().map(|battle| battle.0).collect();
lengths.sort_unstable();
let at = |q: f64| -> u64 {
if lengths.is_empty() {
return 0;
}
lengths[((lengths.len() - 1) as f64 * q).round() as usize]
};
println!("\n| battles | n |");
println!("| --- | ---: |");
println!("| ended | {} |", trace.battles.len());
println!("| won (a battle or trainer payout) | {} |", trace.battles.iter().filter(|b| b.2).count());
println!(
"| still running at the end | {} |",
trace.battle_now.map_or("no".to_string(), |b| format!("{} frames, {} macros", b.0, b.1))
);
println!(
"| frames, median / p90 / max | {} / {} / {} |",
at(0.5),
at(0.9),
lengths.last().copied().unwrap_or(0)
);
println!(
"| macros per battle, max | {} |",
trace.battles.iter().map(|b| b.1).max().unwrap_or(0)
);
println!(
"| `MOVE n` starts / on a move without effect | {} / {} |",
trace.move_starts.0, trace.move_starts.1
);
}

View file

@ -9,7 +9,7 @@
//! - the `FLY_*` names the systemd units already set (`FLY_GAME`, `FLY_ROM`, `FLY_DATASET`, //! - the `FLY_*` names the systemd units already set (`FLY_GAME`, `FLY_ROM`, `FLY_DATASET`,
//! `FLY_STATE`, `FLY_STATE_HOT`, `FLY_FEED_BIND`, `FLY_CONTROL_BIND`, `FLY_METRICS_ADDR`, //! `FLY_STATE`, `FLY_STATE_HOT`, `FLY_FEED_BIND`, `FLY_CONTROL_BIND`, `FLY_METRICS_ADDR`,
//! `FLY_ROM_SHA256`, `FLY_ROM_PLATFORMER_SHA256`, `FLY_CHAT_ENABLED`, `FLY_CHAT_DENY_LIST`, //! `FLY_ROM_SHA256`, `FLY_ROM_PLATFORMER_SHA256`, `FLY_CHAT_ENABLED`, `FLY_CHAT_DENY_LIST`,
//! `FLY_MACRO_MODE`, `RAYON_NUM_THREADS`); //! `FLY_MACRO_MODE`, `FLY_FEED_VIA`, `FLY_BUS_DIR`, `RAYON_NUM_THREADS`);
//! - `FLYSIM_<SECTION>_<KEY>` for everything, e.g. `FLYSIM_LOOP_SPEED=0`. //! - `FLYSIM_<SECTION>_<KEY>` for everything, e.g. `FLYSIM_LOOP_SPEED=0`.
//! //!
//! Nothing here is secret (`docs/control-api.md`: "No secrets live in this service or its //! Nothing here is secret (`docs/control-api.md`: "No secrets live in this service or its
@ -104,6 +104,35 @@ pub struct Feed {
pub bind: SocketAddr, pub bind: SocketAddr,
/// Audio attachment sample rate. The page wants Web Audio's native 48 kHz. /// Audio attachment sample rate. The page wants Web Audio's native 48 kHz.
pub audio_hz: u32, pub audio_hz: u32,
/// Who serves `:7400/feed` (`docs/design/flybus.md`, "Feed over the bus").
pub via: FeedVia,
/// The bus runtime directory in `bus` mode: the router's socket and its artifact store.
/// Belongs on tmpfs; a store here holds a few snapshots, never history.
pub bus_dir: PathBuf,
}
/// Where the feed WebSocket is served from.
///
/// `direct` is the default and is the behaviour that predates the bus, byte for byte: flysim
/// binds `feed.bind` itself. `bus` starts an embedded flybus router, publishes every snapshot
/// on it, and leaves `feed.bind` to the `fly-edge` process. The control API stays in flysim
/// either way. Nothing about the fly changes with this knob: it is outside the simulation loop
/// and outside the compatibility string.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FeedVia {
#[default]
Direct,
Bus,
}
impl FeedVia {
pub const fn as_str(self) -> &'static str {
match self {
Self::Direct => "direct",
Self::Bus => "bus",
}
}
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -197,6 +226,8 @@ impl Default for Feed {
Self { Self {
bind: "127.0.0.1:7400".parse().expect("literal address"), bind: "127.0.0.1:7400".parse().expect("literal address"),
audio_hz: 48_000, audio_hz: 48_000,
via: FeedVia::Direct,
bus_dir: PathBuf::from("/run/fly/bus"),
} }
} }
} }
@ -255,6 +286,12 @@ impl Config {
if let Some(value) = get("FLY_FEED_BIND") { if let Some(value) = get("FLY_FEED_BIND") {
self.feed.bind = parse_addr("FLY_FEED_BIND", value)?; self.feed.bind = parse_addr("FLY_FEED_BIND", value)?;
} }
if let Some(value) = get("FLY_FEED_VIA") {
self.feed.via = parse_feed_via("FLY_FEED_VIA", value)?;
}
if let Some(value) = get("FLY_BUS_DIR") {
self.feed.bus_dir = PathBuf::from(value);
}
if let Some(value) = get("FLY_CONTROL_BIND") { if let Some(value) = get("FLY_CONTROL_BIND") {
self.control.bind = parse_addr("FLY_CONTROL_BIND", value)?; self.control.bind = parse_addr("FLY_CONTROL_BIND", value)?;
} }
@ -330,6 +367,12 @@ impl Config {
if let Some(value) = get("FLYSIM_FEED_AUDIO_HZ") { if let Some(value) = get("FLYSIM_FEED_AUDIO_HZ") {
self.feed.audio_hz = parse("FLYSIM_FEED_AUDIO_HZ", value)?; self.feed.audio_hz = parse("FLYSIM_FEED_AUDIO_HZ", value)?;
} }
if let Some(value) = get("FLYSIM_FEED_VIA") {
self.feed.via = parse_feed_via("FLYSIM_FEED_VIA", value)?;
}
if let Some(value) = get("FLYSIM_FEED_BUS_DIR") {
self.feed.bus_dir = PathBuf::from(value);
}
if let Some(value) = get("FLYSIM_CONTROL_BIND") { if let Some(value) = get("FLYSIM_CONTROL_BIND") {
self.control.bind = parse_addr("FLYSIM_CONTROL_BIND", value)?; self.control.bind = parse_addr("FLYSIM_CONTROL_BIND", value)?;
} }
@ -416,6 +459,16 @@ impl Config {
if self.control.sugar_per_minute == 0 { if self.control.sugar_per_minute == 0 {
bail!("control.sugar_per_minute must be at least 1"); bail!("control.sugar_per_minute must be at least 1");
} }
// The router's socket and store, and the edge's way to them. A relative path would
// resolve against whichever working directory each process happens to have, so the two
// could silently disagree; an empty one is a typo. Checked in either mode, so a bad
// value is found before the day a box is switched to the bus.
if self.feed.bus_dir.as_os_str().is_empty() || !self.feed.bus_dir.is_absolute() {
bail!(
"feed.bus_dir (FLY_BUS_DIR) must be an absolute path, got {:?}",
self.feed.bus_dir
);
}
if self.feed.bind == self.control.bind { if self.feed.bind == self.control.bind {
bail!("feed.bind and control.bind must differ (7400 and 7401)"); bail!("feed.bind and control.bind must differ (7400 and 7401)");
} }
@ -464,6 +517,14 @@ impl Config {
} }
} }
fn parse_feed_via(name: &str, value: &str) -> Result<FeedVia> {
match value.to_ascii_lowercase().as_str() {
"direct" => Ok(FeedVia::Direct),
"bus" => Ok(FeedVia::Bus),
_ => bail!("{name}: {value:?} is not a feed path; expected \"direct\" or \"bus\""),
}
}
fn parse_addr(name: &str, value: &str) -> Result<SocketAddr> { fn parse_addr(name: &str, value: &str) -> Result<SocketAddr> {
value value
.parse() .parse()
@ -687,6 +748,44 @@ mod tests {
assert_eq!(config, Config::default()); assert_eq!(config, Config::default());
} }
#[test]
fn the_feed_path_is_direct_unless_the_environment_says_bus() {
let config = Config::default();
assert_eq!(config.feed.via, FeedVia::Direct);
assert_eq!(config.feed.bus_dir, PathBuf::from("/run/fly/bus"));
let mut config = Config::default();
config
.apply_env(&env(&[("FLY_FEED_VIA", "bus"), ("FLY_BUS_DIR", "/tmp/fly-bus")]))
.unwrap();
assert_eq!(config.feed.via, FeedVia::Bus);
assert_eq!(config.feed.bus_dir, PathBuf::from("/tmp/fly-bus"));
let mut config = Config::default();
config.apply_env(&env(&[("FLYSIM_FEED_VIA", "DIRECT")])).unwrap();
assert_eq!(config.feed.via, FeedVia::Direct);
// A typo is a refusal, not a silent fallback to one of the two.
let error = Config::default().apply_env(&env(&[("FLY_FEED_VIA", "buss")])).unwrap_err();
assert!(error.to_string().contains("FLY_FEED_VIA"), "{error}");
assert_eq!(toml::from_str::<Config>("[feed]\nvia = \"bus\"\n").unwrap().feed.via, FeedVia::Bus);
}
#[test]
fn the_bus_dir_must_be_absolute_and_not_empty() {
Config::default().validate().unwrap();
for bad in ["", "run/fly/bus", "./bus"] {
let mut config = Config::default();
config.feed.bus_dir = PathBuf::from(bad);
let error = config.validate().unwrap_err();
assert!(error.to_string().contains("FLY_BUS_DIR"), "{bad:?}: {error}");
}
// Through the environment too.
let mut config = Config::default();
config.apply_env(&env(&[("FLY_BUS_DIR", "relative/bus")])).unwrap();
assert!(config.validate().is_err());
}
#[test] #[test]
fn the_example_file_parses_and_validates() { fn the_example_file_parses_and_validates() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../flysim.toml.example"); let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../flysim.toml.example");

View file

@ -9,8 +9,14 @@
//! - 30 snapshots a second while running, 2 while paused or booting (header only); //! - 30 snapshots a second while running, 2 while paused or booting (header only);
//! - drop-oldest, never queue: the sim publishes into a `watch` slot, so a slow client misses //! - drop-oldest, never queue: the sim publishes into a `watch` slot, so a slow client misses
//! snapshots instead of slowing the loop down. Those misses are counted. //! snapshots instead of slowing the loop down. Those misses are counted.
//!
//! The server only needs a [`FeedState`]: a watch slot of snapshots, the counters and the idle
//! cadence. flysim builds one from its own state when it serves the feed itself
//! (`FLY_FEED_VIA=direct`), and `fly-edge` builds one from the snapshots it takes off the bus
//! (`FLY_FEED_VIA=bus`), so both paths run this same code and write the same bytes.
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use axum::Router; use axum::Router;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
@ -19,9 +25,22 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::any; use axum::routing::any;
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::watch;
use crate::metrics::Metrics;
use crate::snapshot::{AttachmentKind, FeedStatus, PROTOCOL, Snapshot, Wants}; use crate::snapshot::{AttachmentKind, FeedStatus, PROTOCOL, Snapshot, Wants};
use crate::{AppState, metrics::Metrics};
/// Everything the feed server reads.
#[derive(Clone)]
pub struct FeedState {
/// The newest snapshot. Dropping its sender ends every client's stream.
pub snapshots: watch::Receiver<Arc<Snapshot>>,
/// `frames_sent`, `feed_clients` and `feed_dropped` are the ones this module moves.
pub metrics: Arc<Metrics>,
/// The protocol's idle cadence: how long a paused or booting stream waits before it
/// repeats the current header (`config.publish_periods().1`).
pub idle_period: Duration,
}
/// The one JSON text message a client sends on connect. /// The one JSON text message a client sends on connect.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@ -37,7 +56,7 @@ pub struct ClientHello {
/// Close code for a protocol violation, as the reference server uses. /// Close code for a protocol violation, as the reference server uses.
const CLOSE_PROTOCOL_ERROR: u16 = 1002; const CLOSE_PROTOCOL_ERROR: u16 = 1002;
pub fn router(state: AppState) -> Router { pub fn router(state: FeedState) -> Router {
Router::new() Router::new()
.route("/feed", any(upgrade)) .route("/feed", any(upgrade))
.fallback(not_found) .fallback(not_found)
@ -48,11 +67,11 @@ async fn not_found() -> Response {
(StatusCode::NOT_FOUND, "not found").into_response() (StatusCode::NOT_FOUND, "not found").into_response()
} }
async fn upgrade(upgrade: WebSocketUpgrade, State(state): State<AppState>) -> Response { async fn upgrade(upgrade: WebSocketUpgrade, State(state): State<FeedState>) -> Response {
upgrade.on_upgrade(move |socket| serve_client(socket, state)) upgrade.on_upgrade(move |socket| serve_client(socket, state))
} }
async fn serve_client(mut socket: WebSocket, state: AppState) { async fn serve_client(mut socket: WebSocket, state: FeedState) {
let Some(hello) = read_hello(&mut socket).await else { let Some(hello) = read_hello(&mut socket).await else {
return; return;
}; };
@ -64,9 +83,9 @@ async fn serve_client(mut socket: WebSocket, state: AppState) {
spikes = wants.spikes, spikes = wants.spikes,
"feed client connected" "feed client connected"
); );
state.shared.metrics.client_joined(); state.metrics.client_joined();
let result = pump(&mut socket, &state, wants).await; let result = pump(&mut socket, &state, wants).await;
state.shared.metrics.client_left(); state.metrics.client_left();
match result { match result {
Ok(()) => tracing::info!("feed client disconnected"), Ok(()) => tracing::info!("feed client disconnected"),
Err(error) => tracing::info!(%error, "feed client dropped"), Err(error) => tracing::info!(%error, "feed client dropped"),
@ -117,9 +136,9 @@ async fn read_hello(socket: &mut WebSocket) -> Option<ClientHello> {
None None
} }
async fn pump(socket: &mut WebSocket, state: &AppState, wants: Wants) -> Result<(), axum::Error> { async fn pump(socket: &mut WebSocket, state: &FeedState, wants: Wants) -> Result<(), axum::Error> {
let mut receiver = state.snapshots.clone(); let mut receiver = state.snapshots.clone();
let (_, idle_period) = state.shared.config.publish_periods(); let idle_period = state.idle_period;
let mut last_seq = 0u64; let mut last_seq = 0u64;
// The current snapshot first, so a client that connects while paused or booting sees the // The current snapshot first, so a client that connects while paused or booting sees the
@ -161,18 +180,18 @@ async fn pump(socket: &mut WebSocket, state: &AppState, wants: Wants) -> Result<
async fn send( async fn send(
socket: &mut WebSocket, socket: &mut WebSocket,
state: &AppState, state: &FeedState,
snapshot: &Arc<Snapshot>, snapshot: &Arc<Snapshot>,
wants: Wants, wants: Wants,
last_seq: &mut u64, last_seq: &mut u64,
) -> Result<(), axum::Error> { ) -> Result<(), axum::Error> {
let seq = snapshot.header.seq; let seq = snapshot.header.seq;
if seq > *last_seq + 1 && *last_seq != 0 { if seq > *last_seq + 1 && *last_seq != 0 {
Metrics::add(&state.shared.metrics.feed_dropped, seq - *last_seq - 1); Metrics::add(&state.metrics.feed_dropped, seq - *last_seq - 1);
} }
*last_seq = seq; *last_seq = seq;
socket.send(Message::Binary(snapshot.encode(wants).into())).await?; socket.send(Message::Binary(snapshot.encode(wants).into())).await?;
Metrics::incr(&state.shared.metrics.frames_sent); Metrics::incr(&state.metrics.frames_sent);
Ok(()) Ok(())
} }

View file

@ -0,0 +1,354 @@
//! The feed over flybus (`FLY_FEED_VIA=bus`, `docs/design/flybus.md` "Feed over the bus").
//!
//! Both halves of the bus encoding live here, so the publisher in flysim and the subscriber in
//! `fly-edge` cannot drift apart:
//!
//! - [`publish`] turns one [`Snapshot`] into one publication on [`TOPIC`]: every attachment the
//! header lists as a sealed artifact named after its kind (`frame`, `audio`, `spikes`), and the
//! header itself as the envelope payload `{"header": {...}}`. A header too large for an
//! envelope travels as a `header` artifact instead, so no snapshot is ever unpublishable.
//! - [`receive`] turns that publication back into the same [`Snapshot`], which `fly-edge` hands to
//! [`crate::feed`] exactly as flysim does. The WebSocket bytes are therefore produced by the same
//! `Snapshot::encode` on both paths.
//!
//! The simulation thread never sees any of this. It publishes into its `watch` slot as it
//! always has; [`run_publisher`] is a task on the bus's own runtime that reads that slot and
//! skips whatever it was too slow to see, the same drop-oldest rule every feed client gets.
//! A stalled router, a full store or an absent edge can cost snapshots on the bus, never a
//! frame of the loop.
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use flybus::{
Artifact, BusError, Client, ClientConfig, ErrorCode, Grants, Limits, Message, Pattern, Policy,
PublishReceipt, Retained, Router, RouterConfig, UnixListenerHandle,
};
use serde_json::{Map, Value};
use tokio::sync::watch;
use crate::metrics::Metrics;
use crate::snapshot::{AttachmentKind, FeedHeader, Snapshot};
/// The one topic: `latest` retention, so a subscriber that joins late starts from the newest
/// snapshot and one that falls behind is coalesced rather than queued.
pub const TOPIC: &str = "fly.feed.snapshots";
/// The publisher's participant id (in-process, launcher-bound).
pub const PUBLISHER: &str = "flysim";
/// The edge's participant id; the Unix socket is bound to it.
pub const EDGE: &str = "fly-edge";
/// Socket file under `feed.bus_dir`, bound to [`EDGE`] only.
pub const SOCKET: &str = "edge.sock";
/// Store root under `feed.bus_dir`; the router makes its per-incarnation directory inside it.
pub const STORE: &str = "store";
/// Headers up to this many JSON bytes ride in the envelope; anything larger becomes an artifact.
/// Well under flybus's 65,536-byte envelope limit, leaving room for the attachment references
/// and the router's ids. A real header is 2 to 8 KB.
pub const HEADER_INLINE_MAX: usize = 48 * 1024;
/// The attachment name of an out-of-line header.
pub const HEADER_ARTIFACT: &str = "header";
/// `<bus_dir>/edge.sock`.
pub fn socket_path(bus_dir: &Path) -> PathBuf {
bus_dir.join(SOCKET)
}
/// `<bus_dir>/store`.
pub fn store_root(bus_dir: &Path) -> PathBuf {
bus_dir.join(STORE)
}
/// The router limits for the feed (`docs/design/flybus.md`, amendment "Feed sizing").
///
/// One snapshot with attachments is 122,367 bytes on the live fly: a 92,160-byte 160x144 RGBA
/// frame, a 17,407-byte spike bitset (139,255 neurons) and about 12,800 bytes of audio (1,600
/// stereo f32 frames at 48 kHz per 30 Hz snapshot). A `latest` subscriber pins at most its one
/// queued slot plus its in-flight credits, the topic pins one retained value, and the publisher
/// holds one snapshot of staging plus the sealed copy while it seals.
///
/// Only one client can subscribe at all: the publisher is in process, and the one socket is
/// launcher-bound to [`EDGE`], which the router admits once at a time. So the worst case is
/// that client holding every subscription it may open ([`Limits::max_subscriptions_per_client`],
/// 4), each never consuming with in-flight credits at the cap of 2: `4 * 3 + 1 + 2 = 15`
/// snapshots, about 1.8 MB. `max_clients` bounds connections, pending handshakes included, not
/// subscribers. The store cap is well over ten times that so a burst of catch-up audio after a
/// stall still fits, and it is RAM (tmpfs), so it is kept small on purpose.
pub fn limits() -> Limits {
Limits {
max_clients: 8,
max_services: 8,
max_topics: 8,
max_subscriptions_per_client: 4,
max_subscriptions: 16,
max_latest_in_flight: 2,
max_owners_per_client: 64,
reserved_owners_per_client: 8,
// Audio accumulates while the loop is behind its publish deadline; 4 MiB is ten seconds
// of it, far past anything the pacer allows before it logs lag.
max_artifact_bytes: 4 << 20,
max_store_bytes: 32 << 20,
max_retained_bytes: 8 << 20,
..Limits::default()
}
}
/// flysim may declare and publish the feed topic; the edge may only subscribe to it.
pub fn policy() -> Policy {
Policy::closed()
.client(
PUBLISHER,
Grants {
publish: vec![Pattern::exact(TOPIC)],
manage_topics: vec![Pattern::exact(TOPIC)],
..Grants::default()
},
)
.client(
EDGE,
Grants {
subscribe: vec![Pattern::exact(TOPIC)],
..Grants::default()
},
)
}
/// A running router and the edge's socket. Dropping it stops listening; the router stops with
/// the runtime it was started on.
pub struct BusFeed {
pub router: Router,
_listener: UnixListenerHandle,
}
/// Start the embedded router under `bus_dir` and listen for the edge on `<bus_dir>/edge.sock`.
///
/// Must run inside a Tokio runtime. `Router::new` removes store directories a previous flysim
/// left behind (their `flock` is free once that process is gone); a stale socket file is removed
/// here, because a socket outlives its listener on disk.
pub async fn start_router(bus_dir: &Path) -> anyhow::Result<BusFeed> {
use anyhow::Context as _;
use std::os::unix::fs::DirBuilderExt as _;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(bus_dir)
.with_context(|| format!("creating the bus directory {}", bus_dir.display()))?;
let root = store_root(bus_dir);
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&root)
.with_context(|| format!("creating the bus store root {}", root.display()))?;
let socket = socket_path(bus_dir);
match std::fs::remove_file(&socket) {
Ok(()) => tracing::info!(socket = %socket.display(), "removed a stale bus socket"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(error).with_context(|| format!("removing {}", socket.display()));
}
}
let mut config = RouterConfig::new(root);
config.limits = limits();
config.policy = policy();
let router = Router::new(config).context("starting the flybus router")?;
let listener = router
.listen_unix_as(&socket, EDGE)
.await
.with_context(|| format!("listening on {}", socket.display()))?;
tracing::info!(
socket = %socket.display(),
store = %router.store_dir().display(),
router = router.router_id(),
"feed bus listening"
);
Ok(BusFeed {
router,
_listener: listener,
})
}
fn attachment_name(kind: AttachmentKind) -> &'static str {
match kind {
AttachmentKind::Frame => "frame",
AttachmentKind::Audio => "audio",
AttachmentKind::Spikes => "spikes",
}
}
fn content_type(kind: AttachmentKind) -> &'static str {
match kind {
AttachmentKind::Frame => "image/x-rgba",
AttachmentKind::Audio => "audio/x-f32le",
AttachmentKind::Spikes => "application/x-spike-bitset",
}
}
fn bytes_of(snapshot: &Snapshot, kind: AttachmentKind) -> &[u8] {
match kind {
AttachmentKind::Frame => &snapshot.frame,
AttachmentKind::Audio => &snapshot.audio,
AttachmentKind::Spikes => &snapshot.spikes,
}
}
async fn seal(client: &Client, bytes: &[u8], content_type: &str) -> Result<Artifact, BusError> {
let mut writer = client
.artifacts()
.allocate(bytes.len() as u64, content_type)
.await?;
writer
.write_all(bytes)
.map_err(|error| BusError::new(ErrorCode::StoreFailure, format!("staging: {error}")))?;
writer.seal().await
}
/// Publish one snapshot: its attachments as artifacts, its header as the payload.
pub async fn publish(client: &Client, snapshot: &Snapshot) -> Result<PublishReceipt, BusError> {
let header = &snapshot.header;
let json = serde_json::to_vec(header).expect("a FeedHeader always serializes");
let mut kinds: Vec<AttachmentKind> = Vec::with_capacity(3);
for kind in header.attachments.iter().copied() {
if !kinds.contains(&kind) {
kinds.push(kind);
}
}
let mut artifacts: Vec<(&'static str, Artifact)> = Vec::with_capacity(4);
for kind in kinds {
let artifact = seal(client, bytes_of(snapshot, kind), content_type(kind)).await?;
artifacts.push((attachment_name(kind), artifact));
}
let mut payload = Map::new();
if json.len() <= HEADER_INLINE_MAX {
let value: Value = serde_json::from_slice(&json).expect("a serialized header re-parses");
payload.insert("header".into(), value);
} else {
artifacts.push((
HEADER_ARTIFACT,
seal(client, &json, "application/json").await?,
));
}
let attachments: Vec<(&str, &Artifact)> = artifacts
.iter()
.map(|(name, artifact)| (*name, artifact))
.collect();
client.publish(TOPIC, payload, &attachments).await
}
/// Rebuild the snapshot one publication carries. The message's delivery is released when the
/// caller drops it; every byte has been copied out by then.
pub async fn receive(message: &Message) -> Result<Snapshot, BusError> {
let invalid = |what: String| BusError::new(ErrorCode::InvalidEnvelope, what);
let header: FeedHeader = match message.payload().get("header") {
Some(value) => serde_json::from_value(value.clone())
.map_err(|error| invalid(format!("feed header: {error}")))?,
None => {
let bytes = message.artifact(HEADER_ARTIFACT)?.read_all().await?;
serde_json::from_slice(&bytes)
.map_err(|error| invalid(format!("feed header artifact: {error}")))?
}
};
let mut snapshot = Snapshot {
header,
frame: Arc::new(Vec::new()),
audio: Arc::new(Vec::new()),
spikes: Arc::new(Vec::new()),
};
let kinds = snapshot.header.attachments.clone();
for kind in kinds {
let bytes = Arc::new(message.artifact(attachment_name(kind))?.read_all().await?);
match kind {
AttachmentKind::Frame => snapshot.frame = bytes,
AttachmentKind::Audio => snapshot.audio = bytes,
AttachmentKind::Spikes => snapshot.spikes = bytes,
}
}
Ok(snapshot)
}
/// How often a failing publisher repeats its warning.
const WARN_EVERY: Duration = Duration::from_secs(10);
/// Publish every snapshot the sim puts in its watch slot until the sim is gone.
///
/// Connects in process as [`PUBLISHER`], declares [`TOPIC`] with `latest` retention and
/// publishes the current snapshot first, so an edge that connects at once still sees the boot
/// state. A refused publication (a full store, say) is counted and skipped; a lost connection
/// is re-made after a second. Borrows of the watch slot end before any await, exactly as in
/// [`crate::feed`]: a held borrow is a lock the sim thread's next publish would wait on.
pub async fn run_publisher(
router: Router,
mut snapshots: watch::Receiver<Arc<Snapshot>>,
metrics: Arc<Metrics>,
) {
let store_root = router.store_root().to_path_buf();
let mut last_warning: Option<Instant> = None;
let mut warn = |error: &BusError, what: &str| {
if last_warning.is_none_or(|at| at.elapsed() >= WARN_EVERY) {
tracing::warn!(%error, "feed bus: {what}");
last_warning = Some(Instant::now());
}
};
loop {
let transport = router.connect_in_memory_as(PUBLISHER);
let client =
match Client::connect(transport, ClientConfig::new(PUBLISHER, &store_root)).await {
Ok(client) => client,
Err(error) => {
warn(&error, "the publisher could not connect");
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
};
if let Err(error) = client.declare_topic(TOPIC, Retained::Latest).await {
warn(&error, "the feed topic could not be declared");
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
let mut current = snapshots.borrow_and_update().clone();
loop {
match publish(&client, &current).await {
Ok(_) => Metrics::incr(&metrics.bus_published),
Err(error) => {
Metrics::incr(&metrics.bus_publish_failures);
warn(&error, "a snapshot was not published");
if client.closed().is_some() {
break;
}
}
}
if snapshots.changed().await.is_err() {
// The sim thread is gone; so is the service.
client.close().await;
return;
}
current = snapshots.borrow_and_update().clone();
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_limits_validate_and_hold_the_worst_case_with_room() {
let limits = limits();
limits.validate().unwrap();
// A full snapshot on the live fly (see `limits`).
let snapshot_bytes = crate::snapshot::FRAME_BYTES + 139_255usize.div_ceil(8) + 12_800;
assert_eq!(snapshot_bytes, 122_367);
// One subscribing client (the socket's), every subscription it may open, none consuming.
let subscriptions = limits.max_subscriptions_per_client as u64;
let pinned = subscriptions * (1 + limits.max_latest_in_flight) + 1 + 2;
assert_eq!(pinned, 15);
assert!(
pinned * snapshot_bytes as u64 * 10 <= limits.max_store_bytes,
"{pinned}"
);
assert!(limits.max_artifact_bytes >= crate::snapshot::FRAME_BYTES as u64 * 40);
}
}

View file

@ -0,0 +1,705 @@
//! The legacy frame: one Game Boy frame of the live loop, in its phases, in the one order.
//!
//! This is the order `simloop.rs` runs on the stream, and the order every harness that claims to
//! measure the stream runs: the trap hunt, the palette bench, the room-escape bench, and the
//! stub-readout drivers of the ROM tests and the scene probe. It used to be written out in each of
//! them, and the copies had drifted: the benches ticked the brain through `NeuralAgent::tick`, which
//! installs the previous frame and its rewards *after* the next ticks, so every bench ran the
//! brain one frame behind the stream. There is one copy now, and the parity oracle is this file.
//!
//! The phases are named for the lockstep transaction they become in the session framework
//! (`docs/design/session-framework/legacy-gameboy-v1.md` section 4):
//!
//! | phase | what it does | lockstep |
//! | --- | --- | --- |
//! | (host) | drains commands: sugar and operator pulses are applied here, before the ticks | admission at `Ready(k)` |
//! | [`LegacyFrame::prepare`] | 16 or 17 brain ticks, the remainder carried; decode with the scene's bound channels and the blocked direction | A: `Agent.Prepare` |
//! | [`LegacyFrame::execute`] | the raw mask, then the macro layer decides the mask | B: the executor |
//! | [`LegacyFrame::advance`] | the joypad, one emulator frame, the framebuffer, the audio | B: `Environment.Advance` |
//! | [`LegacyFrame::evaluate`] | reward events, the macro layer's observation, the location, the rank | C: the task |
//! | [`LegacyFrame::commit`] | install the frame, one stimulation per event, one reinforcement | D: `Agent.Commit` |
//! | (host) | the milestone archive | a capture at `Ready(k+1)` |
//! | [`LegacyFrame::boundary`] | the ratchet's capture and decision, and the rollback when it fires | C decides; slot save and rollback at `Ready(k+1)` |
//!
//! Two moves from the order `simloop.rs` used to spell out, both between operations that touch
//! disjoint state, so neither changes a byte: the visual frame is installed in `commit` rather
//! than straight after the emulator frame (nothing reads the network in between), and the
//! stimulation and reinforcement come after the macro layer's observation and the location
//! (which read the emulator and the adapter, never the network). The milestone archive stays
//! where the stream has it -- after the reinforcement, before the ratchet captures -- which is
//! why the ratchet is its own call after `transition`: the host takes its archive between the
//! two. `FLY_TRACE` (`crate::trace`) records every phase, and a trace of the stream from one
//! checkpoint is byte-identical before and after this extraction.
use anyhow::{Result, anyhow};
use flybrain_core::agent::NeuralAgent;
use flybrain_core::decoder::gameboy::to_button_mask;
use flybrain_gb::RewardEvent;
use flybrain_gb::adapter::{GameAdapter, ProgressSnapshot};
use flybrain_gb::emulator::{Emulator, FRAMEBUFFER_LEN};
use flybrain_gb::macros::AdapterLedger;
use flybrain_gb::ratchet::{Ratchet, Snapshot};
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use crate::macros::{MacroEvent, MacroLayer, Silence};
use crate::trace::FrameTrace;
/// Everything one frame reads and writes besides the frame's own state: the parts the loop owns.
pub struct Parts<'a> {
pub agent: &'a mut NeuralAgent,
pub emulator: &'a mut Emulator,
pub adapter: &'a mut dyn GameAdapter,
pub ratchet: &'a mut Ratchet,
/// `None` in raw mode, where not one line of the macro layer runs.
pub macros: Option<&'a mut MacroLayer>,
}
/// Where a host may look in, or time a phase. Every method defaults to nothing.
///
/// The stream's loop uses [`FrameObserver::after`] for its per-phase profile and nothing else. A
/// harness may read the emulator between phases to measure the run, and a stub-readout harness may
/// replace the decision in [`FrameObserver::readout`]; nothing else about the frame is open.
pub trait FrameObserver {
/// A phase has finished. `agent` is lent for the profiler's kernel timings.
fn after(&mut self, _phase: FramePhase, _agent: &mut NeuralAgent) {}
/// The decoded decision, before the executor sees it. The stream never replaces it; the trap
/// hunt's `FLY_TRAP_STUB` does, and the brain still ticks exactly as it would.
fn readout(&mut self, _ms: f64, _bound: Option<&[String]>, _active: &mut Vec<String>) {}
/// Just before the executor decides: O[k] is on the emulator, the palette is the one dealt
/// for it.
fn before_execute(&mut self, _frame: &LegacyFrame, _parts: &mut Parts<'_>, _active: &[String]) {
}
/// The executor has decided and the mask is not yet on the joypad.
fn executed(&mut self, _frame: &LegacyFrame, _parts: &mut Parts<'_>, _executed: &Executed) {}
}
/// The observer that observes nothing.
impl FrameObserver for () {}
/// The points [`FrameObserver::after`] is called at, in order.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FramePhase {
/// The brain ticks are done.
Ticked,
/// The decode and the executor's decision are done.
Executed,
/// The emulator frame has run.
Emulated,
/// The framebuffer and the audio are taken.
Advanced,
/// Rewards are sampled, the scene observed and the transition committed to the brain.
Committed,
}
/// Phase B's result.
#[derive(Debug, Default)]
pub struct Executed {
/// The mask the emulator is given.
pub mask: u32,
/// The macro layer's start and finish events, in order.
pub events: Vec<MacroEvent>,
/// Why nothing was pressed, when nothing was (`crate::macros::Decision::silence`).
pub silence: Option<Silence>,
}
/// Phase C's result.
#[derive(Debug)]
pub struct Evaluated {
/// Reward events from the frame just produced, in adapter order.
pub rewards: Vec<RewardEvent>,
/// The macro layer's own events from observing that frame (at most one abandonment).
pub abandoned: Vec<MacroEvent>,
pub progress: ProgressSnapshot,
}
/// One transition `k -> k+1`, up to and including its commit.
#[derive(Debug)]
pub struct Transition {
/// Brain ticks this frame advanced.
pub ticks: u64,
/// The brain clock after them, which is the clock of every phase that follows.
pub ms: f64,
/// The scene's bound macro channels the decode was masked to; `None` in raw mode.
pub bound: Option<Vec<String>>,
/// The decision the executor was given.
pub active: Vec<String>,
pub executed: Executed,
/// The frame's audio, binjgb's unsigned 8-bit interleaved stereo.
pub audio: Vec<u8>,
pub evaluated: Evaluated,
}
/// Why the ratchet rolled the game back.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RollbackTrigger {
GameOver,
Stall,
}
/// A rollback at the boundary.
#[derive(Debug)]
pub struct Rollback {
pub trigger: RollbackTrigger,
/// The running macro's abandonment and the restored scene's observation, in order.
pub events: Vec<MacroEvent>,
}
/// What happened at `Ready(k+1)`.
#[derive(Debug, Default)]
pub struct Boundary {
/// The ratchet captured a slot this boundary.
pub captured: bool,
pub rollback: Option<Rollback>,
}
/// The neural half of a ratchet recovery, wired to `flybrain-core`.
struct AgentRecovery<'a> {
agent: &'a mut NeuralAgent,
}
impl NeuralRecovery for AgentRecovery<'_> {
fn clear_decoder_holds(&mut self) {
let ms = self.agent.network.ms;
self.agent.decoder.clear_holds(ms);
}
fn clear_eligibility(&mut self) {
let ms = self.agent.network.ms;
self.agent.network.plasticity.clear_eligibility(ms);
}
fn set_visual_frame(&mut self, frame: &[u8]) {
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
self.agent.network.set_visual_frame(frame, width, height);
}
}
/// The frame's own state: the clock remainder, the frame counter, the frame on screen, the mask,
/// and the readout's blocked-direction window.
///
/// The window (`docs/readout.md`) is the player's area and tile as of the last frame the adapter
/// reported one, the channel the group is holding, and the brain clock at which *either* of those
/// last changed. A direction is only blamed once it has been held for a whole `blocked_ms` with no
/// movement, so a direction that has just won is never blamed for a wall the previous one hit.
/// All three are transient and never checkpointed: one hold of a wall after a restart is cheaper
/// than a stale position surviving a restore (`restore: legacy-transient-reset`).
pub struct LegacyFrame {
/// Fractional millisecond carried into the next frame; checkpointed.
pub remainder: f64,
/// Frames the emulator has run in this fly's life; checkpointed.
pub frame_counter: u64,
/// The frame on screen: the last one produced, or a restored slot's.
pub frame_buffer: Vec<u8>,
/// The mask on the joypad; checkpointed.
pub buttons: u32,
pub location: Option<(u32, u32, u32)>,
pub held_channel: Option<String>,
pub blocked_since_ms: f64,
trace: Option<FrameTrace>,
}
impl Default for LegacyFrame {
fn default() -> Self {
Self::new()
}
}
impl LegacyFrame {
/// The state of a fresh process: nothing held, no location, the blocked window starting at
/// brain time 0 (legacy-gameboy-v1 section 14), and a black frame.
pub fn new() -> Self {
Self {
remainder: 0.0,
frame_counter: 0,
frame_buffer: vec![0u8; FRAMEBUFFER_LEN],
buttons: 0,
location: None,
held_channel: None,
blocked_since_ms: 0.0,
trace: None,
}
}
/// Record every phase into `trace` (`FLY_TRACE`).
pub fn with_trace(mut self, trace: Option<FrameTrace>) -> Self {
self.trace = trace;
self
}
/// The trace, when one is on: a host records its admissions and captures through it.
pub fn trace_mut(&mut self) -> Option<&mut FrameTrace> {
self.trace.as_mut()
}
// -- setup -------------------------------------------------------------------------------
/// A fresh start: one frame with no button down, then the brain's warm-up on it
/// (`Environment.Initialize`, legacy-gameboy-v1 section 9). Returns that frame's audio.
pub fn initialize(
&mut self,
emulator: &mut Emulator,
agent: &mut NeuralAgent,
) -> Result<Vec<u8>> {
emulator
.run_frame()
.map_err(|error| anyhow!("running the first frame: {error}"))?;
self.frame_buffer.copy_from_slice(emulator.framebuffer());
self.frame_counter = 1;
let audio = emulator.take_audio_u8();
agent
.warmup(Some(&self.frame_buffer))
.map_err(|error| anyhow!("{error}"))?;
Ok(audio)
}
/// Everything a `FLYSIM01` checkpoint restores into the parts and the frame, in the order the
/// stream restores it; the host checks the cartridge and the compatibility string first.
///
/// The readout transient is left as a fresh process has it (`legacy-transient-reset`), which
/// is what a restart of the service gives the fly. `import_state` of the agent is
/// self-validating, so a refused checkpoint leaves the agent as it was.
pub fn restore(
&mut self,
parts: &mut Parts<'_>,
checkpoint: &crate::store::Checkpoint,
) -> Result<()> {
let runtime = &checkpoint.runtime;
if runtime.framebuffer.len() != FRAMEBUFFER_LEN {
anyhow::bail!(
"checkpoint framebuffer is {} bytes",
runtime.framebuffer.len()
);
}
parts
.agent
.import_state(&checkpoint.agent)
.map_err(|error| anyhow!("{error}"))?;
parts
.emulator
.import_state(&runtime.emulator)
.map_err(|error| anyhow!("{error}"))?;
if !runtime.reward.is_null() {
parts
.adapter
.import_state(&runtime.reward)
.map_err(|error| anyhow!("{error}"))?;
}
let snapshot = if runtime.ratchet_game.is_empty() {
None
} else {
Some(Snapshot {
game: runtime.ratchet_game.clone(),
frame: runtime.ratchet_frame.clone(),
})
};
parts
.ratchet
.import(
Some(runtime.ratchet),
snapshot,
parts.adapter.rank_ladder().len(),
)
.map_err(|error| anyhow!("{error}"))?;
self.remainder = checkpoint.agent.remainder;
self.frame_counter = runtime.emulator_frame;
self.buttons = runtime.buttons;
self.frame_buffer.copy_from_slice(&runtime.framebuffer);
let (width, height) = (parts.agent.frame.width, parts.agent.frame.height);
parts
.agent
.network
.set_visual_frame(&self.frame_buffer, width, height);
parts.emulator.set_buttons(self.buttons as u8);
Ok(())
}
// -- the transition ----------------------------------------------------------------------
/// Transition `k -> k+1`, prepare through commit. The host takes its milestone archive after
/// this and then calls [`LegacyFrame::boundary`].
pub fn transition(
&mut self,
parts: &mut Parts<'_>,
observer: &mut dyn FrameObserver,
) -> Result<Transition> {
let ticks = self.tick(parts.agent);
observer.after(FramePhase::Ticked, parts.agent);
// Not the mode string: the adapter decides what counts as boot, because a platformer
// needs the permissive Start variant in four of its five modes (`GameAdapter::boot`).
let boot = parts.adapter.boot();
// The scene's own macro buttons, for the macro group's per-decision mask
// (`docs/design/macros.md` section 12: "unbound channels are masked from the decision").
// They are the bindings the previous frame's `observe` dealt, which is the palette the
// page is showing, so the fly is choosing among exactly the buttons the audience can see.
let bound = parts.macros.as_deref().map(MacroLayer::bound_channels);
let mut active = self.decode(parts.agent, boot, bound.as_deref());
let ms = parts.agent.network.ms;
observer.readout(ms, bound.as_deref(), &mut active);
observer.before_execute(self, parts, &active);
let raw = to_button_mask(&active);
let executed = self.execute(
parts.macros.as_deref_mut(),
&active,
raw,
ms,
parts.emulator,
&*parts.adapter,
);
observer.executed(self, parts, &executed);
observer.after(FramePhase::Executed, parts.agent);
let audio = self.advance(parts.emulator, parts.agent, observer)?;
observer.after(FramePhase::Advanced, parts.agent);
let evaluated = self.evaluate(
parts.emulator,
parts.adapter,
parts.macros.as_deref_mut(),
ms,
);
self.commit(parts.agent, &evaluated.rewards, ms);
observer.after(FramePhase::Committed, parts.agent);
Ok(Transition {
ticks,
ms,
bound,
active,
executed,
audio,
evaluated,
})
}
/// The rest of phase B and phase C behind a stub readout, for the drivers that measure the
/// macros without a brain (the ROM tests, the scene probe). The driver decodes its stub,
/// calls [`LegacyFrame::execute`] with no raw mask, reads what it measures, and then this runs
/// the frame and evaluates it. There is no commit and no ratchet.
///
/// A stub has no phase A to advance its clock in, so it keeps its own and advances it with the
/// emulator frame: it decides at its clock and evaluates at `evaluate_ms`, one frame later.
pub fn stub_advance(
&mut self,
macros: Option<&mut MacroLayer>,
emulator: &mut Emulator,
adapter: &mut dyn GameAdapter,
evaluate_ms: f64,
) -> Result<Evaluated> {
self.run(emulator)?;
let _ = self.take_frame(emulator);
Ok(self.evaluate(emulator, adapter, macros, evaluate_ms))
}
/// Phase A: brain ticks and the decode, masked to `bound`.
pub fn prepare(
&mut self,
agent: &mut NeuralAgent,
boot: bool,
bound: Option<&[String]>,
) -> (u64, Vec<String>) {
let ticks = self.tick(agent);
(ticks, self.decode(agent, boot, bound))
}
/// Phase A, the ticks: 16 or 17 whole milliseconds, the fraction carried to the next frame.
pub fn tick(&mut self, agent: &mut NeuralAgent) -> u64 {
if let Some(trace) = self.trace.as_mut() {
trace.begin(self.frame_counter, agent.network.ms);
}
self.remainder += agent.ms_per_frame;
let steps = self.remainder.floor();
self.remainder -= steps;
agent.network.step(steps as u64);
if let Some(trace) = self.trace.as_mut() {
trace.ticked(steps as u64, self.remainder, &agent.network);
}
steps as u64
}
/// Phase A, the readout: decode the rates with the blocked direction and the bound channels,
/// and restart the blocked window when the held channel changes.
pub fn decode(
&mut self,
agent: &mut NeuralAgent,
boot: bool,
bound: Option<&[String]>,
) -> Vec<String> {
let ms = agent.network.ms;
let rates = agent.network.rates.clone();
// The readout's blocked-direction cooldown (`docs/readout.md`): the direction the group
// is holding, once the adapter's position has stood still for a whole `blocked_ms`. The
// loop owns the clock and the position; the decoder only learns *which* channel did
// nothing. `blocked_ms == 0` -- the platformer preset, and the Game Boy preset before
// v0.1.1 -- switches the rule off here, before the decoder is asked.
let blocked_ms = agent.decoder.blocked_ms();
let blocked = (blocked_ms > 0.0 && ms - self.blocked_since_ms >= blocked_ms)
.then(|| agent.decoder.current())
.flatten()
.map(str::to_string);
let active = agent
.decoder
.decode_bound(&rates, ms, boot, blocked.as_deref(), bound);
// A new winner starts its own window: it has not had a hold to move in yet.
let held = agent.decoder.current().map(str::to_string);
if held != self.held_channel {
self.held_channel = held;
self.blocked_since_ms = ms;
}
active
}
/// Phase B: the mask. `raw_mask` is the decision's own buttons (`to_button_mask`); in macros
/// mode the mask that reaches the emulator is the running macro's, or nothing, or -- on the
/// title screen alone -- the raw mask (`docs/design/macros.md` sections 4 and 12). In raw mode
/// it is the raw mask.
pub fn execute(
&mut self,
macros: Option<&mut MacroLayer>,
active: &[String],
raw_mask: u32,
ms: f64,
emulator: &mut Emulator,
adapter: &dyn GameAdapter,
) -> Executed {
self.buttons = raw_mask;
let executed = match macros {
// The adapter's exploration ledger answers the ways out' "unvisited" -- read-only, by
// `&dyn`, and the only thing the palette is told about the reward side.
Some(layer) => {
let ledger = AdapterLedger(adapter);
let decision = layer.decide(active, self.buttons, ms, emulator, &ledger);
self.buttons = decision.mask;
Executed {
mask: decision.mask,
events: decision.events,
silence: decision.silence,
}
}
None => Executed {
mask: self.buttons,
..Executed::default()
},
};
if let Some(trace) = self.trace.as_mut() {
trace.decided(active);
trace.executed(self.buttons, &executed.events);
}
executed
}
/// Phase B, the environment: the joypad, one emulator frame, the frame it drew and its audio.
pub fn advance(
&mut self,
emulator: &mut Emulator,
agent: &mut NeuralAgent,
observer: &mut dyn FrameObserver,
) -> Result<Vec<u8>> {
self.run(emulator)?;
observer.after(FramePhase::Emulated, agent);
Ok(self.take_frame(emulator))
}
fn run(&mut self, emulator: &mut Emulator) -> Result<()> {
emulator.set_buttons(self.buttons as u8);
emulator
.run_frame()
.map_err(|error| anyhow!("frame {}: {error}", self.frame_counter + 1))?;
self.frame_counter += 1;
Ok(())
}
fn take_frame(&mut self, emulator: &mut Emulator) -> Vec<u8> {
self.frame_buffer.copy_from_slice(emulator.framebuffer());
if let Some(trace) = self.trace.as_mut() {
trace.advanced(&self.frame_buffer, emulator);
}
emulator.take_audio_u8()
}
/// Phase C: rewards from the frame just produced, then the scene, then the location.
///
/// `docs/design/macros.md` section 2: the scene is sampled once per game frame, after the
/// frame, so the palette the fly is offered on the next frame is the one for the frame it can
/// actually see.
pub fn evaluate(
&mut self,
emulator: &mut Emulator,
adapter: &mut dyn GameAdapter,
macros: Option<&mut MacroLayer>,
ms: f64,
) -> Evaluated {
let rewards = adapter.sample(emulator, ms);
// At most one: a macro that has run into a scene with no palette.
let abandoned = match macros {
Some(layer) => {
let ledger = AdapterLedger(&*adapter);
layer.observe(emulator, &ledger, ms)
}
None => Vec::new(),
};
// The cooldown's other reset: the player actually moved. `None` -- a battle, a script, a
// map transition -- is no information rather than "still", so the rule cannot fire while
// the fly has no control anyway.
let location = adapter.location();
if location.is_some() && location != self.location {
self.location = location;
self.blocked_since_ms = ms;
}
let progress = adapter.progress();
if let Some(trace) = self.trace.as_mut() {
trace.evaluated(&rewards, &abandoned, progress.rank);
}
Evaluated {
rewards,
abandoned,
progress,
}
}
/// Phase D: the frame just produced becomes the next ticks' visual drive, each reward event
/// stimulates once, and the summed value reinforces once.
pub fn commit(&mut self, agent: &mut NeuralAgent, rewards: &[RewardEvent], ms: f64) {
let (width, height) = (agent.frame.width, agent.frame.height);
agent
.network
.set_visual_frame(&self.frame_buffer, width, height);
let mut total = 0.0;
for event in rewards {
agent.network.stimulate(f64::from(event.stimulation_ms));
total += event.value;
}
if agent.network.plasticity.enabled {
agent.network.plasticity.reinforce(total, ms);
}
}
// -- the boundary ------------------------------------------------------------------------
/// `Ready(k+1)`: the ratchet captures on a safe frame above its best, observes, and rolls the
/// game back when it says so.
pub fn boundary(
&mut self,
parts: &mut Parts<'_>,
progress: &ProgressSnapshot,
ms: f64,
) -> Result<Boundary> {
let safe = parts.adapter.safe_for_snapshot();
let capture_due = safe && u64::from(progress.rank) > parts.ratchet.state.best;
let captured = if capture_due {
Some(Snapshot {
game: parts
.emulator
.export_state()
.map_err(|error| anyhow!("capturing a ratchet snapshot: {error}"))?,
frame: self.frame_buffer.clone(),
})
} else {
None
};
// The stall window's second progress signal (`docs/design/ladder.md`, the 2026-09-17
// rule as amended 2026-09-22): the macro layer answers "nearer the objective" with the map
// graph it already walks (`docs/design/macros.md` section 12.15); in raw mode there is no
// layer and no objective, and the answer is false.
let nearer = parts
.macros
.as_deref()
.is_some_and(MacroLayer::nearer_the_objective);
let trace = &mut self.trace;
let mut saved = false;
let recover = parts.ratchet.observe_with_progress(
safe,
u64::from(progress.rank),
progress.unique_locations as u64,
ms as u64,
parts.adapter.game_over(),
nearer,
|| {
let snapshot =
captured.expect("the ratchet only captures when a snapshot was prepared");
if let Some(trace) = trace.as_mut() {
trace.slot_saved(&snapshot.game);
}
saved = true;
snapshot
},
);
let rollback = if recover {
// Two triggers, two stories on the ticker: a game over ended the run, a stall did not.
let trigger = if parts.adapter.game_over() {
RollbackTrigger::GameOver
} else {
RollbackTrigger::Stall
};
let events = self.rollback(parts)?;
Some(Rollback { trigger, events })
} else {
None
};
Ok(Boundary {
captured: saved,
rollback,
})
}
/// The ratchet's game-only rollback (`legacy-ratchet-rollback-v1`): the slot is restored, the
/// brain's holds and eligibility are cleared and it is shown the slot's frame, the buttons are
/// released, the blocked window restarts, and a running macro is abandoned and the restored
/// scene observed. The brain clock, its learning and the ratchet's ledger carry on.
pub fn rollback(&mut self, parts: &mut Parts<'_>) -> Result<Vec<MacroEvent>> {
let snapshot = Snapshot {
game: parts
.ratchet
.game()
.ok_or_else(|| anyhow!("the ratchet asked to recover with no snapshot"))?
.to_vec(),
frame: parts
.ratchet
.frame()
.ok_or_else(|| anyhow!("the ratchet snapshot has no framebuffer"))?
.to_vec(),
};
let frame = {
let mut neural = AgentRecovery { agent: parts.agent };
recover_game(parts.emulator, parts.adapter, &mut neural, &snapshot)
.map_err(|error| anyhow!("recovering the game: {error}"))?
};
self.frame_buffer.copy_from_slice(&frame);
self.buttons = 0;
parts.emulator.set_buttons(0);
let ms = parts.agent.network.ms;
self.location = parts.adapter.location();
self.held_channel = None;
self.blocked_since_ms = ms;
// A rollback restores a game the running macro's plan was never made for, so the macro is
// abandoned rather than carried over a map change it cannot see. The frame's `observe` ran
// before the ratchet decided, so the scene describes the run just thrown away: re-detect
// on the restored game rather than decide the next frame against a map the fly is no
// longer standing on.
let events = match parts.macros.as_deref_mut() {
Some(layer) => {
let mut events = layer.cancel(ms);
let ledger = AdapterLedger(&*parts.adapter);
events.extend(layer.observe(parts.emulator, &ledger, ms));
events
}
None => Vec::new(),
};
if let Some(trace) = self.trace.as_mut() {
trace.rolled_back(&events);
}
Ok(events)
}
/// Write the open transition of the trace, if one is on.
pub fn finish_trace(&mut self) {
if let Some(trace) = self.trace.as_mut() {
trace.finish();
}
}
}

View file

@ -0,0 +1,176 @@
//! The sugar journal: every admitted audience input, stamped with the frame it was applied before.
//!
//! `sugar-journal.jsonl` in `[paths] hot_dir`, one JSON object per line, append-only. It is not
//! part of a checkpoint and nothing reads it back into the fly: it is the record a shadow run
//! (the session framework's CUT-01, `docs/design/session-framework/legacy-gameboy-v1.md` section
//! 15) replays audience input from. The legacy loop applies an admitted sugar at once, in the
//! command drain at the top of a frame, so an input is fully placed by the transition it precedes:
//!
//! ```json
//! {"frame":"6465126","brainMs":108246189,"kind":"sugar","durationMs":400,"by":"viewer","source":"twitch","eventId":81234,"wallMs":1790000000000}
//! ```
//!
//! - `frame` is the frame counter when the input was applied, which is the `step` of the next
//! transition in the frame trace (`crate::trace`): replay applies it before that transition's
//! ticks. A restore carries the frame counter, so stamps continue across restarts.
//! - `kind` is `sugar` (a `reward-pulse` stimulation of `durationMs`, after the admission rules
//! and the clamp) or `reward` (an operator's `POST /reward`, one reinforcement of `value`).
//! - `brainMs` cross-checks the stamp; `eventId` joins the event log; `wallMs` is for people.
//!
//! Refused requests are not journalled: admission is wall-clock policy, and a replay applies what
//! was admitted. A write that fails is a warning, never a refusal: the input has already reached
//! the fly.
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
/// The journal's file name inside the hot directory.
pub const FILE_NAME: &str = "sugar-journal.jsonl";
/// What was applied.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Input {
Sugar { duration_ms: f64 },
Reward { value: f64 },
}
/// One journal line.
#[derive(Debug, Clone, PartialEq)]
pub struct Entry<'a> {
pub frame: u64,
pub brain_ms: f64,
pub input: Input,
pub by: &'a str,
pub source: &'a str,
pub event_id: u64,
pub wall_ms: u64,
}
impl Entry<'_> {
pub fn to_json(&self) -> Value {
let mut line = json!({ "frame": self.frame.to_string(), "brainMs": self.brain_ms });
let map = line.as_object_mut().expect("an object");
match self.input {
Input::Sugar { duration_ms } => {
map.insert("kind".into(), "sugar".into());
map.insert("durationMs".into(), json!(duration_ms));
}
Input::Reward { value } => {
map.insert("kind".into(), "reward".into());
map.insert("value".into(), json!(value));
}
}
map.insert("by".into(), self.by.into());
map.insert("source".into(), self.source.into());
map.insert("eventId".into(), self.event_id.into());
map.insert("wallMs".into(), self.wall_ms.into());
line
}
}
/// The append-only journal. Opened on the first input, so a run nobody feeds writes no file.
pub struct SugarJournal {
path: PathBuf,
file: Option<File>,
}
impl SugarJournal {
pub fn new(hot_dir: &Path) -> Self {
Self {
path: hot_dir.join(FILE_NAME),
file: None,
}
}
pub fn path(&self) -> &Path {
&self.path
}
/// Append one line. Each line is one `write` of the whole line, so a crash leaves at most a
/// torn last line, which a reader skips.
pub fn record(&mut self, entry: &Entry<'_>) {
if self.file.is_none() {
match OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
{
Ok(file) => self.file = Some(file),
Err(error) => {
tracing::warn!(%error, path = %self.path.display(), "could not open the sugar journal");
return;
}
}
}
let mut line = entry.to_json().to_string();
line.push('\n');
if let Some(file) = self.file.as_mut()
&& let Err(error) = file.write_all(line.as_bytes())
{
tracing::warn!(%error, path = %self.path.display(), "could not append to the sugar journal");
// Reopen on the next input rather than write through a broken handle.
self.file = None;
}
}
}
/// Read a journal back: every whole line, in order. A torn or unreadable line is skipped.
pub fn read(path: &Path) -> std::io::Result<Vec<Value>> {
let text = std::fs::read_to_string(path)?;
Ok(text
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter(|value| value.get("frame").is_some())
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inputs_are_appended_with_their_frame_and_survive_a_reopen() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut journal = SugarJournal::new(dir.path());
assert!(
!journal.path().exists(),
"nothing is written before an input"
);
let sugar = Entry {
frame: 42,
brain_ms: 703.0,
input: Input::Sugar { duration_ms: 400.0 },
by: "viewer",
source: "test",
event_id: 7,
wall_ms: 1,
};
journal.record(&sugar);
drop(journal);
let mut journal = SugarJournal::new(dir.path());
journal.record(&Entry {
frame: 43,
input: Input::Reward { value: 0.5 },
event_id: 8,
..sugar
});
// A torn tail from a crash is skipped, not fatal.
std::fs::OpenOptions::new()
.append(true)
.open(journal.path())
.and_then(|mut file| file.write_all(b"{\"frame\":\"44\",\"kin"))
.expect("appending a torn line");
let lines = read(journal.path()).expect("the journal reads back");
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["frame"], "42");
assert_eq!(lines[0]["kind"], "sugar");
assert_eq!(lines[0]["durationMs"], 400.0);
assert_eq!(lines[1]["frame"], "43");
assert_eq!(lines[1]["kind"], "reward");
assert_eq!(lines[1]["value"], 0.5);
assert_eq!(lines[1]["eventId"], 8);
}
}

View file

@ -8,7 +8,8 @@
//! //!
//! ```text //! ```text
//! +-- watch<Snapshot> --> feed :7400/feed (axum + ws) //! +-- watch<Snapshot> --> feed :7400/feed (axum + ws)
//! sim thread ---------+ //! sim thread ---------+ \-> feedbus -> flybus -> fly-edge :7400/feed
//! | (FLY_FEED_VIA=bus instead of the line above)
//! agent +-- Shared ------------> api :7401 (axum) //! agent +-- Shared ------------> api :7401 (axum)
//! emulator | /status /stimulate /reward /checkpoint //! emulator | /status /stimulate /reward /checkpoint
//! adapter | /pause /resume /events /healthz /metrics //! adapter | /pause /resume /events /healthz /metrics
@ -25,6 +26,9 @@ pub mod chat;
pub mod config; pub mod config;
pub mod eventlog; pub mod eventlog;
pub mod feed; pub mod feed;
pub mod feedbus;
pub mod frame;
pub mod journal;
pub mod macros; pub mod macros;
pub mod metrics; pub mod metrics;
pub mod pacing; pub mod pacing;
@ -35,13 +39,14 @@ pub mod sdnotify;
pub mod simloop; pub mod simloop;
pub mod snapshot; pub mod snapshot;
pub mod store; pub mod store;
pub mod trace;
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use tokio::sync::{mpsc, watch}; use tokio::sync::{mpsc, watch};
use crate::config::Config; use crate::config::{Config, FeedVia};
use crate::eventlog::{EventRing, now_wall_ms}; use crate::eventlog::{EventRing, now_wall_ms};
use crate::simloop::{COMMAND_QUEUE, Command, Shared, Sim, booting_snapshot}; use crate::simloop::{COMMAND_QUEUE, Command, Shared, Sim, booting_snapshot};
use crate::snapshot::Snapshot; use crate::snapshot::Snapshot;
@ -59,6 +64,15 @@ impl AppState {
pub fn snapshot(&self) -> Arc<Snapshot> { pub fn snapshot(&self) -> Arc<Snapshot> {
Arc::clone(&self.snapshots.borrow()) Arc::clone(&self.snapshots.borrow())
} }
/// What the feed server needs, when flysim serves the feed itself.
pub fn feed(&self) -> feed::FeedState {
feed::FeedState {
snapshots: self.snapshots.clone(),
metrics: Arc::clone(&self.shared.metrics),
idle_period: self.shared.config.publish_periods().1,
}
}
} }
/// Run the service until a signal or a fatal simulation error. /// Run the service until a signal or a fatal simulation error.
@ -86,10 +100,17 @@ pub fn run(config: Config) -> Result<()> {
let feed_addr = config.feed.bind; let feed_addr = config.feed.bind;
let control_addr = config.control.bind; let control_addr = config.control.bind;
let metrics_addr = config.control.metrics_bind; let metrics_addr = config.control.metrics_bind;
let via = config.feed.via;
let listeners = runtime.block_on(async { let listeners = runtime.block_on(async {
let feed = tokio::net::TcpListener::bind(feed_addr) // In bus mode the feed port belongs to `fly-edge`; binding it here would take it away.
.await let feed = match via {
.with_context(|| format!("binding the feed listener on {feed_addr}"))?; FeedVia::Direct => Some(
tokio::net::TcpListener::bind(feed_addr)
.await
.with_context(|| format!("binding the feed listener on {feed_addr}"))?,
),
FeedVia::Bus => None,
};
let control = tokio::net::TcpListener::bind(control_addr) let control = tokio::net::TcpListener::bind(control_addr)
.await .await
.with_context(|| format!("binding the control listener on {control_addr}"))?; .with_context(|| format!("binding the control listener on {control_addr}"))?;
@ -104,16 +125,42 @@ pub fn run(config: Config) -> Result<()> {
Ok::<_, anyhow::Error>((feed, control, metrics)) Ok::<_, anyhow::Error>((feed, control, metrics))
})?; })?;
let (feed_listener, control_listener, metrics_listener) = listeners; let (feed_listener, control_listener, metrics_listener) = listeners;
tracing::info!(feed = %feed_addr, control = %control_addr, metrics = ?metrics_addr, "listening"); tracing::info!(
feed = %feed_addr,
feed_via = via.as_str(),
control = %control_addr,
metrics = ?metrics_addr,
"listening"
);
{ if let Some(feed_listener) = feed_listener {
let state = state.clone(); let state = state.feed();
runtime.spawn(async move { runtime.spawn(async move {
if let Err(error) = axum::serve(feed_listener, feed::router(state)).await { if let Err(error) = axum::serve(feed_listener, feed::router(state)).await {
tracing::error!(%error, "the feed listener stopped"); tracing::error!(%error, "the feed listener stopped");
} }
}); });
} }
// The bus gets a runtime of its own, so neither its router nor the artifact copies can take
// a worker from the control API; and it is fed from the watch slot, never from the sim thread.
let bus_runtime = match via {
FeedVia::Direct => None,
FeedVia::Bus => {
let bus_runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("flysim-bus")
.enable_all()
.build()
.context("building the bus runtime")?;
let bus = bus_runtime.block_on(feedbus::start_router(&config.feed.bus_dir))?;
bus_runtime.spawn(feedbus::run_publisher(
bus.router.clone(),
state.snapshots.clone(),
Arc::clone(&state.shared.metrics),
));
Some((bus_runtime, bus))
}
};
{ {
let state = state.clone(); let state = state.clone();
runtime.spawn(async move { runtime.spawn(async move {
@ -139,6 +186,13 @@ pub fn run(config: Config) -> Result<()> {
let result = sim.run(&notifier); let result = sim.run(&notifier);
notifier.notify("STOPPING=1\n"); notifier.notify("STOPPING=1\n");
drop(sim); drop(sim);
if let Some((bus_runtime, bus)) = bus_runtime {
// The publisher ends by itself once the watch sender is gone; stopping the runtime under
// it, rather than the router first, keeps a last in-flight publish from being logged as
// a refusal. The edge sees the socket close either way.
drop(bus);
bus_runtime.shutdown_timeout(std::time::Duration::from_secs(1));
}
runtime.shutdown_timeout(std::time::Duration::from_secs(2)); runtime.shutdown_timeout(std::time::Duration::from_secs(2));
result result
} }

Some files were not shown because too many files have changed in this diff Show more