Compare commits

..

No commits in common. "main" and "v0.5.4" have entirely different histories.
main ... v0.5.4

82 changed files with 1873 additions and 10494 deletions

View file

@ -89,9 +89,7 @@ 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, exits found outdoors, catches, conversations indoors and items flags, decaying wild wins, badges. Version `pokered-unique8-v5`.
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
@ -133,9 +131,6 @@ 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`,
@ -164,8 +159,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,feedbus,api,chat,store,eventlog,metrics}.rs`, Where: `services/flysim/crates/flysim/src/{main,config,simloop,pacing,snapshot,feed,api,chat,store,eventlog,metrics}.rs`,
`services/flysim/crates/fly-edge`, `docs/design/flysim.md`. `docs/design/flysim.md`.
## 4. Stage page ## 4. Stage page

View file

@ -1,7 +1,6 @@
# flybus: the communications bus # flybus: the communications bus
Status: **crate landed; the feed rides it behind `FLY_FEED_VIA=bus`, off by default**. Status: **crate landed, nothing wired onto it**. Written 2026-09-22. Index only; the
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).
@ -38,8 +37,8 @@ does not change any published contract by existing.
## Crate layout ## Crate layout
`services/flysim/crates/flybus`, a workspace member of the flysim workspace. `flysim` depends `services/flysim/crates/flybus`, a workspace member of the flysim workspace; no other crate
on it for the feed publisher (`src/feedbus.rs`) and `fly-edge` for the subscriber. depends on it yet.
| Module | Contents | | Module | Contents |
| --- | --- | | --- | --- |
@ -60,131 +59,16 @@ allocate/seal/read with quotas and router restarts, plus the conformance suites
## Wiring still pending ## Wiring still pending
- ~~**flysim publisher.**~~ Done 2026-09-23 behind `FLY_FEED_VIA=bus`: see "Feed over the bus". - **flysim publisher.** Router startup inside the sim service, a store root under its
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. The operator chose the edge process (port decisions, 2026-09-23); for and HTTP surfaces.
the feed it exists (`fly-edge`), and they keep the WebSocket contract unchanged. The - **Sizing.** `max_store_bytes`, `max_retained_bytes` and `max_latest_in_flight` need values
control API (:7401) is the next slice and stays in flysim until then. chosen for 1.2 MB frames at 30 to 60 Hz with a slow consumer, not the defaults.
- ~~**Sizing.**~~ Decided 2026-09-23: amendment "Feed sizing" below. - **Lifecycle.** Orphaned store directories are cleaned only when a new router starts on the
- ~~**Lifecycle.**~~ Decided 2026-09-23: amendment "Feed store lifecycle" below. same root, so service restart order and the store root's location need a decision.
- **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,17 +387,6 @@ 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,28 +133,6 @@ 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
@ -915,83 +893,3 @@ 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,211 +1445,6 @@ 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,10 +177,6 @@ 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

@ -1,6 +1,6 @@
# Rewards and learning # Rewards and learning
The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v7`. The code of record is The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v6`. The code of record is
`services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the `services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the
gates and the rules); this page says what each rule pays for and why it is allowed to. The gates and the rules); this page says what each rule pays for and why it is allowed to. The
prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules
@ -23,10 +23,8 @@ 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. **Nothing on an indoor map** (since v7): the exit is still recorded, and pays 0 | | `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger |
| `catch` | `wildwin` | +0.30, +0.10 | 150 ms | A wild Pokémon kept by a ball: +0.30 for a species this run had never owned, +0.10 for a repeat; at most three payouts per species for the lifetime of the ledger | | `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
@ -45,18 +43,6 @@ 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
@ -96,87 +82,6 @@ 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
@ -205,10 +110,6 @@ 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
@ -276,20 +177,7 @@ body picks the macro; the descending neurons press the buttons.**
## Honesty ## Honesty
The catalog now includes conversations and items (v7). Paying for a conversation is the closest The catalog now includes catches. The honesty panel's copy is not data-driven from the catalog --
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,66 +815,3 @@ 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.

View file

@ -112,11 +112,6 @@ 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
@ -300,9 +295,8 @@ 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-v6 for the deploy # may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 for the deploy
# that adds the engagement rewards (v7; v5 -> v6 was the catch reward's). # that adds the catch reward. It only applies when the adapter segment is the
# 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
@ -355,7 +349,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-v6). The run is kept; flysim applies the same string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v5). The run is kept; flysim applies the same
rule at restore. See docs/design/flysim.md, \"Restoring across an adapter version\", or 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
@ -419,7 +413,6 @@ 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
@ -506,16 +499,6 @@ 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
@ -650,11 +633,9 @@ 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/flyedge=$page_cpus, flycast=$encoder_cpus" log "05-deploy: cpuset partition — flysim=$sim_cpus, xvfb/flystage/flystage-web/pulse/mediamtx=$page_cpus, flycast=$encoder_cpus"
tmp_dropin="$(mktemp)" tmp_dropin="$(mktemp)"
# flyedge is off by default, but its drop-in is written with the rest so that the day for u in flysim xvfb flystage flystage-web flycast pulse mediamtx; do
# 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" ;;

View file

@ -32,9 +32,6 @@ 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}"
@ -71,7 +68,6 @@ 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"
@ -216,11 +212,6 @@ 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)"
@ -344,32 +335,10 @@ 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 "$(feed_metrics_url)/metrics" 2>/dev/null || true)" metrics="$(curl -fsS "${FLY_METRICS_URL}/metrics" 2>/dev/null || true)"
if [ -z "$metrics" ]; then if [ -z "$metrics" ]; then
ok=0 ok=0
else else
@ -797,16 +766,6 @@ 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
@ -857,7 +816,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" or .kind == "reward") | "\(.brainMs)\t\(.label)\t\(.kind)"' -R 2>/dev/null \ | jq -r 'fromjson? // empty | select(.kind == "macro") | "\(.brainMs)\t\(.label)"' -R 2>/dev/null \
|| true || true
} }
@ -865,28 +824,23 @@ 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 US rewards # timeout US done
# #
# `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 next four count each outcome in the window, and `rewards` the reward # the last four count each outcome in the window. `period`/`repeats` describe
# 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" '
# Reward lines are counted and nothing else: the window still ends at the { ms[NR] = $1 + 0; lbl[NR] = $2; n = NR }
# 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\0370\n"; exit } if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\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; rewards = 0 starts = 0; refused = 0; blocked = 0; timeout = 0; done = 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 }
@ -919,9 +873,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\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\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, rewards k, refused, blocked, timeout, done
}' }'
} }
@ -979,21 +933,20 @@ 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 rewards local decisions refused blocked timeouts completed
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 rewards <<< "$analysis" decisions refused blocked timeouts completed <<< "$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}"; rewards="${rewards:-0}" timeouts="${timeouts:-0}"; completed="${completed:-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)"
@ -1014,14 +967,6 @@ 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
@ -1046,11 +991,6 @@ 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"
@ -1082,7 +1022,6 @@ 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" \
@ -1111,7 +1050,6 @@ 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),
@ -1135,9 +1073,7 @@ 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" = "unrewarded" ]; then if [ "$reason" = "stalled" ] || [ "$reason" = "zero-progress" ]; 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,12 +79,6 @@ 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"
@ -112,10 +106,4 @@ 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,9 +11,6 @@
# #
# 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)
@ -75,13 +72,6 @@ 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

@ -6,9 +6,6 @@ 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

@ -20,8 +20,7 @@ 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 (`flysim::frame::LegacyFrame` since 2026-09-23; Both arms are the sim loop's own frame order over the real connectome (`data/fafb-v783`), the real
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,15 +151,6 @@ 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 \
@ -2664,390 +2655,3 @@ 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-v6 infra/05-deploy.sh <release-env> <release-tarball> FLY_ACCEPT_ADAPTERS=pokered-unique8-v5 infra/05-deploy.sh <release-env> <release-tarball>
# The gate logs "the adapter version is the only difference, and it is named; the run is KEPT # 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,14 +602,11 @@ 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`), or 100+ decisions with no reward event among them on two probes in a row (`zero-progress`) — **and** no growth in the exploration count. A decision is a `start` or a
(`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,13 +312,6 @@ 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.
@ -335,9 +328,8 @@ FLY_FEED_VIA=direct
# 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. The v0.6.0 # 05-deploy writes it into /etc/fly/fly.env only while it is set.
# deploy (pokered-unique8-v7, the engagement rewards) is the one that needs: # FLY_ACCEPT_ADAPTERS=pokered-unique8-v5
# 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,20 +154,6 @@ 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,140 +429,6 @@ 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
@ -1199,51 +1065,10 @@ 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 eight cases" pass "check 10: never acts — no unit was restarted across any of the six 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

View file

@ -1,56 +0,0 @@
# 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,10 +30,6 @@ 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,25 +416,6 @@ 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"
@ -503,12 +484,10 @@ 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,7 +1,6 @@
[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

@ -1,36 +0,0 @@
[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

@ -1,344 +0,0 @@
//! `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

@ -1,81 +0,0 @@
//! `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

@ -1,246 +0,0 @@
#![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

@ -1,301 +0,0 @@
//! 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

@ -1,354 +0,0 @@
//! 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-v7".to_owned(), adapter: "pokered-unique8-v6".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-v7/{}/{}/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:{pokered}/statefmt:199616-x86_64-unknown-linux-gnu", "{}/pokered-unique8-v6/{}/{}/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-v7", "adapter": "pokered-unique8-v6",
"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-v7/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"
}, },
"digest": "44916db0a0846d3338f50d4f24d9d4e0214fa239af7d15e4174213cd93b07e28", "digest": "77d8a88ff7fea51eb29b1ae75fc9cf8c17c184e6a1e5a585e86399f41fd9c9b0",
"recipeLines": [ "recipeLines": [
"fly-session/composition-v1", "fly-session/composition-v1",
"session=<sessionId>", "session=<sessionId>",

View file

@ -5202,457 +5202,7 @@
"byteLength": "1048576", "byteLength": "1048576",
"format": "gb-rom" "format": "gb-rom"
}, },
"adapter": "pokered-unique8-v7", "adapter": "pokered-unique8-v6",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": [
"macro_go_objective",
"macro_talk",
"macro_next",
"macro_move_1"
]
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01",
"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"
},
{
"name": "composition in raw mode with macro channels",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "raw",
"macroChannels": [
"macro_go_objective",
"macro_talk",
"macro_next",
"macro_move_1"
]
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01",
"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"
},
{
"name": "composition in macros mode with no channels",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": []
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01",
"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"
},
{
"name": "composition with another executor",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "identity-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": [
"macro_go_objective",
"macro_talk",
"macro_next",
"macro_move_1"
]
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01",
"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"
},
{
"name": "composition with exact restore",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": [
"macro_go_objective",
"macro_talk",
"macro_next",
"macro_move_1"
]
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "exact",
"checkpointFormatOfRecord": "FLYSIM01",
"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"
},
{
"name": "composition without the slots extension",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": [
"macro_go_objective",
"macro_talk",
"macro_next",
"macro_move_1"
]
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"world-step-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01",
"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"
},
{
"name": "composition with a 60 Hz step",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": [
"macro_go_objective",
"macro_talk",
"macro_next",
"macro_move_1"
]
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "50000000",
"denominator": "3"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01",
"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"
},
{
"name": "composition whose compatibility string names another adapter",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros", "mode": "macros",
"macroChannels": [ "macroChannels": [
@ -5695,6 +5245,456 @@
"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-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 profile AssetRef digest is the legacy profile's"
},
{
"name": "composition in raw mode with macro channels",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v6",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "raw",
"macroChannels": [
"macro_go_objective",
"macro_talk",
"macro_next",
"macro_move_1"
]
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"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"
},
"reason": "raw mode deals no macro channels"
},
{
"name": "composition in macros mode with no channels",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v6",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": []
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"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"
},
"reason": "macros mode needs its channels"
},
{
"name": "composition with another executor",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "identity-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v6",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": [
"macro_go_objective",
"macro_talk",
"macro_next",
"macro_move_1"
]
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"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"
},
"reason": "the executor extension is pokered-macros-v1"
},
{
"name": "composition with exact restore",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v6",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": [
"macro_go_objective",
"macro_talk",
"macro_next",
"macro_move_1"
]
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "exact",
"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"
},
"reason": "the legacy composition declares legacy-transient-reset"
},
{
"name": "composition without the slots extension",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v6",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": [
"macro_go_objective",
"macro_talk",
"macro_next",
"macro_move_1"
]
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"world-step-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"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"
},
"reason": "the rollback policy needs gameboy-slots-v1"
},
{
"name": "composition with a 60 Hz step",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v6",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": [
"macro_go_objective",
"macro_talk",
"macro_next",
"macro_move_1"
]
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "50000000",
"denominator": "3"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"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"
},
"reason": "one Game Boy frame is 8572265625/512 ns, not 1/60 s"
},
{
"name": "composition whose compatibility string names another adapter",
"type": "LegacyGameboyComposition",
"value": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v6",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": [
"macro_go_objective",
"macro_talk",
"macro_next",
"macro_move_1"
]
},
"decoderConfigDigest": "5c3f2f084bef674fed492b82e5e0951e993c856ae98f5453d180cd06143dc854",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"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"
},
"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-v7", "adapter": "pokered-unique8-v6",
"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-v7/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": "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-v7", "adapter": "pokered-unique8-v6",
"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-v7/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"
}, },
"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-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\"}", "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\"}",
"digest": "44916db0a0846d3338f50d4f24d9d4e0214fa239af7d15e4174213cd93b07e28" "digest": "77d8a88ff7fea51eb29b1ae75fc9cf8c17c184e6a1e5a585e86399f41fd9c9b0"
}, },
{ {
"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-v7", "adapter": "pokered-unique8-v6",
"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-v7/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"
}, },
"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-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\"}", "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\"}",
"digest": "568eceeb052ebf4fbd0c10661e227783cecc8ac7fee75ae6ba84335c684bfb7c" "digest": "ac42e702ec6c9b09482b1ee335743ef85fe599a72e2c34a4d32aea67d67d0201"
}, },
{ {
"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` does not come through here: its frame (`flysim::frame::LegacyFrame`) drives the /// `flysim`'s sim loop does not come through here (it drives the network and the decoder
/// network and the decoder itself, so that the macro layer can read the emulator between the /// itself, so that the macro layer can read the emulator between the two), but the bench that
/// two, and it installs a frame and its rewards straight after the frame rather than after the /// measures the two arms against each other does, and a bench whose macro group could win a
/// next ticks. Every `flysim` harness runs that frame too. /// channel the scene never bound would be measuring something the stream cannot do.
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-v7` reward adapter, its catalog and its generated symbol table | | `pokemon_red` | The `pokered-unique8-v5` 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`, /// `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, `catch`); it is the
/// `talk`, `item`); it is the key the statistics counters and the on-screen ticker group /// key the statistics counters and the on-screen ticker group by. Field names
/// by. Field names serialize exactly as the prototype's `RewardEvent` did, so a /// serialize exactly as the prototype's `RewardEvent` did, so a checkpoint
/// checkpoint written by either implementation reads in the other. /// 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-v7`. /// Pokémon: `pokered-unique8-v6`.
fn id(&self) -> &'static str; fn id(&self) -> &'static str;
/// Earlier [`GameAdapter::id`]s whose checkpoints this build can read, by a migration /// 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-v7`. /// The adapter's version string, e.g. `pokered-unique8-v6`.
pub adapter: &'a str, pub adapter: &'a str,
/// The dataset's seven SHA-256 digests joined with `:`. /// The dataset's seven SHA-256 digests joined with `:`.
pub dataset_fingerprint: &'a str, pub dataset_fingerprint: &'a str,
@ -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-v6`. /// whitespace-separated adapter ids, e.g. `FLY_ACCEPT_ADAPTERS=pokered-unique8-v5`.
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-v7/aa:bb:cc:dd:ee:ff:00/", "lif-1ms-f64-v2/pokered-unique8-v6/aa:bb:cc:dd:ee:ff:00/",
"fly-kc-mbon-rstdp-v2/", "fly-kc-mbon-rstdp-v2/",
"binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/", "binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/",
"pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b", "pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
@ -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-v7"); let current = with_adapter("pokered-unique8-v6");
assert_eq!(decide(&current, &current, &[], &[]), RestoreDecision::Exact); assert_eq!(decide(&current, &current, &[], &[]), RestoreDecision::Exact);
} }
#[test] #[test]
fn a_v6_checkpoint_restores_under_v7_only_with_the_opt_in() { fn a_v5_checkpoint_restores_under_v6_only_with_the_opt_in() {
let old = with_adapter("pokered-unique8-v6"); let old = with_adapter("pokered-unique8-v5");
let new = with_adapter("pokered-unique8-v7"); let new = with_adapter("pokered-unique8-v6");
let migrates = ["pokered-unique8-v6"]; let migrates = ["pokered-unique8-v5"];
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-v6"))), decide(&old, &new, &migrates, &accepted_adapters(Some("pokered-unique8-v5"))),
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v6".to_string() } RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".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-v6"))), decide(&old, &new, &[], &accepted_adapters(Some("pokered-unique8-v5"))),
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-v6"]; let migrates = ["pokered-unique8-v5"];
let accepted = accepted_adapters(Some("pokered-unique8-v6")); let accepted = accepted_adapters(Some("pokered-unique8-v5"));
let new = with_adapter("pokered-unique8-v7"); let new = with_adapter("pokered-unique8-v6");
// 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-v6", adapter: "pokered-unique8-v5",
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-v6", neural_kernel_version: "lif-1ms-f64-v3", ..fixture() } Compatibility { adapter: "pokered-unique8-v5", 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-v6, pokered-unique8-v5")), accepted_adapters(Some("pokered-unique8-v5, pokered-unique8-v4")),
vec!["pokered-unique8-v6".to_string(), "pokered-unique8-v5".to_string()] vec!["pokered-unique8-v5".to_string(), "pokered-unique8-v4".to_string()]
); );
} }

View file

@ -1,4 +1,4 @@
//! The `pokered-unique8-v7` reward catalog. //! The `pokered-unique8-v6` reward catalog.
//! //!
//! A direct port of the prototype's `src/reward/catalog.ts`, including the //! A direct port of the prototype's `src/reward/catalog.ts`, including the
//! declaration order, which is the order `counts` and `last` serialize in. //! declaration order, which is the order `counts` and `last` serialize in.
@ -21,8 +21,6 @@ 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.
@ -46,7 +44,7 @@ pub struct RewardRule {
pub stimulation_ms: u32, pub stimulation_ms: u32,
} }
pub const REWARDS: [RewardRule; 11] = [ pub const REWARDS: [RewardRule; 9] = [
RewardRule { RewardRule {
kind: kind::MILESTONE, kind: kind::MILESTONE,
label: "Story", label: "Story",
@ -127,30 +125,6 @@ pub const REWARDS: [RewardRule; 11] = [
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
@ -255,17 +229,12 @@ 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 new_rules_are_appended_so_the_older_key_order_does_not_move() { fn the_catch_rule_is_last_so_the_older_key_order_does_not_move() {
let order: Vec<&str> = REWARDS.iter().map(|rule| rule.kind).collect(); let order: Vec<&str> = REWARDS.iter().map(|rule| rule.kind).collect();
assert_eq!( assert_eq!(
order, order,
@ -279,11 +248,9 @@ mod tests {
kind::BADGE, kind::BADGE,
kind::BOUNDARY, kind::BOUNDARY,
kind::CATCH, kind::CATCH,
kind::TALK,
kind::ITEM,
] ]
); );
assert_eq!(index(kind::ITEM), Some(REWARDS.len() - 1)); assert_eq!(index(kind::CATCH), Some(REWARDS.len() - 1));
} }
#[test] #[test]

View file

@ -1,423 +0,0 @@
//! 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,24 +365,6 @@ 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 {
@ -416,38 +398,6 @@ 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,17 +377,6 @@ 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,20 +18,13 @@ 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::{ use super::cartridge::{Areas, Frontiers, MacroState, Pushed, Stood, Talked, Targets, Tile};
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 {
@ -106,19 +99,6 @@ 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
@ -146,46 +126,10 @@ 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()
@ -273,7 +217,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).
while let Some((map, tile)) = self.machine.take_pushed() { if 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
@ -299,10 +243,6 @@ 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,
@ -325,10 +265,7 @@ 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.
// A warp's tear is a warp in flight: the cartridge is driving and the seam's readings let scene = state.scene();
// 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).
@ -342,16 +279,17 @@ 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() && !torn).then(|| state.player()).flatten(); let standing = (!state.scripted()).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(|_| { let approach = standing.and_then(|player| {
let objective = palette::objective_place(&mut state)?; let objective = palette::objective_place(&mut state)?;
let hops = geography::hops(palette::region_here(&mut state)?, objective.map)?; let hops =
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);
@ -517,7 +455,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.
while self.machine.take_pushed().is_some() {} let _ = self.machine.take_pushed();
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
@ -587,79 +525,6 @@ 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,12 +149,6 @@ 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)]
@ -547,16 +541,6 @@ 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
@ -605,7 +589,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: Vec<(u8, Tile)>, pushed_tile: Option<(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`).
@ -653,24 +637,6 @@ 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
@ -698,14 +664,12 @@ impl MacroMachine {
blocked: Vec::new(), blocked: Vec::new(),
reached: None, reached: None,
exhausted: None, exhausted: None,
pushed_tile: Vec::new(), pushed_tile: None,
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 },
} }
@ -939,11 +903,8 @@ 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)> {
if self.pushed_tile.is_empty() { None } else { Some(self.pushed_tile.remove(0)) } self.pushed_tile.take()
} }
/// 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
@ -975,7 +936,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.clear(); self.pushed_tile = None;
self.exhausted = None; self.exhausted = None;
self.refused_at = None; self.refused_at = None;
self.timed_out = None; self.timed_out = None;
@ -987,9 +948,6 @@ 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.
@ -1018,7 +976,6 @@ 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;
@ -1045,49 +1002,6 @@ 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 {
@ -1213,18 +1127,14 @@ 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),
}; };
(player.map, tile) self.pushed_tile = Some((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
@ -1271,15 +1181,8 @@ 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 {
// Held with the tile above, and for the same reason: a trainer's walk up to the self.blocked.push(entry);
// 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,7 +28,6 @@ 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;
@ -45,13 +44,10 @@ 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.
/// ///
/// Every row is the header's own, checked line by line against the disassembly at the pinned /// Two rows are worth a note. `ROUTE_3` and `ROUTE_4` are connected along the east-west axis even
/// commit (row 59). Four pairs had the right neighbour in the wrong column, and a wrong column is /// though Mt. Moon stands between them, so the walkable path is the cave and not the edge; that
/// a wrong map on the other side of an edge: `ROUTE_3` / `ROUTE_4` (Route 4 is north of Route 3, /// costs nothing, because an edge whose tiles are not walkable produces no exit at all
/// not east, and Route 3's top edge is the road to Mt. Moon's Pokécenter), `ROUTE_14` / /// ([`super::path::exits`] filters on the walkable predicate) and the cave is in [`LINKS`].
/// `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]),
@ -66,8 +62,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, [maps::ROUTE_4, NONE, maps::PEWTER_CITY, NONE]), (maps::ROUTE_3, [NONE, NONE, maps::PEWTER_CITY, maps::ROUTE_4]),
(maps::ROUTE_4, [NONE, maps::ROUTE_3, NONE, maps::CERULEAN_CITY]), (maps::ROUTE_4, [NONE, NONE, maps::ROUTE_3, 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]),
@ -77,54 +73,28 @@ 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, NONE, maps::ROUTE_15, maps::ROUTE_13]), (maps::ROUTE_14, [NONE, maps::ROUTE_15, NONE, maps::ROUTE_13]),
(maps::ROUTE_15, [NONE, NONE, maps::FUCHSIA_CITY, maps::ROUTE_14]), (maps::ROUTE_15, [maps::ROUTE_14, NONE, maps::FUCHSIA_CITY, NONE]),
(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]),
(maps::ROUTE_22, [maps::ROUTE_23, NONE, NONE, maps::VIRIDIAN_CITY]), // Route 22 ends at the League gate, which is a building rather than an edge, and this
(maps::ROUTE_23, [maps::INDIGO_PLATEAU, maps::ROUTE_22, NONE, NONE]), // table has no id for it: Indigo Plateau is on the graph but not reachable from the south.
(maps::ROUTE_24, [NONE, maps::CERULEAN_CITY, NONE, maps::ROUTE_25]), (maps::ROUTE_22, [NONE, NONE, NONE, maps::VIRIDIAN_CITY]),
(maps::ROUTE_25, [NONE, NONE, maps::ROUTE_24, NONE]), (maps::ROUTE_23, [maps::INDIGO_PLATEAU, NONE, NONE, 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. Every pair is two warp tables that name each /// at from another map and changes nothing else. A cave with two mouths appears twice, which is
/// other (a `LAST_MAP` door resolved to the one outdoor map whose warps lead in). /// what makes Mt. Moon a way from Route 3 to Route 4.
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),
@ -152,14 +122,10 @@ 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),
// Mt. Moon has two mouths, and both are on Route 4 (`data/maps/objects/Route4.asm`): (18, 5) (maps::MT_MOON_1F, maps::ROUTE_3),
// 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),
@ -175,12 +141,11 @@ 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. Row 59 found three more /// out of the gate it had just walked into, once per hold, for four hours.
/// 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: its index in that row. 0 everywhere else. /// Which piece, for a map [`SPLIT`] has a row for; 0 everywhere else.
pub part: u8, pub part: u8,
} }
@ -189,268 +154,108 @@ 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 }
}
} }
/// One piece of a split map. /// [`SPLIT`]'s two piece numbers.
struct Piece {
/// 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 pieces the player cannot walk between.
struct Split {
map: u8,
pieces: &'static [Piece],
}
/// Route 2's two halves, in [`SPLIT`]'s order.
#[cfg(test)]
const NORTH_PIECE: u8 = 0; const NORTH_PIECE: u8 = 0;
#[cfg(test)]
const SOUTH_PIECE: u8 = 1; const SOUTH_PIECE: u8 = 1;
/// Route 4's two sides of the mountain. /// A map whose walkable ground is in two pieces, and which of its neighbours each piece touches.
#[cfg(test)] struct Split {
const WEST_SIDE: u8 = 0; map: u8,
const EAST_SIDE: u8 = 1; /// The tile rows each piece's own doorway is on, measured from the cartridge: a tile belongs
/// 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],
}
/// Mt. Moon B1F's four chambers, named by what they hold. /// Every map whose ground is in two pieces. One row, and it took four hours of stream to find.
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.
/// ///
/// Every row is measured from the disassembly at the pinned commit: the map's blocks, its /// `ROUTE_2`, surveyed from the cartridge on 2026-09-17 (`docs/design/macros-wram.md`'s method,
/// tileset's blockset and collision list, the tile-pair walls and the ledges, flooded tile by tile /// the run recorded in `infra/docs/macros-traps.md` row 33). The map is 20 by 72 and its warp
/// (`infra/docs/macros-traps.md` row 59 has the method). A map is listed here only when two of its /// table reads:
/// ways out are on different pieces, and the audit ran over every map on the graph.
/// ///
/// - **`ROUTE_2`** (row 33): the forest's north gate at (3, 11) and Pewter's edge; the south gate /// | warp | tile | into |
/// at (3, 43) and Viridian's. Maps 46, 48 and 49 stay off the graph, the module's standing rule /// | ---: | --- | --- |
/// for a building no rung place needs a route through: 49's two doors are both Route 2's own. /// | 0 | (12, 9) | `DIGLETTS_CAVE_ROUTE_2` (46) |
/// - **`ROUTE_4`** (row 59): Mt. Moon stands across it. The west side holds the Pokécenter at /// | 1 | (3, 11) | `VIRIDIAN_FOREST_NORTH_GATE` (47) |
/// (11, 5), the cave mouth at (18, 5) and the road down to Route 3; the east side holds B1F's /// | 2 | (15, 19) | `ROUTE_2_TRADE_HOUSE` (48) |
/// exit at (24, 5) and the ledges down to Cerulean. From the Pewter side the only way east is /// | 3 | (16, 35) | `ROUTE_2_GATE` (49) |
/// through the mountain. /// | 4 | (15, 39) | `ROUTE_2_GATE` (49) |
/// - **`MT_MOON_B1F`** (row 59): four chambers, each two ladders and nothing between them. The /// | 5 | (3, 43) | `VIRIDIAN_FOREST_SOUTH_GATE` (50) |
/// one road through is 1F (5, 5) to the west chamber, (21, 17) down to B2F, B2F (5, 7) up to the ///
/// exit chamber, (27, 3) out onto Route 4's east side. The middle and south chambers are ladders /// with `north: true` and `south: true` in `wCurMapConnections` — Pewter off the top row, Viridian
/// to dead ends on B2F. /// off the bottom one. The two forest gates at rows 11 and 43 are the doorways this splits on.
/// - **`MT_MOON_B2F`** (row 59): the fossil floor, one large piece with the two ladders the road ///
/// uses, and two small pieces under the dead-end ladders. /// Maps 46, 48 and 49 are deliberately *not* on the graph, which is this module's standing rule
const SPLIT: &[Split] = &[ /// for a building no rung place needs a route through: 49's two doors are both warps of `ROUTE_2`
Split { /// itself, so it is a shortcut within one map rather than a way between two, and 46 and 48 are
/// ends of the line. A route the table does not carry is simply not offered; nothing is guessed.
const SPLIT: &[Split] = &[Split {
map: maps::ROUTE_2, map: maps::ROUTE_2,
pieces: &[ north_door: 11,
Piece { south_door: 43,
doors: &[(1, 3, 11)], north: &[maps::PEWTER_CITY, maps::VIRIDIAN_FOREST_NORTH_GATE],
next: &[ south: &[maps::VIRIDIAN_CITY, maps::VIRIDIAN_FOREST_SOUTH_GATE],
Region::whole(maps::PEWTER_CITY), }];
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 pieces of a split map with their numbers, which are their [`Region::part`]s. /// The piece of `map` a tile on row `y` is in.
fn pieces(split: &'static Split) -> impl Iterator<Item = (u8, &'static Piece)> { ///
split.pieces.iter().enumerate().filter_map(|(part, piece)| Some((u8::try_from(part).ok()?, piece))) /// For every map but [`SPLIT`]'s rows this is [`Region::whole`]. Callers pass the player's own
/// 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` the tile `(x, y)` is in, by the doors alone. /// The piece of `map` that `from` opens onto, or `None` when no piece of it touches `from`.
/// ///
/// For every map but [`SPLIT`]'s rows this is [`Region::whole`]. On a split map it is the piece /// This is the reverse of [`region_at`] and it needs no tile: a door or an edge is listed under
/// with the nearest door, counting tiles across and down, the first piece on a tie. That is exact /// exactly one piece, so "which half of Route 2 does the north gate open onto" is a table lookup.
/// for every tile of Route 2's and Route 4's ground, which is where the grid cannot answer /// `None` is an edge the graph does not have -- the south half of Route 2 is not reachable from
/// ([`region_on`]: a ledge is a one-way step the grid does not model), and it is only the fallback /// Pewter City, whatever the map ids alone would suggest.
/// on Mt. Moon's floors, whose chambers wrap round each other. fn region_toward(map: u8, from: u8) -> Option<Region> {
pub fn region_at(map: u8, x: u8, y: u8) -> Region { match split_of(map) {
let Some(split) = split_of(map) else { return Region::whole(map) }; None => Some(Region::whole(map)),
let distance = |piece: &Piece| { Some(split) => {
piece if split.north.contains(&from) {
.doors Some(Region { map, part: NORTH_PIECE })
.iter() } else if split.south.contains(&from) {
.map(|(_, dx, dy)| u16::from(x.abs_diff(*dx)) + u16::from(y.abs_diff(*dy))) Some(Region { map, part: SOUTH_PIECE })
.min() } else {
.unwrap_or(u16::MAX) None
};
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> {
if let Some(split) = split_of(region.map) { let of = |map: u8| region_toward(map, region.map);
return split.pieces.get(usize::from(region.part)).map_or_else(Vec::new, |piece| piece.next.to_vec()); match split_of(region.map) {
} None => neighbours(region.map).into_iter().filter_map(of).collect(),
// A whole map steps onto every piece of a split neighbour that lists it back: Mt. Moon's Some(split) => {
// first floor has a ladder into three of B1F's four chambers. let own = if region.part == NORTH_PIECE { split.north } else { split.south };
let mut out = Vec::new(); own.iter().copied().filter_map(of).collect()
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.
@ -475,20 +280,11 @@ 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 on foot from `map`, doors and edges together, deduplicated and in id order. /// Every map one step 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( out.extend(row.1.iter().copied().filter(|id| *id != NONE));
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 {
@ -500,7 +296,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.iter().zip(EDGES).any(|(other, edge)| *other == map && crossable(*id, edge)) { if row.contains(&map) {
out.push(*id); out.push(*id);
} }
} }
@ -517,23 +313,16 @@ 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, Region> = HashMap::new(); let mut first: HashMap<Region, u8> = 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); first.insert(hop, hop.map);
queue.push_back(hop); queue.push_back(hop);
} }
} }
@ -706,62 +495,6 @@ 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));
@ -787,7 +520,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));
// Out along Route 3, whose only other end is the road up to Mt. Moon. // Through the cave, because Route 3 and Route 4 are the same two maps either way round.
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);
@ -795,169 +528,37 @@ mod tests {
} }
#[test] #[test]
fn a_split_maps_pieces_add_up_and_answer_each_other() { fn a_split_maps_pieces_divide_its_neighbours_between_them() {
// The invariants that keep [`SPLIT`] honest. A typo in any of them is a road that does // The invariant that keeps [`SPLIT`] honest: a piece's own list is a real subset of the
// not exist, or a door that leads nowhere. // map's neighbours, the two pieces together are all of them, and neither claims the same
// neighbour twice. A typo here is a road that does not exist.
for split in SPLIT { for split in SPLIT {
// The pieces' neighbours together are exactly the map's. let mut both: Vec<u8> =
let mut maps_of: Vec<u8> = split.north.iter().chain(split.south.iter()).copied().collect();
split.pieces.iter().flat_map(|piece| piece.next.iter().map(|r| r.map)).collect(); both.sort_unstable();
maps_of.sort_unstable(); let mut once = both.clone();
maps_of.dedup(); once.dedup();
assert_eq!(both, once, "{:#04x} lists a neighbour under both pieces", split.map);
assert_eq!( assert_eq!(
maps_of, both,
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
); );
let mut doors: Vec<u8> = assert_ne!(split.north_door, split.south_door);
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, 3, 11).part, NORTH_PIECE); assert_eq!(region_at(maps::ROUTE_2, 11).part, NORTH_PIECE);
assert_eq!(region_at(maps::ROUTE_2, 3, 43).part, SOUTH_PIECE); assert_eq!(region_at(maps::ROUTE_2, 43).part, SOUTH_PIECE);
assert_eq!(region_at(maps::ROUTE_2, 8, 0).part, NORTH_PIECE, "Pewter's end"); assert_eq!(region_at(maps::ROUTE_2, 0).part, NORTH_PIECE, "Pewter's end");
assert_eq!(region_at(maps::ROUTE_2, 8, 71).part, SOUTH_PIECE, "Viridian's end"); assert_eq!(region_at(maps::ROUTE_2, 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, 10, 30), Region::whole(maps::ROUTE_1)); assert_eq!(region_at(maps::ROUTE_1, 30), Region::whole(maps::ROUTE_1));
assert_eq!(region_at(maps::VIRIDIAN_FOREST_SOUTH_GATE, 4, 7).part, 0); assert_eq!(region_at(maps::VIRIDIAN_FOREST_SOUTH_GATE, 7).part, 0);
} }
#[test] #[test]
@ -977,16 +578,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, 3, 11), maps::PEWTER_CITY), Some(maps::PEWTER_CITY)); assert_eq!(next_hop(region_at(maps::ROUTE_2, 11), maps::PEWTER_CITY), Some(maps::PEWTER_CITY));
assert_eq!( assert_eq!(
next_hop(region_at(maps::ROUTE_2, 3, 43), maps::PEWTER_CITY), next_hop(region_at(maps::ROUTE_2, 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, 3, 11), maps::VIRIDIAN_CITY), next_hop(region_at(maps::ROUTE_2, 11), maps::VIRIDIAN_CITY),
Some(maps::VIRIDIAN_FOREST_NORTH_GATE) Some(maps::VIRIDIAN_FOREST_NORTH_GATE)
); );
} }
@ -1044,11 +645,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, 3, 11), maps::PEWTER_CITY), Some(1)); assert_eq!(hops(region_at(maps::ROUTE_2, 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, 3, 43), maps::PEWTER_CITY), Some(5)); assert_eq!(hops(region_at(maps::ROUTE_2, 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,47 +1422,14 @@ 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(from) = region_here(state) else { return Vec::new() }; let Some(player) = state.player() else { return Vec::new() };
let here = from.map; let here = player.map;
if here == objective.map { if here == objective.map {
return Vec::new(); return Vec::new();
} }
match geography::next_step(from, objective.map) { let hop = geography::next_hop(geography::region_at(here, player.y), objective.map);
Some(hop) => { let aim = hop.unwrap_or(objective.map);
candidates.iter().copied().filter(|exit| leads_to(state, exit, here, hop)).collect() candidates.iter().copied().filter(|exit| exit.destination(here) == Some(aim)).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.
@ -1720,18 +1687,9 @@ 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 targets = objective_targets(state); let mut ranked: Vec<(u32, Tile, TalkTarget)> = 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();
@ -1801,10 +1759,9 @@ pub fn goals_toward(state: &mut dyn MacroState, target: u8) -> Vec<Aim> {
}) })
.collect() .collect()
}; };
let hop = region_here(state).and_then(|from| geography::next_step(from, target)); if let Some(hop) = geography::next_hop(geography::region_at(here, player.y), target) {
if let Some(hop) = hop {
let toward: Vec<Exit> = let toward: Vec<Exit> =
exits.iter().copied().filter(|exit| leads_to(state, exit, here, hop)).collect(); exits.iter().copied().filter(|exit| exit.destination(here) == Some(hop)).collect();
if !toward.is_empty() { if !toward.is_empty() {
return of(toward); return of(toward);
} }
@ -1838,18 +1795,7 @@ 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 {
// Row 58: the room's people, drawn or not. From the Pewter Gym's doormat the only person PlaceKind::Person => path::person_targets(state),
// 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
@ -1927,8 +1873,7 @@ pub const fn move_index(kind: MacroKind) -> Option<u8> {
} }
} }
/// Whether `kind`'s move slot holds a move with PP that the battle engine will not answer with /// Whether `kind`'s move slot holds a move with PP: the four buttons' precondition (section 14).
/// 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:
/// ///
@ -1956,41 +1901,23 @@ 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.
let main = matches!(battle.menu, BattleMenu::Main { .. }); if index == 0 && 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 return index == 0 && matches!(battle.menu, BattleMenu::Moves { cursor: Some(_), .. });
&& (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)
}; };
// **A move the cartridge will answer with nothing is not dealt beside one it will not** let Some(entry) = holds(usize::from(index)) else { return false };
// (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 useful[usize::from(index)] || !any_useful; return true;
} }
// 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,28 +348,6 @@ 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.
@ -430,9 +408,7 @@ 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,
}; };
// A connection the headers name and no step on foot crosses -- Pallet Town's shore -- if !connected {
// 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,22 +536,18 @@ 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 {
self.reachable(x, y).iter().filter(|seen| **seen).count() if self.index(x, y).is_none() {
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;
@ -567,30 +563,7 @@ impl MapGrid {
queue.push_back((nx, ny)); queue.push_back((nx, ny));
} }
} }
Reachable { width: self.width, seen } count
}
}
/// 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()
} }
} }
@ -682,15 +655,6 @@ 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, LAST_MAP, MacroState, Objective, BLOCKED_MINUTES_DEFAULT, CHEAPEST_PURCHASE, Edge, ExitId, FACINGS, 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,8 +89,6 @@ 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,
@ -111,8 +109,6 @@ 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).
@ -227,7 +223,6 @@ 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,
@ -244,7 +239,6 @@ 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(),
@ -642,10 +636,6 @@ 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()
} }
@ -699,10 +689,6 @@ 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()
} }
@ -825,13 +811,6 @@ 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);
} }
@ -844,27 +823,11 @@ fn settle(machine: &mut MacroMachine, world: &mut World) {
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.
@ -3547,11 +3510,7 @@ 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));
let mut machine = MacroMachine::new(0x1234_5678); assert_eq!(run(&mut world, MacroKind::GoRoute), Ok(MacroAbort::Done));
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"
@ -3948,95 +3907,6 @@ 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();
@ -4162,20 +4032,18 @@ 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 reported Route // The rung-11 reading of row 54 (`infra/docs/macros-traps.md`). The cartridge reports Route
// 3's connections as north and west while `geography`'s row carried west and east, so the // 3's connections as north and west; `geography`'s row carries west and east, so the north
// north edge's destination was unnameable -- and an unnameable destination counted as // edge's destination is unnameable -- and an unnameable destination counted as *unvisited*,
// *unvisited*, which made those tiles first-tier for `GO ROUTE` on every hold for ever. Row 59 // which made those tiles first-tier for `GO ROUTE` on every hold for ever, with
// corrected the row itself, and since then every header connection has a row; the rule is // `GO OBJECTIVE` off the pad beside them because nothing on this map leads to the objective.
// 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 = 0x0b; world.map = maps::ROUTE_3;
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 the run has already stood on. // West is Pewter City, which the table does name and the run has stood on.
world.visited.insert(ExitId::Edge(Edge::West)); world.seen_maps.insert(maps::PEWTER_CITY);
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!(
@ -5155,13 +5023,10 @@ 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);
// Row 58: written when the cartridge gives the joypad back in the overworld. let (map, tile) = machine.take_pushed().expect("the script moved the fly: a push-back");
let reached = world.player; assert_eq!(map, maps::PEWTER_CITY);
hand_back(&mut machine, &mut world); assert_ne!(tile, Tile::new(3, 6), "not the tile the walk set out from");
let pushed: Vec<Tile> = world.pushes.iter().copied().collect(); assert_eq!(tile, world.player, "the tile the walk had reached when the script took over");
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.
@ -5177,13 +5042,10 @@ 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);
assert!(world.pushes.is_empty(), "nothing is decided while the cartridge holds the joypad"); let pushed = machine.take_pushed();
// 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!(
world.pushes.iter().copied().collect::<Vec<_>>(), pushed,
vec![Tile::new(3, 3)], Some((world.map, 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"
); );
} }
@ -5336,154 +5198,3 @@ 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-v7`. //! The Pokémon Red reward adapter, `pokered-unique8-v6`.
//! //!
//! A port of the prototype's `src/reward/pokemon-red.ts`. The gates and budgets //! A port of the prototype's `src/reward/pokemon-red.ts`. The gates and budgets
//! are unchanged; `docs/rewards-learning.md` holds the live rule table and the //! are unchanged; `docs/rewards-learning.md` holds the live rule table and the
@ -6,12 +6,9 @@
//! 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; v7 adds //! operator's decision of 2026-09-22, which pays for keeping a wild Pokémon.
//! `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;
@ -36,39 +33,33 @@ use symbols::ram;
/// Adapter version, pinned into the checkpoint compatibility string. /// Adapter version, pinned into the checkpoint compatibility string.
/// ///
/// `v7` is the engagement rules: `talk` and `item` pay, and `boundary` stops paying on an /// `v6` is the `catch` rule. Bumping it is what makes a `v5` checkpoint a decision
/// indoor map (the operator, 2026-09-23). Bumping it is what makes a `v6` checkpoint a /// rather than an accident: the compatibility string is compared whole before a
/// decision rather than an accident: the compatibility string is compared whole before a /// restore is attempted, so a `v5` run is refused by default and resumed only when
/// restore is attempted, so a `v6` run is refused by default and resumed only when the /// the operator names it in `FLY_ACCEPT_ADAPTERS`
/// operator names it in `FLY_ACCEPT_ADAPTERS` ([`crate::compatibility::RestoreDecision`], /// ([`crate::compatibility::RestoreDecision`], `docs/design/flysim.md`). That
/// `docs/design/flysim.md`). That migration is safe in one direction only, and only for this /// migration is safe in one direction only, and only for this pair: `v5`'s ledger is
/// pair: `v6`'s ledger is a `v7` ledger holding no `talk:`, `item:` or `hidden:` keys, and the /// a `v6` ledger with the catch counter absent, and an absent counter reads as zero.
/// first sample after the restore seeds the item keys from the cartridge's own bits, so
/// nothing already picked up pays ([`engage::ItemFlags::seed`]).
/// ///
/// (`v6` was the `catch` rule and migrated `v5` the same way: an absent counter reads as /// (`v5` was the `boundary` rule, and rejected `v4` because a ledger that had never
/// 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; the last two are, because nothing an older ledger holds means /// those is a migration: this one is, because nothing a `v5` ledger holds means
/// something different under the newer rules.) /// something different under `v6`.)
pub const REWARD_ADAPTER: &str = "pokered-unique8-v7"; pub const REWARD_ADAPTER: &str = "pokered-unique8-v6";
/// Adapter ids whose checkpoints `v7` can read. /// Adapter ids whose checkpoints `v6` can read.
/// ///
/// Exactly one, and it is one because the engagement rules add ledger keys and change nothing /// Exactly one, and it is one because the `catch` rule adds a counter and changes nothing else:
/// else a `v6` state holds: every field keeps its name, shape and meaning, the `talk:` keys start /// a `v5` ledger restores as a `v6` ledger with `catchCounts` empty, and every other byte of the
/// empty (no conversation was ever paid), and the `item:`/`hidden:` keys are seeded from the /// state means what it meant. `v4` is not here -- its `seen` ledger holds no `boundary:` keys, so
/// game's own flags on the first sample, so no pickup made under `v6` pays when a rollback /// resuming it would pay a second time for every exit the run had already found -- and neither is
/// un-takes it. The `boundary:` keys a `v6` run earned indoors stay in the ledger and mean what /// `v3`, whose stored rank is a rung on a different ladder.
/// 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-v6"]; pub const MIGRATES_FROM: &[&str] = &["pokered-unique8-v5"];
/// The only cartridge semantic rewards are enabled for. Even the canonical /// The only cartridge semantic rewards are enabled for. Even the canonical
/// pret build stays disabled until reviewed; see `docs/rewards-learning.md`. /// pret build stays disabled until reviewed; see `docs/rewards-learning.md`.
@ -98,19 +89,8 @@ 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
@ -461,13 +441,6 @@ 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,
@ -518,8 +491,6 @@ 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,
@ -664,10 +635,6 @@ 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);
@ -783,13 +750,11 @@ 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 {
@ -884,18 +849,6 @@ 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();
@ -1103,11 +1056,6 @@ 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,
@ -1124,7 +1072,6 @@ 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) {
@ -1165,10 +1112,6 @@ 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,
@ -1181,34 +1124,6 @@ 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,
@ -1372,8 +1287,6 @@ 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} opp={:#04x} \ flags6={:#04x} move={:#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,7 +126,6 @@ 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,18 +410,3 @@ 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,24 +132,6 @@ 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;
@ -202,19 +184,6 @@ 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;
@ -237,54 +206,6 @@ 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 {
@ -359,22 +280,12 @@ 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, no battle decided and not yet begun. /// scripted movement, no warp in flight, not mid-ledge-hop.
/// ///
/// 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, poke::CUR_OPPONENT) == 0 read(memory, ram::wJoyIgnore) == 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
@ -678,114 +589,6 @@ 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
@ -1242,78 +1045,6 @@ 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
@ -1846,14 +1577,8 @@ 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 {
match super::scene::detect(self.memory) { 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> {
@ -1900,10 +1625,6 @@ 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)
} }
@ -1921,27 +1642,6 @@ 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
@ -1955,7 +1655,7 @@ pub fn trainer_engaged(memory: &mut dyn MemoryReader) -> bool {
/// 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) || trainer_engaged(self.memory) !controllable(self.memory)
} }
fn text_open(&mut self) -> bool { fn text_open(&mut self) -> bool {
@ -1970,10 +1670,6 @@ 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,216 +1040,3 @@ 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,38 +23,29 @@ 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
@ -80,15 +71,11 @@ 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-v7"); assert_eq!(reward.id(), "pokered-unique8-v6");
assert_eq!(reward.migrates_from(), ["pokered-unique8-v6"]); assert_eq!(reward.migrates_from(), ["pokered-unique8-v5"]);
assert!(reward.rom_allowed(SUPPORTED_ROM)); assert!(reward.rom_allowed(SUPPORTED_ROM));
assert!(!reward.rom_allowed( assert!(!reward.rom_allowed(
"5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b" "5ca7ba01642a3b27b0cc0b5349b52792795b62d3ed977e98a09390659af96b7b"
@ -1356,541 +1356,3 @@ 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::{MapExit, MemoryReader}; use flybrain_gb::adapter::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, engage}; use flybrain_gb::pokemon_red::{PokemonRedReward, SUPPORTED_ROM};
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,10 +444,7 @@ 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". Since `pokered-unique8-v7` the bedroom is /// payouts are boundary events near the stairs". Two things are checked here that no synthetic
/// *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,
@ -456,11 +453,9 @@ 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 -- and the ledger records them from beside them. /// playable sample and have to be walked to.
/// 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_records_the_bedroom_stairs_and_pays_nothing_indoors() { fn the_boundary_rule_pays_for_the_bedroom_stairs_on_a_real_cartridge() {
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();
@ -503,8 +498,6 @@ fn the_boundary_rule_records_the_bedroom_stairs_and_pays_nothing_indoors() {
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!(
@ -516,7 +509,6 @@ fn the_boundary_rule_records_the_bedroom_stairs_and_pays_nothing_indoors() {
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)
@ -539,19 +531,26 @@ fn the_boundary_rule_records_the_bedroom_stairs_and_pays_nothing_indoors() {
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) = found_at.expect("the stairs were never found"); let (x, y, value) = *boundary.first().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 stairs entered the ledger at ({x}, {y}), not next to the stairs at (7, 1)" "the first boundary payout was at ({x}, {y}), not next to the stairs at (7, 1)"
); );
assert!(boundary.is_empty(), "an indoor exit pays nothing, got {boundary:?}"); assert!(
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,9 +14,8 @@ 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; Nothing in the crate is specific to a game, a brain or a stream. It is a workspace member and
`flysim` embeds a router for the feed (`FLY_FEED_VIA=bus`, `flysim::feedbus`) and `fly-edge` no other crate depends on it yet.
subscribes to it (`docs/design/flybus.md`, "Feed over the bus").
## Layout ## Layout

View file

@ -30,14 +30,12 @@ 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,20 +70,19 @@ use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use flybrain_core::agent::{ use flybrain_core::agent::{
AgentConfig, NeuralAgent, AgentConfig, NeuralAgent, RewardEvent as NeuralReward, TickOptions,
}; };
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; use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask};
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;
@ -104,12 +103,25 @@ fn emulator(rom: &[u8]) -> Emulator {
.expect("binjgb should accept the cartridge") .expect("binjgb should accept the cartridge")
} }
/// Whether a macro owned the buttons of a frame: read as the executor hands the mask over. /// The neural half of a ratchet recovery, exactly as `simloop.rs` wires it.
struct MacroOwned(bool); struct AgentRecovery<'a> {
agent: &'a mut NeuralAgent,
}
impl FrameObserver for MacroOwned { impl NeuralRecovery for AgentRecovery<'_> {
fn executed(&mut self, _frame: &LegacyFrame, parts: &mut Parts<'_>, _executed: &Executed) { fn clear_decoder_holds(&mut self) {
self.0 = parts.macros.as_deref().is_some_and(|layer| layer.running().is_some()); 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);
} }
} }
@ -232,9 +244,11 @@ 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 frame is `flysim::frame::LegacyFrame`, the one the stream runs, so the arm measures the /// The order is `simloop.rs`'s (steps 2 to 10), as `NeuralAgent::tick` expresses it: the frame and
/// wiring under test rather than a second implementation of it. (Before FND-01 the arms ticked the /// the payouts handed to a tick are the ones the previous tick's buttons produced. The macro layer
/// brain through `NeuralAgent::tick`, one frame behind the stream's order.) /// is consulted at exactly the two points the loop consults it — after the decode, before the
/// 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>,
@ -256,39 +270,48 @@ 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() };
if let Start::Fresh { warmup_ms, .. } = start { match start {
Start::Fresh { state, warmup_ms } => {
emulator.import_state(state).expect("the booted state should import");
agent_config.warmup_ms = *warmup_ms; 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 { state, warmup_ms: _ } => { Start::Fresh { .. } => {
emulator.import_state(state).expect("the booted state should import"); agent.warmup(Some(emulator.framebuffer())).expect("warm-up");
frame.frame_buffer.copy_from_slice(emulator.framebuffer()); }
agent.warmup(Some(&frame.frame_buffer)).expect("warm-up"); Start::Live { checkpoint } => {
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
@ -305,62 +328,136 @@ 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));
// One observation before the first frame, as the sim loop takes after a restore, so frame one // The scene has not been observed yet, so the first frame is decided on an empty palette,
// is decided on a real palette. // which presses nothing. That is one frame, and it is the honest starting state.
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 mut parts = Parts { let rewards: Vec<NeuralReward> = payouts
agent: &mut agent, .iter()
emulator: &mut emulator, .map(|event| {
adapter: &mut adapter, NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms))
ratchet: &mut ratchet, })
macros: macros.as_mut(), .collect();
}; let options = TickOptions { rewards: &rewards, boot: adapter.boot(), learn: true };
let mut owned = MacroOwned(false);
let transition = frame.transition(&mut parts, &mut owned).expect("a frame"); // The blocked-direction cooldown's input, as `simloop.rs` computes it.
let ms = transition.ms; let ms = agent.network.ms;
let executed = &transition.executed; let blocked = (blocked_ms > 0.0 && ms - blocked_since_ms >= blocked_ms)
if let Some(layer) = parts.macros.as_deref() { .then(|| agent.decoder.current().map(str::to_string))
if let Some(silence) = executed.silence { .flatten();
// 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 executed.events.iter().filter(|event| event.outcome.is_none()) { for event in decision.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 owned.0 { if layer.running().is_some() {
arm.macro_frames += 1; arm.macro_frames += 1;
} }
*arm.scenes.entry(layer.scene_name()).or_insert(0) += 1;
} }
if executed.mask == 0 { if 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;
for event in &transition.evaluated.rewards { frame.copy_from_slice(emulator.framebuffer());
// 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;
} }
arm.digest = hash(arm.digest, u64::from(executed.mask)); if let Some(layer) = macros.as_mut() {
if let Some((map, x, y)) = frame.location { let ledger = AdapterLedger(&adapter);
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));
} }
let progress = transition.evaluated.progress; // Step 10: the ratchet, with the adapter's own policy.
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 boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary"); let safe = adapter.safe_for_snapshot();
if boundary.rollback.is_some() { let capture_due = safe && u64::from(progress.rank) > ratchet.state.best;
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, AgentConfig, GAMEBOY_MS_PER_FRAME, NeuralAgent, RewardEvent as NeuralReward, TickOptions,
}; };
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,6 +496,28 @@ 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.
@ -644,97 +666,6 @@ 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.
/// ///
@ -766,81 +697,174 @@ 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 = let (hold_ms, blocked_ms) = {
config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group").hold_ms; let group = config.exclusive.as_ref().expect("the Game Boy preset has an exclusive group");
(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 {
Start::Fresh { state, map, warmup_ms } => {
emulator.import_state(state).expect("the starting state should import");
agent_config.warmup_ms = *warmup_ms; 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"));
} }
// The stream's own frame (`flysim::frame::LegacyFrame`), in raw mode: no macro layer. match &start {
let mut frame = LegacyFrame::new(); Start::Fresh { .. } => agent.warmup(Some(emulator.framebuffer())).expect("warm-up"),
let start_map = match &start {
Start::Fresh { state, map, .. } => {
emulator.import_state(state).expect("the starting state should import");
frame.frame_buffer.copy_from_slice(emulator.framebuffer());
agent.warmup(Some(&frame.frame_buffer)).expect("warm-up");
*map
}
Start::Live { checkpoint, rng } => { Start::Live { checkpoint, rng } => {
let mut checkpoint = (*checkpoint).clone(); let mut state = checkpoint.agent.clone();
checkpoint.agent.network.rng = *rng; state.network.rng = *rng;
frame agent.import_state(&state).expect("the checkpoint's agent state should import");
.restore( let (width, height) = (agent.frame.width, agent.frame.height);
&mut Parts { agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height);
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;
let mut escape = Escape { // The sim loop's own order (`simloop.rs`, steps 2 to 10), as `NeuralAgent::tick` expresses it:
before: None, // the frame and the payouts handed to a tick are the ones the previous tick's buttons produced.
hold_start: (frame.location, began_ms), let mut frame = emulator.framebuffer().to_vec();
run_winner: None, let mut payouts: Vec<flybrain_gb::RewardEvent> = Vec::new();
run_length: 0.0, let mut location = adapter.location();
start_map, // The blocked-direction cooldown's window, restarted by a move *or* by a new winner, exactly
exits, // as `simloop.rs` restarts it: a direction that has just won has not had a hold to move in yet.
hold_ms, let mut blocked_since_ms = began_ms;
trace, let mut held_channel: Option<String> = agent.decoder.current().map(str::to_string);
}; 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;
escape.trace.tiles = tiles_at_start; trace.tiles = tiles_at_start;
while agent.network.ms < until { while agent.network.ms < until {
let mut parts = Parts { let rewards: Vec<NeuralReward> = payouts
agent: &mut agent, .iter()
emulator: &mut emulator, .map(|event| {
adapter: &mut adapter, NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms))
ratchet: &mut ratchet, })
macros: None, .collect();
}; 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;
let location = frame.location; // The blocked-direction cooldown's input, computed the way `simloop.rs` computes it: the
if location != location_before { // channel the readout is holding, once the adapter's location has stood still for a whole
// 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);
@ -852,15 +876,40 @@ fn brain_trace(
*trace.exit_tile_frames.entry(tile).or_insert(0) += 1; *trace.exit_tile_frames.entry(tile).or_insert(0) += 1;
} }
// The ratchet, with the adapter's own policy and the checkpoint's own budget. // Step 10: the ratchet, with the adapter's own policy and the checkpoint's own budget.
let progress = transition.evaluated.progress; let progress = adapter.progress();
let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary"); let safe = adapter.safe_for_snapshot();
if boundary.rollback.is_some() { let capture_due = safe && u64::from(progress.rank) > ratchet.state.best;
escape.trace.recoveries += 1; let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot {
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;
escape.hold_start = (frame.location, ms); hold_start = (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) {
@ -876,7 +925,6 @@ 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,12 +189,11 @@ 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_step({from:?}, {:#04x})` = {:?}, neighbours {:?}", "- `next_hop({:?}, {:#04x})` = {:?}, neighbours {:?}",
geography::region_at(player.map, player.y),
objective.map, objective.map,
geography::next_step(from, objective.map), geography::next_hop(geography::region_at(player.map, player.y), objective.map),
geography::neighbours(player.map) geography::neighbours(player.map)
); );
} }
@ -809,8 +808,6 @@ 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!("```");
@ -826,9 +823,18 @@ 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));
legacy.execute(Some(&mut *layer), &active, 0, *ms, gb, &*adapter); let mask = {
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;
legacy.stub_advance(Some(&mut *layer), gb, adapter, *ms).expect("a frame should complete"); adapter.sample(gb, *ms);
{
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);
@ -1347,16 +1353,11 @@ 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( fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) {
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::{FACINGS, MacroState, TalkTarget, TargetKey}; use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, TargetKey};
use flybrain_gb::pokemon_red::macros::path::Way; use flybrain_gb::pokemon_red::macros::path::Way;
use flybrain_gb::pokemon_red::macros::{PokemonPalette, Tile, palette, path}; use flybrain_gb::pokemon_red::macros::{PokemonPalette, 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;
@ -1378,21 +1379,6 @@ fn route_survey(
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.
@ -1440,9 +1426,6 @@ fn route_survey(
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;
@ -1493,12 +1476,7 @@ fn route_survey(
} }
} }
if let Some((name, outcome)) = macros.take_finished() { if let Some((name, outcome)) = macros.take_finished() {
*outcomes *outcomes.entry(format!("{name} {outcome:?}")).or_default() += 1;
.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:?}",
@ -1509,78 +1487,23 @@ fn route_survey(
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 {
@ -1589,15 +1512,8 @@ fn route_survey(
return; return;
}; };
println!( println!(
"\n## Caught on frame {frame} ({:.1} brain minutes): {}\n", "\n## Caught on frame {frame} ({:.1} brain minutes): one button, refused twenty holds running\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:?}");
@ -1609,18 +1525,6 @@ fn route_survey(
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));
@ -1645,46 +1549,16 @@ fn route_survey(
reach reach
); );
} }
// A room small enough to print whole is printed whole, with its people on it (row 58: println!("\n### The fly's own neighbourhood (pushed = `P`, player = `@`)\n\n```");
// the gym's leader is twelve rows from the door). for y in player.y.saturating_sub(3)..=player.y.saturating_add(3) {
let size = state.map_size().expect("a loaded map"); let row: String = (player.x.saturating_sub(6)..=player.x.saturating_add(6))
// `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 walk { match state.walkable(x, y) {
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 => '?',
@ -1692,14 +1566,9 @@ fn route_survey(
} }
}) })
.collect(); .collect();
println!("y{y:2} x{:2}.. {row}", if whole { 0 } else { player.x.saturating_sub(6) }); println!("y{y:2} x{:2}.. {row}", 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);
}
}); });
} }
@ -1720,28 +1589,6 @@ 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.");
@ -1833,7 +1680,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, &checkpoint); route_survey(&mut gb, &mut adapter, &mut ms);
return; return;
} }
@ -1863,8 +1710,6 @@ 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()]);
@ -1874,11 +1719,18 @@ 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));
legacy.execute(Some(&mut layer), &active, 0, ms, &mut gb, &adapter); let mask = {
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;
legacy adapter.sample(&mut gb, ms);
.stub_advance(Some(&mut layer), &mut gb, &mut adapter, ms) {
.expect("a frame should complete"); let ledger = AdapterLedger(&adapter);
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
@ -1946,10 +1798,19 @@ 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 bytes = save_state(&checkpoint, &mut gb, &adapter, frame, &save); 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(&save), &bytes)
.expect("the checkpoint should be writable");
println!( println!(
"\nWrote this state to `{}` ({bytes} bytes).", "\nWrote this state to `{}` ({} 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
@ -2115,36 +1976,3 @@ 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,11 +36,9 @@
//! | `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 is `flysim::frame::LegacyFrame`, the one the stream runs, restored the way the stream //! The frame order is `simloop.rs`'s, as `examples/palette_bench.rs` expresses it, so what this
//! restores it, so what this measures is the loop that ships rather than a second implementation //! measures is the loop that ships rather than a second implementation of it. Without a
//! 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.
@ -48,19 +46,18 @@ 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}; use flybrain_core::agent::{AgentConfig, NeuralAgent, RewardEvent as NeuralReward, TickOptions};
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; use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask};
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;
@ -157,6 +154,28 @@ 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)>);
@ -228,12 +247,6 @@ 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,
} }
@ -293,161 +306,6 @@ 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(
@ -461,6 +319,8 @@ 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();
@ -473,28 +333,34 @@ 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"));
} }
// The stream's own restore, into the stream's own frame: a fresh process's readout transient agent.import_state(&checkpoint.agent).expect("the checkpoint's agent should import");
// (`restore: legacy-transient-reset`), which is what the fly has after the service restarts. let (width, height) = (agent.frame.width, agent.frame.height);
let mut frame = LegacyFrame::new() agent.network.set_visual_frame(&checkpoint.runtime.framebuffer, width, height);
.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();
@ -519,23 +385,21 @@ 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 rank = adapter.progress().rank; 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 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;
let mut hunt = Hunt {
stub,
stub_hold: 0,
stub_next_ms: f64::NEG_INFINITY,
hold_ms,
running: None,
// The scene of the frames in a row, for "stuck in a text box" against "in and out of one". // The scene of the frames in a row, for "stuck in a text box" against "in and out of one".
scene_run: ("", 0, began_ms), let mut scene_run: (&'static str, u64, f64) = ("", 0, began_ms);
dialog_map: None, let mut trace = Trace {
battle_sub: None,
trace: Trace {
steps: Vec::new(), steps: Vec::new(),
starts: Vec::new(), starts: Vec::new(),
episodes: Vec::new(), episodes: Vec::new(),
@ -559,73 +423,184 @@ fn run(
battle_frames: BTreeMap::new(), battle_frames: BTreeMap::new(),
battle_starts: BTreeMap::new(), battle_starts: BTreeMap::new(),
battle_pads: 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, wall_seconds: 0.0,
seeded: seeded_note, seeded: seeded_note,
refusals: BTreeMap::new(), refusals: BTreeMap::new(),
refusal_run: (None, 0), refusal_run: (None, 0),
longest_refusal_run: (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 mut parts = Parts { let rewards: Vec<NeuralReward> = payouts
agent: &mut agent, .iter()
emulator: &mut emulator, .map(|event| {
adapter: &mut adapter, NeuralReward::with_stimulation(event.value, f64::from(event.stimulation_ms))
ratchet: &mut ratchet, })
macros: macros.as_mut(), .collect();
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 transition = frame.transition(&mut parts, &mut hunt).expect("a frame"); let held = agent.decoder.current().map(str::to_string);
let ms = transition.ms; if held != held_channel {
hunt.trace.frames += 1; held_channel = held;
for payout in &transition.evaluated.rewards { blocked_since_ms = ms;
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,
});
}
}
}
}
} }
{ {
use flybrain_gb::MemoryReader; let text = flybrain_gb::pokemon_red::state::text_box(&mut emulator);
let trace = &mut hunt.trace; let (corners, border) =
let fighting = flybrain_gb::pokemon_red::state::dialog_border(&mut emulator);
parts.emulator.read8(flybrain_gb::pokemon_red::symbols::ram::wIsInBattle) != 0; match (text.open, corners, border) {
let won = transition (true, true, true) => trace.font_corners_border += 1,
.evaluated (true, true, false) => trace.font_corners_no_border += 1,
.rewards (true, false, _) => trace.font_no_corners += 1,
.iter() (false, true, _) => trace.corners_no_font += 1,
.any(|payout| matches!(payout.kind, "battle" | "trainer")); (false, false, _) => {}
match (fighting, trace.battle_now.as_mut()) {
(true, Some(battle)) => {
battle.0 += 1;
battle.2 |= won;
} }
(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) => {} if let Some(layer) = macros.as_ref() {
let name = layer.scene_name();
*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;
}
}
}
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 = parts.macros.as_deref().map_or("", MacroLayer::scene_name); let scene = macros.as_ref().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(parts.emulator).err(); let refusal = flybrain_gb::pokemon_red::state::map_grid(&mut emulator).err();
let mut state = flybrain_gb::pokemon_red::state::PokeState::new(parts.emulator); let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut 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| {
@ -633,18 +608,22 @@ 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(parts.emulator); let why = flybrain_gb::pokemon_red::scene::why_unknown(&mut 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 location = frame.location; let now = adapter.location();
if let Some((map, x, y)) = location { if now.is_some() && now != location {
hunt.trace.steps.push((ms, map, x, y)); location = now;
blocked_since_ms = ms;
} }
if let Some(run) = hunt.running.as_mut() { if let Some((map, x, y)) = location {
trace.steps.push((ms, map, x, y));
}
if let Some(run) = 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);
@ -656,18 +635,47 @@ fn run(
} }
} }
let progress = transition.evaluated.progress; let progress = adapter.progress();
if progress.rank != rank { if progress.rank != rank {
rank = progress.rank; rank = progress.rank;
hunt.trace.rungs.push((rank, progress.rank_label, ms - began_ms)); trace.rungs.push((rank, progress.rank_label, ms - began_ms));
} }
let boundary = frame.boundary(&mut parts, &progress, ms).expect("the boundary"); let safe = adapter.safe_for_snapshot();
if boundary.rollback.is_some() { let capture_due = safe && u64::from(progress.rank) > ratchet.state.best;
hunt.trace.recoveries += 1; let captured = capture_due.then(|| flybrain_gb::ratchet::Snapshot {
hunt.running = None; 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);
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(),
@ -979,7 +987,6 @@ 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 {
@ -1010,43 +1017,3 @@ 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`, `FLY_FEED_VIA`, `FLY_BUS_DIR`, `RAYON_NUM_THREADS`); //! `FLY_MACRO_MODE`, `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,35 +104,6 @@ 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)]
@ -226,8 +197,6 @@ 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"),
} }
} }
} }
@ -286,12 +255,6 @@ 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)?;
} }
@ -367,12 +330,6 @@ 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)?;
} }
@ -459,16 +416,6 @@ 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)");
} }
@ -517,14 +464,6 @@ 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()
@ -748,44 +687,6 @@ 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,14 +9,8 @@
//! - 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};
@ -25,22 +19,9 @@ 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)]
@ -56,7 +37,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: FeedState) -> Router { pub fn router(state: AppState) -> Router {
Router::new() Router::new()
.route("/feed", any(upgrade)) .route("/feed", any(upgrade))
.fallback(not_found) .fallback(not_found)
@ -67,11 +48,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<FeedState>) -> Response { async fn upgrade(upgrade: WebSocketUpgrade, State(state): State<AppState>) -> 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: FeedState) { async fn serve_client(mut socket: WebSocket, state: AppState) {
let Some(hello) = read_hello(&mut socket).await else { let Some(hello) = read_hello(&mut socket).await else {
return; return;
}; };
@ -83,9 +64,9 @@ async fn serve_client(mut socket: WebSocket, state: FeedState) {
spikes = wants.spikes, spikes = wants.spikes,
"feed client connected" "feed client connected"
); );
state.metrics.client_joined(); state.shared.metrics.client_joined();
let result = pump(&mut socket, &state, wants).await; let result = pump(&mut socket, &state, wants).await;
state.metrics.client_left(); state.shared.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"),
@ -136,9 +117,9 @@ async fn read_hello(socket: &mut WebSocket) -> Option<ClientHello> {
None None
} }
async fn pump(socket: &mut WebSocket, state: &FeedState, wants: Wants) -> Result<(), axum::Error> { async fn pump(socket: &mut WebSocket, state: &AppState, wants: Wants) -> Result<(), axum::Error> {
let mut receiver = state.snapshots.clone(); let mut receiver = state.snapshots.clone();
let idle_period = state.idle_period; let (_, idle_period) = state.shared.config.publish_periods();
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
@ -180,18 +161,18 @@ async fn pump(socket: &mut WebSocket, state: &FeedState, wants: Wants) -> Result
async fn send( async fn send(
socket: &mut WebSocket, socket: &mut WebSocket,
state: &FeedState, state: &AppState,
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.metrics.feed_dropped, seq - *last_seq - 1); Metrics::add(&state.shared.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.metrics.frames_sent); Metrics::incr(&state.shared.metrics.frames_sent);
Ok(()) Ok(())
} }

View file

@ -1,354 +0,0 @@
//! 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

@ -1,705 +0,0 @@
//! 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

@ -1,176 +0,0 @@
//! 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,8 +8,7 @@
//! //!
//! ```text //! ```text
//! +-- watch<Snapshot> --> feed :7400/feed (axum + ws) //! +-- watch<Snapshot> --> feed :7400/feed (axum + ws)
//! sim thread ---------+ \-> feedbus -> flybus -> fly-edge :7400/feed //! sim thread ---------+
//! | (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
@ -26,9 +25,6 @@ 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;
@ -39,14 +35,13 @@ 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, FeedVia}; use crate::config::Config;
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;
@ -64,15 +59,6 @@ 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.
@ -100,17 +86,10 @@ 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 {
// In bus mode the feed port belongs to `fly-edge`; binding it here would take it away. let feed = tokio::net::TcpListener::bind(feed_addr)
let feed = match via {
FeedVia::Direct => Some(
tokio::net::TcpListener::bind(feed_addr)
.await .await
.with_context(|| format!("binding the feed listener on {feed_addr}"))?, .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}"))?;
@ -125,42 +104,16 @@ 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!( tracing::info!(feed = %feed_addr, control = %control_addr, metrics = ?metrics_addr, "listening");
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.feed(); let state = state.clone();
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 {
@ -186,13 +139,6 @@ 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
} }

View file

@ -43,10 +43,6 @@ pub struct Metrics {
pub lag_ms: AtomicU64, pub lag_ms: AtomicU64,
/// 1 when the restore fell back past the newest candidate. /// 1 when the restore fell back past the newest candidate.
pub restore_fallback: AtomicU64, pub restore_fallback: AtomicU64,
/// Snapshots published on the feed bus (`FLY_FEED_VIA=bus`); 0 in direct mode.
pub bus_published: AtomicU64,
/// Snapshots the feed bus refused or could not take; each one is skipped, never retried.
pub bus_publish_failures: AtomicU64,
} }
impl Metrics { impl Metrics {
@ -85,7 +81,7 @@ impl Metrics {
} }
/// One metric line plus its help and type headers. /// One metric line plus its help and type headers.
pub fn metric(out: &mut String, name: &str, kind: &str, help: &str, value: impl std::fmt::Display) { fn metric(out: &mut String, name: &str, kind: &str, help: &str, value: impl std::fmt::Display) {
use std::fmt::Write as _; use std::fmt::Write as _;
let _ = writeln!(out, "# HELP {name} {help}"); let _ = writeln!(out, "# HELP {name} {help}");
let _ = writeln!(out, "# TYPE {name} {kind}"); let _ = writeln!(out, "# TYPE {name} {kind}");
@ -118,20 +114,6 @@ pub fn render(metrics: &Metrics, snapshot: &Snapshot, now_wall_ms: u64) -> Strin
"Snapshots superseded before a slow client could be sent them.", "Snapshots superseded before a slow client could be sent them.",
Metrics::get(&metrics.feed_dropped), Metrics::get(&metrics.feed_dropped),
); );
metric(
&mut out,
"fly_bus_published_total",
"counter",
"Snapshots published on the feed bus (FLY_FEED_VIA=bus).",
Metrics::get(&metrics.bus_published),
);
metric(
&mut out,
"fly_bus_publish_failures_total",
"counter",
"Snapshots the feed bus did not take; skipped, like any superseded snapshot.",
Metrics::get(&metrics.bus_publish_failures),
);
metric( metric(
&mut out, &mut out,
"fly_snapshots_published_total", "fly_snapshots_published_total",

View file

@ -321,7 +321,7 @@ mod tests {
wall_ms: 1_700_000_000_000, wall_ms: 1_700_000_000_000,
rom_sha256: "ab".repeat(32), rom_sha256: "ab".repeat(32),
emulator_frame: 12_345, emulator_frame: 12_345,
compatibility: "kernel/pokered-unique8-v7/fingerprint".to_string(), compatibility: "kernel/pokered-unique8-v6/fingerprint".to_string(),
speed: 1.0, speed: 1.0,
buttons: 0, buttons: 0,
rank_since_ms: 4_242.0, rank_since_ms: 4_242.0,

View file

@ -8,8 +8,7 @@
//! //!
//! The per-frame order is the prototype worker's (`fly-plays-pokemon/src/simulation.worker.ts`, //! The per-frame order is the prototype worker's (`fly-plays-pokemon/src/simulation.worker.ts`,
//! `tick()`), not `NeuralAgent::tick`'s argument order, because the prototype samples reward //! `tick()`), not `NeuralAgent::tick`'s argument order, because the prototype samples reward
//! inside the same frame it produced. It lives in `crate::frame::LegacyFrame`, the one copy every //! inside the same frame it produced:
//! harness runs too; in outline:
//! //!
//! 1. drain commands //! 1. drain commands
//! 2. step the brain 16 or 17 ms (the fractional remainder carries and is checkpointed) //! 2. step the brain 16 or 17 ms (the fractional remainder carries and is checkpointed)
@ -31,13 +30,14 @@ use anyhow::{Context, Result, anyhow, bail};
use flybrain_core::agent::{AgentConfig, NeuralAgent}; 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::DecoderConfig; use flybrain_core::decoder::DecoderConfig;
use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros; use flybrain_core::decoder::gameboy::{gameboy_decoder_config_with_macros, to_button_mask};
use flybrain_core::decoder::platformer::platformer_decoder_config; use flybrain_core::decoder::platformer::platformer_decoder_config;
use flybrain_core::lif::SweepPlan; use flybrain_core::lif::SweepPlan;
use flybrain_gb::adapter::{DecoderPresetId, GameAdapter, ProgressSnapshot}; use flybrain_gb::adapter::{DecoderPresetId, GameAdapter, ProgressSnapshot};
use flybrain_gb::macros::AdapterLedger; use flybrain_gb::macros::AdapterLedger;
use flybrain_gb::emulator::{DEFAULT_AUDIO_FRAMES, Emulator}; use flybrain_gb::emulator::{DEFAULT_AUDIO_FRAMES, Emulator, FRAMEBUFFER_LEN};
use flybrain_gb::ratchet::Ratchet; use flybrain_gb::ratchet::Ratchet;
use flybrain_gb::recovery::{NeuralRecovery, recover_game};
use serde::Serialize; use serde::Serialize;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use tokio::sync::{mpsc, oneshot, watch}; use tokio::sync::{mpsc, oneshot, watch};
@ -45,8 +45,6 @@ use tokio::sync::{mpsc, oneshot, watch};
use crate::chat::{ChatLimiter, ChatRefusal, ChatRing, DenyList, RejectReason}; use crate::chat::{ChatLimiter, ChatRefusal, ChatRing, DenyList, RejectReason};
use crate::config::Config; use crate::config::Config;
use crate::eventlog::{EventLog, EventRing, NewEvent, now_wall_ms, utc_day}; use crate::eventlog::{EventLog, EventRing, NewEvent, now_wall_ms, utc_day};
use crate::journal::{Input, SugarJournal};
use crate::frame::{FrameObserver, FramePhase, LegacyFrame, Parts, RollbackTrigger};
use crate::macros::{MacroEvent, MacroLayer, macro_layer}; use crate::macros::{MacroEvent, MacroLayer, macro_layer};
use crate::metrics::Metrics; use crate::metrics::Metrics;
use crate::pacing::{Pacer, RealtimeWindow}; use crate::pacing::{Pacer, RealtimeWindow};
@ -58,7 +56,6 @@ use crate::snapshot::{
RewardCounts, RewardKind, Snapshot, f32_bytes, finite, spike_bitset, RewardCounts, RewardKind, Snapshot, f32_bytes, finite, spike_bitset,
}; };
use crate::store::{self, RuntimeState, Store}; use crate::store::{self, RuntimeState, Store};
use crate::trace::FrameTrace;
/// Commands the control API queues for the sim thread. /// Commands the control API queues for the sim thread.
#[derive(Debug)] #[derive(Debug)]
@ -152,7 +149,7 @@ pub struct DecoderChannelStatus {
#[derive(Debug)] #[derive(Debug)]
pub struct Shared { pub struct Shared {
pub config: Config, pub config: Config,
pub metrics: Arc<Metrics>, pub metrics: Metrics,
pub events: EventRing, pub events: EventRing,
/// `Date.now()` at the top of the most recent loop iteration. `GET /healthz` is 200 while /// `Date.now()` at the top of the most recent loop iteration. `GET /healthz` is 200 while
/// this is less than two seconds old, which is true while paused as well: a paused loop is /// this is less than two seconds old, which is true while paused as well: a paused loop is
@ -176,7 +173,7 @@ impl Shared {
pub fn new(config: Config, events: EventRing) -> Self { pub fn new(config: Config, events: EventRing) -> Self {
Self { Self {
config, config,
metrics: Arc::default(), metrics: Metrics::default(),
events, events,
heartbeat_ms: AtomicU64::new(0), heartbeat_ms: AtomicU64::new(0),
versions: OnceLock::new(), versions: OnceLock::new(),
@ -276,26 +273,25 @@ pub fn booting_snapshot(seq: u64, wall_ms: u64, mode: MacroMode) -> Snapshot {
} }
} }
/// The stream's only look inside the frame: the per-phase profile (`crate::profile`). /// The neural half of a ratchet recovery, wired to `flybrain-core`.
struct Laps<'a> { struct AgentRecovery<'a> {
profiler: &'a mut Profiler, agent: &'a mut NeuralAgent,
} }
impl FrameObserver for Laps<'_> { impl NeuralRecovery for AgentRecovery<'_> {
fn after(&mut self, phase: FramePhase, agent: &mut NeuralAgent) { fn clear_decoder_holds(&mut self) {
match phase { let ms = self.agent.network.ms;
FramePhase::Ticked => { self.agent.decoder.clear_holds(ms);
self.profiler.lap(Phase::Step);
if self.profiler.enabled() {
self.profiler.absorb_brain(agent.network.timings());
agent.network.reset_timings();
} }
fn clear_eligibility(&mut self) {
let ms = self.agent.network.ms;
self.agent.network.plasticity.clear_eligibility(ms);
} }
FramePhase::Executed => self.profiler.lap(Phase::Decode),
FramePhase::Emulated => self.profiler.lap(Phase::Emulate), fn set_visual_frame(&mut self, frame: &[u8]) {
FramePhase::Advanced => self.profiler.lap(Phase::Retina), let (width, height) = (self.agent.frame.width, self.agent.frame.height);
FramePhase::Committed => self.profiler.lap(Phase::Rewards), self.agent.network.set_visual_frame(frame, width, height);
}
} }
} }
@ -340,11 +336,24 @@ pub struct Sim {
next_generation: u64, next_generation: u64,
best_archived_rank: Option<u32>, best_archived_rank: Option<u32>,
/// The frame order and its state: the remainder, the frame counter, the frame on screen, the /// Fractional millisecond carried into the next frame, exactly as `NeuralAgent` keeps it.
/// mask and the readout's blocked-direction window (`crate::frame`). remainder: f64,
frame: LegacyFrame, frame_counter: u64,
frame_buffer: Vec<u8>,
pending_audio: Vec<f32>, pending_audio: Vec<f32>,
dc_blocker: DcBlocker, dc_blocker: DcBlocker,
buttons: u32,
/// The readout's blocked-direction cooldown (`docs/readout.md`), as the loop computes it: 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 deliberately not checkpointed: one hold of a wall after a
/// restart is cheaper than a stale position surviving a restore.
location: Option<(u32, u32, u32)>,
held_channel: Option<String>,
blocked_since_ms: f64,
/// Palette mode (`docs/design/macros.md`), or `None` in raw mode — which is the default and /// Palette mode (`docs/design/macros.md`), or `None` in raw mode — which is the default and
/// which is byte for byte the behaviour that predates it: every call site below is inside an /// which is byte for byte the behaviour that predates it: every call site below is inside an
@ -376,9 +385,6 @@ pub struct Sim {
sugar_last_by: Option<String>, sugar_last_by: Option<String>,
sugar_today: u64, sugar_today: u64,
sugar_day: String, sugar_day: String,
/// Every admitted sugar and operator pulse, frame-stamped, in the hot directory
/// (`crate::journal`): what a shadow run replays. Not checkpointed.
journal: SugarJournal,
/// The chat path. None of it is wired to the agent, the emulator or plasticity. /// The chat path. None of it is wired to the agent, the emulator or plasticity.
chat_ring: ChatRing, chat_ring: ChatRing,
@ -560,14 +566,16 @@ impl Sim {
writer_thread: None, writer_thread: None,
next_generation: 1, next_generation: 1,
best_archived_rank: None, best_archived_rank: None,
// The per-frame trace, off unless `FLY_TRACE` names a file (`crate::trace`). remainder: 0.0,
frame: LegacyFrame::new().with_trace( frame_counter: 0,
FrameTrace::from_env() frame_buffer: vec![0u8; FRAMEBUFFER_LEN],
.with_context(|| format!("creating the {} file", crate::trace::ENV))?,
),
pending_audio: Vec::new(), pending_audio: Vec::new(),
dc_blocker: DcBlocker::default(), dc_blocker: DcBlocker::default(),
buttons: 0,
status: FeedStatus::Booting, status: FeedStatus::Booting,
location: None,
held_channel: None,
blocked_since_ms: 0.0,
pending_recovery: false, pending_recovery: false,
seq: 0, seq: 0,
started, started,
@ -581,7 +589,6 @@ impl Sim {
sugar_last_by: None, sugar_last_by: None,
sugar_today: 0, sugar_today: 0,
sugar_day: utc_day(now_wall_ms()), sugar_day: utc_day(now_wall_ms()),
journal: SugarJournal::new(&config.paths.hot_dir),
chat_ring: ChatRing::new(config.chat.ring), chat_ring: ChatRing::new(config.chat.ring),
chat_limits: ChatLimiter::default(), chat_limits: ChatLimiter::default(),
deny_list: if config.chat.enabled { deny_list: if config.chat.enabled {
@ -679,7 +686,7 @@ impl Sim {
} }
tracing::info!( tracing::info!(
origin = %candidate.origin, origin = %candidate.origin,
frame = self.frame.frame_counter, frame = self.frame_counter,
brain_ms = self.agent.network.ms, brain_ms = self.agent.network.ms,
rank = self.rank, rank = self.rank,
"restored" "restored"
@ -742,21 +749,46 @@ impl Sim {
self.compatibility self.compatibility
), ),
} }
if runtime.framebuffer.len() != FRAMEBUFFER_LEN {
bail!("checkpoint framebuffer is {} bytes", runtime.framebuffer.len());
}
// `import_state` is self-validating and a no-op on failure, so a refused checkpoint // `import_state` is self-validating and a no-op on failure, so a refused checkpoint
// leaves the agent exactly as it was and the next candidate starts clean. // leaves the agent exactly as it was and the next candidate starts clean.
{ self.agent
let mut parts = Parts { .import_state(&checkpoint.agent)
agent: &mut self.agent, .map_err(|error| anyhow!("{error}"))?;
emulator: &mut self.emulator, self.emulator
adapter: self.adapter.as_mut(), .import_state(&runtime.emulator)
ratchet: &mut self.ratchet, .map_err(|error| anyhow!("{error}"))?;
macros: self.macros.as_mut(), if !runtime.reward.is_null() {
}; self.adapter
self.frame.restore(&mut parts, &checkpoint)?; .import_state(&runtime.reward)
.map_err(|error| anyhow!("{error}"))?;
} }
let snapshot = if runtime.ratchet_game.is_empty() {
None
} else {
Some(flybrain_gb::ratchet::Snapshot {
game: runtime.ratchet_game.clone(),
frame: runtime.ratchet_frame.clone(),
})
};
self.ratchet
.import(Some(runtime.ratchet), snapshot, self.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.rank_since_ms = runtime.rank_since_ms; self.rank_since_ms = runtime.rank_since_ms;
self.frame_buffer.copy_from_slice(&runtime.framebuffer);
let (width, height) = (self.agent.frame.width, self.agent.frame.height);
self.agent
.network
.set_visual_frame(&self.frame_buffer, width, height);
self.rank = self.adapter.progress().rank; self.rank = self.adapter.progress().rank;
self.log.resume_from(runtime.last_event_id); self.log.resume_from(runtime.last_event_id);
self.emulator.set_buttons(self.buttons as u8);
Ok(()) Ok(())
} }
@ -764,8 +796,16 @@ impl Sim {
/// readout on the settled rates. Never after a restore, which already carries a settled /// readout on the settled rates. Never after a restore, which already carries a settled
/// network and a calibrated decoder. /// network and a calibrated decoder.
fn fresh_start(&mut self) -> Result<()> { fn fresh_start(&mut self) -> Result<()> {
let raw = self.frame.initialize(&mut self.emulator, &mut self.agent)?; self.emulator
.run_frame()
.map_err(|error| anyhow!("running the first frame: {error}"))?;
self.frame_buffer.copy_from_slice(self.emulator.framebuffer());
self.frame_counter = 1;
let raw = self.emulator.take_audio_u8();
self.dc_blocker.process_into(&raw, &mut self.pending_audio); self.dc_blocker.process_into(&raw, &mut self.pending_audio);
self.agent
.warmup(Some(&self.frame_buffer))
.map_err(|error| anyhow!("{error}"))?;
self.emit(NewEvent::new(FeedEventKind::System, "Fresh start: the fly woke up")); self.emit(NewEvent::new(FeedEventKind::System, "Fresh start: the fly woke up"));
Ok(()) Ok(())
} }
@ -887,27 +927,117 @@ impl Sim {
self.deny_list.maybe_reload(Instant::now(), forced); self.deny_list.maybe_reload(Instant::now(), forced);
} }
/// One frame, in the prototype's order (`crate::frame`). /// One frame, in the prototype's order.
fn step_frame(&mut self) -> Result<()> { fn step_frame(&mut self) -> Result<()> {
// Prepare through commit: ticks, decode, the executor's mask, one emulator frame, rewards, // 2. Step the brain: 16 or 17 integer ticks, the remainder carried and checkpointed.
// the scene and the location, then the stimulations and the reinforcement. self.remainder += self.agent.ms_per_frame;
let transition = { let steps = self.remainder.floor();
let mut parts = Parts { self.remainder -= steps;
agent: &mut self.agent, self.agent.network.step(steps as u64);
emulator: &mut self.emulator, self.profiler.lap(Phase::Step);
adapter: self.adapter.as_mut(), if self.profiler.enabled() {
ratchet: &mut self.ratchet, self.profiler.absorb_brain(self.agent.network.timings());
macros: self.macros.as_mut(), self.agent.network.reset_timings();
}; }
let mut laps = Laps { profiler: &mut self.profiler };
self.frame.transition(&mut parts, &mut laps)? // 3. Decode, 4. apply the buttons.
// 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 = self.adapter.boot();
let ms = self.agent.network.ms;
let rates = self.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 = self.agent.decoder.blocked_ms();
let blocked = (blocked_ms > 0.0 && ms - self.blocked_since_ms >= blocked_ms)
.then(|| self.agent.decoder.current())
.flatten()
.map(str::to_string);
// 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.
// `None` in raw mode, where the group has no channels to mask anyway.
let bound = self.macros.as_ref().map(MacroLayer::bound_channels);
let active = self.agent.decoder.decode_bound(
&rates,
ms,
boot,
blocked.as_deref(),
bound.as_deref(),
);
// A new winner starts its own window: it has not had a hold to move in yet.
let held = self.agent.decoder.current().map(str::to_string);
if held != self.held_channel {
self.held_channel = held;
self.blocked_since_ms = ms;
}
self.buttons = to_button_mask(&active);
// Macros mode (`docs/design/macros.md` sections 4 and 12): the same decode, plus the
// macro group whose winner is in `active` alongside the buttons. The mask that reaches
// the emulator is the running macro's, or nothing, or -- on the title screen alone -- the
// raw mask above. In raw mode `self.macros` is `None` and not one line of this runs.
let started_or_finished = match self.macros.as_mut() {
// Two disjoint fields of the same struct, so the layer can read the emulator while
// the loop still owns both. Taking the layer out and putting it back would leave
// raw mode running silently if anything in between ever panicked.
Some(layer) => {
// Three disjoint fields: the layer decides, the emulator is read, and 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.
let ledger = AdapterLedger(self.adapter.as_ref());
let decision = layer.decide(
&active,
self.buttons,
ms,
&mut self.emulator,
&ledger,
);
self.buttons = decision.mask;
decision.events
}
None => Vec::new(),
}; };
self.emit_macro_events(&started_or_finished);
self.emulator.set_buttons(self.buttons as u8);
self.profiler.lap(Phase::Decode);
// 5. Run one emulator frame, 6. set the visual frame from it.
self.emulator
.run_frame()
.map_err(|error| anyhow!("frame {}: {error}", self.frame_counter + 1))?;
self.frame_counter += 1;
Metrics::incr(&self.shared.metrics.sim_frames); Metrics::incr(&self.shared.metrics.sim_frames);
self.dc_blocker.process_into(&transition.audio, &mut self.pending_audio); self.profiler.lap(Phase::Emulate);
// The feed events, in the order the phases produced them: the executor's starts and self.frame_buffer.copy_from_slice(self.emulator.framebuffer());
// finishes, the rewards, then the observation's abandonment. let (width, height) = (self.agent.frame.width, self.agent.frame.height);
self.emit_macro_events(&transition.executed.events); self.agent
for event in &transition.evaluated.rewards { .network
.set_visual_frame(&self.frame_buffer, width, height);
let raw = self.emulator.take_audio_u8();
self.dc_blocker.process_into(&raw, &mut self.pending_audio);
self.profiler.lap(Phase::Retina);
// 7. Sample rewards from the frame just produced.
let ms = self.agent.network.ms;
let events = {
let (adapter, emulator) = (&mut self.adapter, &mut self.emulator);
adapter.sample(emulator, ms)
};
// 8. Stimulate once per event, 9. reinforce with the sum.
let mut total = 0.0;
for event in &events {
self.agent.network.stimulate(f64::from(event.stimulation_ms));
total += event.value;
}
if self.agent.network.plasticity.enabled {
self.agent.network.plasticity.reinforce(total, ms);
}
for event in &events {
let kind = RewardKind::from_adapter(event.kind); let kind = RewardKind::from_adapter(event.kind);
let mut new = NewEvent::new(FeedEventKind::Reward, event.label.clone()) let mut new = NewEvent::new(FeedEventKind::Reward, event.label.clone())
.value(event.value); .value(event.value);
@ -916,27 +1046,68 @@ impl Sim {
} }
self.emit(new); self.emit(new);
} }
self.emit_macro_events(&transition.evaluated.abandoned);
// The milestone archive sits here, after the commit and before the ratchet captures, self.profiler.lap(Phase::Rewards);
// which is the legacy order legacy-gameboy-v1 section 4 declares.
let ms = transition.ms; // `docs/design/macros.md` section 2: the scene is sampled once per game frame, after the
let progress = transition.evaluated.progress; // frame. So the palette the fly is offered on the next frame is the one for the frame it
// can actually see, and the feed's `game.scene` is never a frame ahead of the screen.
let abandoned = match self.macros.as_mut() {
Some(layer) => {
let ledger = AdapterLedger(self.adapter.as_ref());
layer.observe(&mut self.emulator, &ledger, ms)
}
None => Vec::new(),
};
// At most one: a macro that has run into a scene with no palette, abandoned before the
// header this frame publishes can show it beside that scene.
self.emit_macro_events(&abandoned);
// 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 = self.adapter.location();
if location.is_some() && location != self.location {
self.location = location;
self.blocked_since_ms = ms;
}
// 10. Ratchet: observe, and recover if it says so.
let progress = self.adapter.progress();
self.track_rank(&progress, ms); self.track_rank(&progress, ms);
let safe = self.adapter.safe_for_snapshot();
// `Ready(k+1)`: the ratchet captures, observes, and rolls the game back if it says so. let capture_due = safe && u64::from(progress.rank) > self.ratchet.state.best;
let boundary = { let captured = if capture_due {
let mut parts = Parts { Some(flybrain_gb::ratchet::Snapshot {
agent: &mut self.agent, game: self
emulator: &mut self.emulator, .emulator
adapter: self.adapter.as_mut(), .export_state()
ratchet: &mut self.ratchet, .map_err(|error| anyhow!("capturing a ratchet snapshot: {error}"))?,
macros: self.macros.as_mut(), frame: self.frame_buffer.clone(),
})
} else {
None
}; };
self.frame.boundary(&mut parts, &progress, ms)? // The stall window's second progress signal (`docs/design/ladder.md`, the 2026-09-17
}; // rule as amended 2026-09-22): coverage is ground never stood on, and a fly crossing a
if let Some(rollback) = boundary.rollback { // town it has already covered to reach the rung's own door earns none of it while it is
self.recovered(rollback.trigger, &rollback.events); // plainly getting somewhere. The macro layer answers 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 = self.macros.as_ref().is_some_and(MacroLayer::nearer_the_objective);
let recover = self.ratchet.observe_with_progress(
safe,
u64::from(progress.rank),
progress.unique_locations as u64,
ms as u64,
self.adapter.game_over(),
nearer,
|| captured.expect("the ratchet only captures when a snapshot was prepared"),
);
if recover {
// Two triggers, two stories on the ticker: a game over ended the run, a stall did not.
let reason = if self.adapter.game_over() { "Game over" } else { "Stuck" };
self.recover(reason)?;
} }
self.profiler.lap(Phase::Ratchet); self.profiler.lap(Phase::Ratchet);
Ok(()) Ok(())
@ -969,14 +1140,32 @@ impl Sim {
} }
} }
/// The host's half of a rollback the frame has already applied: the ticker, the metric, the fn recover(&mut self, reason: &str) -> Result<()> {
/// `recovering` status and a durable checkpoint. let snapshot = flybrain_gb::ratchet::Snapshot {
fn recovered(&mut self, trigger: RollbackTrigger, events: &[crate::macros::MacroEvent]) { game: self
// Two triggers, two stories on the ticker: a game over ended the run, a stall did not. .ratchet
let reason = match trigger { .game()
RollbackTrigger::GameOver => "Game over", .ok_or_else(|| anyhow!("the ratchet asked to recover with no snapshot"))?
RollbackTrigger::Stall => "Stuck", .to_vec(),
frame: self
.ratchet
.frame()
.ok_or_else(|| anyhow!("the ratchet snapshot has no framebuffer"))?
.to_vec(),
}; };
let frame = {
let mut neural = AgentRecovery { agent: &mut self.agent };
recover_game(
&mut self.emulator,
self.adapter.as_mut(),
&mut neural,
&snapshot,
)
.map_err(|error| anyhow!("recovering the game: {error}"))?
};
self.frame_buffer.copy_from_slice(&frame);
self.buttons = 0;
self.emulator.set_buttons(0);
Metrics::incr(&self.shared.metrics.recoveries_total); Metrics::incr(&self.shared.metrics.recoveries_total);
let attempts = self.ratchet.state.attempts; let attempts = self.ratchet.state.attempts;
self.emit( self.emit(
@ -989,11 +1178,31 @@ impl Sim {
) )
.value(f64::from(self.rank)), .value(f64::from(self.rank)),
); );
self.emit_macro_events(events); self.location = self.adapter.location();
self.held_channel = None;
self.blocked_since_ms = self.agent.network.ms;
// A rollback restores a game the running macro's plan was never made for, so the macro is
// abandoned here rather than carried over a map change it cannot see.
let abandoned = match self.macros.as_mut() {
Some(layer) => {
let events = layer.cancel(self.agent.network.ms);
// The frame's `observe` ran before the ratchet decided to roll back, so the scene
// and the palette describe the run that was just thrown away. The restored game
// is in WRAM now, so re-detect here rather than let the next frame's decision be
// made against a map the fly is no longer standing on.
let ledger = AdapterLedger(self.adapter.as_ref());
let mut events = events;
events.extend(layer.observe(&mut self.emulator, &ledger, self.agent.network.ms));
events
}
None => Vec::new(),
};
self.emit_macro_events(&abandoned);
self.pending_recovery = true; self.pending_recovery = true;
if let Err(error) = self.checkpoint(true, None) { if let Err(error) = self.checkpoint(true, None) {
tracing::error!(%error, "could not checkpoint after a recovery"); tracing::error!(%error, "could not checkpoint after a recovery");
} }
Ok(())
} }
// -- commands ------------------------------------------------------------------------ // -- commands ------------------------------------------------------------------------
@ -1083,9 +1292,6 @@ impl Sim {
.clamp(1.0, self.shared.config.control.sugar_max_ms) .clamp(1.0, self.shared.config.control.sugar_max_ms)
.min(self.shared.config.control.sugar_max_ms); .min(self.shared.config.control.sugar_max_ms);
self.agent.network.stimulate(duration); self.agent.network.stimulate(duration);
if let Some(trace) = self.frame.trace_mut() {
trace.sugar(duration);
}
let day = utc_day(now_ms); let day = utc_day(now_ms);
if day != self.sugar_day { if day != self.sugar_day {
@ -1098,15 +1304,6 @@ impl Sim {
let event = self.emit(NewEvent::new(FeedEventKind::Sugar, sugar_label(by)) let event = self.emit(NewEvent::new(FeedEventKind::Sugar, sugar_label(by))
.by(by) .by(by)
.value(duration)); .value(duration));
self.journal.record(&crate::journal::Entry {
frame: self.frame.frame_counter,
brain_ms: self.agent.network.ms,
input: Input::Sugar { duration_ms: duration },
by,
source,
event_id: event.id,
wall_ms: event.wall_ms,
});
tracing::info!(by, source, duration_ms = duration, "sugar accepted"); tracing::info!(by, source, duration_ms = duration, "sugar accepted");
Ok(event.id) Ok(event.id)
} }
@ -1116,25 +1313,13 @@ impl Sim {
fn reward(&mut self, value: f64, by: &str, source: &str) -> u64 { fn reward(&mut self, value: f64, by: &str, source: &str) -> u64 {
let ms = self.agent.network.ms; let ms = self.agent.network.ms;
self.agent.network.plasticity.reinforce(value, ms); self.agent.network.plasticity.reinforce(value, ms);
if let Some(trace) = self.frame.trace_mut() {
trace.reward_pulse(value);
}
tracing::info!(by, source, value, "reward pulse applied"); tracing::info!(by, source, value, "reward pulse applied");
let event = self.emit( self.emit(
NewEvent::new(FeedEventKind::Reward, format!("{by} sent a reward pulse ({value})")) NewEvent::new(FeedEventKind::Reward, format!("{by} sent a reward pulse ({value})"))
.by(by) .by(by)
.value(value), .value(value),
); )
self.journal.record(&crate::journal::Entry { .id
frame: self.frame.frame_counter,
brain_ms: ms,
input: Input::Reward { value },
by,
source,
event_id: event.id,
wall_ms: event.wall_ms,
});
event.id
} }
/// `POST /chat`: the on-screen chat path, enforced here rather than trusted from the bridge. /// `POST /chat`: the on-screen chat path, enforced here rather than trusted from the bridge.
@ -1205,7 +1390,6 @@ impl Sim {
if let Err(error) = self.checkpoint_blocking(true, None) { if let Err(error) = self.checkpoint_blocking(true, None) {
tracing::error!(%error, "the final checkpoint failed"); tracing::error!(%error, "the final checkpoint failed");
} }
self.frame.finish_trace();
if let Err(error) = self.log.flush() { if let Err(error) = self.log.flush() {
tracing::error!(%error, "the final event log flush failed"); tracing::error!(%error, "the final event log flush failed");
} }
@ -1315,10 +1499,6 @@ impl Sim {
durable && self.best_archived_rank.is_none_or(|best| *rank > best) durable && self.best_archived_rank.is_none_or(|best| *rank > best)
}); });
let (generation, agent, runtime) = self.snapshot_state()?; let (generation, agent, runtime) = self.snapshot_state()?;
let step = self.frame.frame_counter;
if let Some(trace) = self.frame.trace_mut() {
trace.capture(generation, step);
}
if let Some(rank) = archive_rank { if let Some(rank) = archive_rank {
self.best_archived_rank = Some(rank); self.best_archived_rank = Some(rank);
} }
@ -1367,15 +1547,15 @@ impl Sim {
let mut agent_state = self.agent.export_state(); let mut agent_state = self.agent.export_state();
// The sim loop owns the frame remainder, not `NeuralAgent::tick`, so the exported state // The sim loop owns the frame remainder, not `NeuralAgent::tick`, so the exported state
// carries the loop's value. // carries the loop's value.
agent_state.remainder = self.frame.remainder; agent_state.remainder = self.remainder;
let runtime = RuntimeState { let runtime = RuntimeState {
generation, generation,
wall_ms: now_wall_ms(), wall_ms: now_wall_ms(),
rom_sha256: self.rom_sha256.clone(), rom_sha256: self.rom_sha256.clone(),
emulator_frame: self.frame.frame_counter, emulator_frame: self.frame_counter,
compatibility: self.compatibility.clone(), compatibility: self.compatibility.clone(),
speed: self.shared.config.loop_.speed, speed: self.shared.config.loop_.speed,
buttons: self.frame.buttons, buttons: self.buttons,
rank_since_ms: self.rank_since_ms, rank_since_ms: self.rank_since_ms,
last_event_id: self.log.next_id().saturating_sub(1), last_event_id: self.log.next_id().saturating_sub(1),
reward: self.adapter.export_state(), reward: self.adapter.export_state(),
@ -1384,7 +1564,7 @@ impl Sim {
.emulator .emulator
.export_state() .export_state()
.map_err(|error| anyhow!("exporting the Game Boy: {error}"))?, .map_err(|error| anyhow!("exporting the Game Boy: {error}"))?,
framebuffer: self.frame.frame_buffer.clone(), framebuffer: self.frame_buffer.clone(),
ratchet_game: self.ratchet.game().map(<[u8]>::to_vec).unwrap_or_default(), ratchet_game: self.ratchet.game().map(<[u8]>::to_vec).unwrap_or_default(),
ratchet_frame: self.ratchet.frame().map(<[u8]>::to_vec).unwrap_or_default(), ratchet_frame: self.ratchet.frame().map(<[u8]>::to_vec).unwrap_or_default(),
}; };
@ -1457,7 +1637,7 @@ impl Sim {
let audio = f32_bytes(&self.pending_audio); let audio = f32_bytes(&self.pending_audio);
self.pending_audio.clear(); self.pending_audio.clear();
( (
Arc::new(self.frame.frame_buffer.clone()), Arc::new(self.frame_buffer.clone()),
Arc::new(audio), Arc::new(audio),
Arc::new(bitset), Arc::new(bitset),
count, count,
@ -1483,8 +1663,8 @@ impl Sim {
uptime_seconds: self.started.elapsed().as_secs_f64(), uptime_seconds: self.started.elapsed().as_secs_f64(),
run_seconds: finite(ms / 1000.0).max(0.0), run_seconds: finite(ms / 1000.0).max(0.0),
brain_ms: finite(ms).max(0.0), brain_ms: finite(ms).max(0.0),
frame: self.frame.frame_counter, frame: self.frame_counter,
buttons: self.frame.buttons & 0xff, buttons: self.buttons & 0xff,
rates, rates,
population_rate: finite(self.agent.network.population_rate).max(0.0), population_rate: finite(self.agent.network.population_rate).max(0.0),
spike_count, spike_count,

View file

@ -242,7 +242,7 @@ pub struct FeedMacroOutcome {
/// Reward categories the feed reports counts for. The adapter's own interned kinds /// Reward categories the feed reports counts for. The adapter's own interned kinds
/// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`, /// (`milestone`, `exploration`, `map`, `species`, `trainer`, `battle`, `badge`, `boundary`,
/// `catch`, `talk`, `item`) map onto these. /// `catch`) map onto these.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum RewardKind { pub enum RewardKind {
@ -291,15 +291,6 @@ impl RewardKind {
// for it on the same frame, so the `pokedex` counter already moves. Mapping `catch` // for it on the same frame, so the `pokedex` counter already moves. Mapping `catch`
// there as well would count one event twice. // there as well would count one event twice.
"catch" => Self::Wildwin, "catch" => Self::Wildwin,
// `talk` and `item` (the operator, 2026-09-23) are the fly finding what is in a place:
// a person or a sign it opened, an item it picked up. That is the family `explore`
// already counts -- new ground, a door found -- at the same quiet scale (0.05 to
// 0.15), so both publish there and the feed's closed kind set does not move. Not
// `area`, which counts maps and is a notable row; not `story`, which is the plot;
// not `wildwin`, which is a battle. The Pokémon Red ticker's copy for `explore` stays
// "new place" for all four, the operator's call (`apps/stage/src/games/pokemon-red.ts`).
"talk" => Self::Explore,
"item" => Self::Explore,
// The platformer. // The platformer.
"band" => Self::Explore, "band" => Self::Explore,
"coin" => Self::Wildwin, "coin" => Self::Wildwin,
@ -758,8 +749,6 @@ mod tests {
assert_eq!(RewardKind::from_adapter("nonsense"), None); assert_eq!(RewardKind::from_adapter("nonsense"), None);
assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore)); assert_eq!(RewardKind::from_adapter("boundary"), Some(RewardKind::Explore));
assert_eq!(RewardKind::from_adapter("catch"), Some(RewardKind::Wildwin)); assert_eq!(RewardKind::from_adapter("catch"), Some(RewardKind::Wildwin));
assert_eq!(RewardKind::from_adapter("talk"), Some(RewardKind::Explore));
assert_eq!(RewardKind::from_adapter("item"), Some(RewardKind::Explore));
} }
#[test] #[test]

View file

@ -1,385 +0,0 @@
//! `FLY_TRACE=<path>`: a per-frame record of the legacy loop, for parity and for the port.
//!
//! One JSON object per line. The first line names the format; every other line is either one
//! transition `k -> k+1` of the legacy frame order (`crate::frame`) together with what happened at
//! the boundary it reached, or -- once, before the first transition -- the captures taken at the
//! boundary the run started on.
//!
//! The field names follow the step trace of the session framework
//! (`docs/design/session-framework/step-v1.md` section 8 and its 2026-09-23 amendment, Rust
//! `fly_session_types::trace`) wherever a legacy field is the same thing:
//!
//! - `behaviour` is what two runs of one build pair must agree on, byte for byte:
//! `step`, `ticksAdvanced`, `brainTicks` and `remainder` (a `RationalNs`, exact, because every
//! legacy remainder is a multiple of 2^-15 ms: legacy-gameboy-v1 section 3), the decision, the
//! controller mask, the macro and reward events in the order they happened, the digests of the
//! rates, of the spikes of this transition, of the frame and of work RAM, the rank, and
//! `boundaryActions` -- a ratchet capture is a `save-slot` of slot `best` with the digest of the
//! saved emulator state, a ratchet recovery is a `rollback` to `best` -- in the shape of
//! `TraceBehaviour.boundaryActions`;
//! - `operational.captures` is every checkpoint taken at the boundary, in order, each with the
//! number of boundary actions applied before it, in the shape of `TraceOperational.captures`.
//! The legacy loop archives a milestone *before* the ratchet captures (legacy-gameboy-v1 section
//! 4), so a climb records `afterActions: 0` ahead of a `save-slot`: the declared difference, as
//! it happens, which the ported loop's validator refuses on purpose.
//!
//! `admissions` is sugar and operator reward pulses applied at the top of the frame, before the
//! brain ticks: the admission cut of legacy-gameboy-v1 section 15.
//!
//! Wall time is never written, so a run with the periodic checkpoint intervals pushed out of the
//! way produces the same file twice. Off by default; when off, nothing here is constructed and the
//! loop pays one `Option` test per hook.
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use flybrain_core::lif::LifNetwork;
use flybrain_gb::RewardEvent;
use flybrain_gb::emulator::Emulator;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use crate::macros::MacroEvent;
/// The format name on the first line.
pub const FORMAT: &str = "flysim-legacy-frame-trace-v1";
/// The environment variable that turns the trace on.
pub const ENV: &str = "FLY_TRACE";
/// The ratchet's one slot, as the legacy composition declares it (legacy-gameboy-v1 section 9).
pub const SLOT: &str = "best";
/// Work RAM, `$C000..=$DFFF`, as the CPU sees it.
const WRAM: std::ops::RangeInclusive<u16> = 0xc000..=0xdfff;
/// Lowercase hex SHA-256.
pub fn sha256_hex(bytes: &[u8]) -> String {
hex(&Sha256::digest(bytes))
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
/// A legacy remainder in milliseconds as exact nanoseconds, `{numerator, denominator}` in lowest
/// terms. The remainder is always `m / 32768` ms for an integer `m` (legacy-gameboy-v1 section
/// 3), so this never rounds; a value that is not is written with its bits instead.
pub fn remainder_ns(remainder_ms: f64) -> Value {
let scaled = remainder_ms * 32_768.0;
if scaled.fract() != 0.0 || !(0.0..32_768.0 * 1_000.0).contains(&scaled) {
return json!({ "inexactBits": format!("{:016x}", remainder_ms.to_bits()) });
}
// ns = m * 1e6 / 32768 = m * 15625 / 512.
let mut numerator = scaled as u64 * 15_625;
let mut denominator = 512u64;
let (mut a, mut b) = (numerator, denominator);
while b != 0 {
(a, b) = (b, a % b);
}
if a > 1 {
numerator /= a;
denominator /= a;
}
if numerator == 0 {
denominator = 1;
}
json!({ "numerator": numerator.to_string(), "denominator": denominator.to_string() })
}
fn macro_json(phase: &str, event: &MacroEvent) -> Value {
json!({
"phase": phase,
"slot": event.slot,
"name": event.name,
"outcome": event.outcome.map(|outcome| outcome.as_str()),
})
}
/// One transition and the boundary it reached, while it is being recorded.
#[derive(Default)]
struct Record {
step: u64,
admissions: Vec<Value>,
ticks: u64,
brain_ticks: u64,
remainder: Value,
rates: String,
spikes: String,
spike_count: u64,
decision: Vec<String>,
mask: u32,
macros: Vec<Value>,
framebuffer: String,
wram: String,
rewards: Vec<Value>,
rank: u32,
boundary_actions: Vec<Value>,
captures: Vec<Value>,
}
/// The recorder. The loop calls it at fixed points of the frame order; it holds the transition
/// open until the next one starts, so the captures and admissions a host takes between two frames
/// land on the boundary they belong to.
pub struct FrameTrace {
out: BufWriter<File>,
/// Captures at the boundary the run started on, before any transition.
initial: Vec<Value>,
initial_step: Option<u64>,
open: Option<Record>,
/// Admissions since the last transition started: they belong to the next one.
admissions: Vec<Value>,
/// Brain clock before this transition's ticks, the lower edge of its spike window.
ms_before: f64,
}
impl FrameTrace {
/// `FLY_TRACE`, if it is set and non-empty. A path that cannot be created is an error rather
/// than a silent run without the trace that was asked for.
pub fn from_env() -> std::io::Result<Option<Self>> {
match std::env::var_os(ENV) {
Some(path) if !path.is_empty() => Self::create(Path::new(&path)).map(Some),
_ => Ok(None),
}
}
pub fn create(path: &Path) -> std::io::Result<Self> {
let mut out = BufWriter::with_capacity(1 << 20, File::create(path)?);
writeln!(out, "{}", json!({ "format": FORMAT }))?;
Ok(Self {
out,
initial: Vec::new(),
initial_step: None,
open: None,
admissions: Vec::new(),
ms_before: 0.0,
})
}
fn write(&mut self, value: &Value) {
if let Err(error) = writeln!(self.out, "{value}") {
tracing::warn!(%error, "could not write the frame trace");
}
}
/// Sugar admitted at the top of the frame, before the brain ticks.
pub fn sugar(&mut self, duration_ms: f64) {
self.admissions
.push(json!({ "kind": "sugar", "durationMs": duration_ms }));
}
/// An operator reward pulse, applied at the top of the frame.
pub fn reward_pulse(&mut self, value: f64) {
self.admissions
.push(json!({ "kind": "reward", "value": value }));
}
/// A checkpoint capture at the current boundary: the open transition's, or the start's.
pub fn capture(&mut self, generation: u64, step: u64) {
let (captures, after) = match self.open.as_mut() {
Some(record) => {
let after = record.boundary_actions.len();
(&mut record.captures, after)
}
None => {
self.initial_step.get_or_insert(step);
(&mut self.initial, 0)
}
};
captures.push(json!({ "checkpointId": format!("g{generation}"), "afterActions": after }));
}
/// Transition `step -> step + 1` begins: the previous one is complete.
pub fn begin(&mut self, step: u64, ms_before: f64) {
self.flush_open();
if let Some(start) = self.initial_step.take() {
let initial = std::mem::take(&mut self.initial);
self.write(&json!({
"boundary": start.to_string(),
"operational": { "captures": initial },
}));
}
self.ms_before = ms_before;
self.open = Some(Record {
step,
admissions: std::mem::take(&mut self.admissions),
..Record::default()
});
}
/// Phase A's ticks, and the network they left behind.
pub fn ticked(&mut self, ticks: u64, remainder_ms: f64, network: &LifNetwork) {
let Some(record) = self.open.as_mut() else {
return;
};
record.ticks = ticks;
record.brain_ticks = network.ms as u64;
record.remainder = remainder_ns(remainder_ms);
let mut hasher = Sha256::new();
for (role, rate) in network.rates.iter() {
hasher.update((role.len() as u32).to_le_bytes());
hasher.update(role.as_bytes());
hasher.update(rate.to_bits().to_le_bytes());
}
record.rates = hex(&hasher.finalize());
let (bits, count) =
crate::snapshot::spike_bitset(&network.last_spike_ms, self.ms_before, network.ms);
record.spikes = sha256_hex(&bits);
record.spike_count = count;
}
/// The readout's decision, as decoded.
pub fn decided(&mut self, active: &[String]) {
if let Some(record) = self.open.as_mut() {
record.decision = active.to_vec();
}
}
/// Phase B: the mask the emulator is given, and the macro events deciding it produced.
pub fn executed(&mut self, mask: u32, events: &[MacroEvent]) {
if let Some(record) = self.open.as_mut() {
record.mask = mask;
record
.macros
.extend(events.iter().map(|event| macro_json("execute", event)));
}
}
/// The frame the emulator produced, and work RAM after it. Read uncached, so the trace never
/// fills the per-frame read cache the adapter and the macros share.
pub fn advanced(&mut self, framebuffer: &[u8], emulator: &Emulator) {
if let Some(record) = self.open.as_mut() {
record.framebuffer = sha256_hex(framebuffer);
let wram: Vec<u8> = WRAM
.map(|address| emulator.read_uncached(address))
.collect();
record.wram = sha256_hex(&wram);
}
}
/// Phase C: the reward events in adapter order, the scene's own macro events, and the rank.
pub fn evaluated(&mut self, rewards: &[RewardEvent], abandoned: &[MacroEvent], rank: u32) {
if let Some(record) = self.open.as_mut() {
record.rewards.extend(rewards.iter().map(|event| {
json!({
"kind": event.kind,
"value": event.value,
"stimulationMs": event.stimulation_ms,
})
}));
record
.macros
.extend(abandoned.iter().map(|event| macro_json("evaluate", event)));
record.rank = rank;
}
}
/// The ratchet captured: a slot save at the boundary.
pub fn slot_saved(&mut self, state: &[u8]) {
if let Some(record) = self.open.as_mut() {
record.boundary_actions.push(json!({
"kind": "save-slot",
"slotId": SLOT,
"stateDigest": sha256_hex(state),
}));
}
}
/// The ratchet rolled back, and the macro events the rollback produced.
pub fn rolled_back(&mut self, events: &[MacroEvent]) {
if let Some(record) = self.open.as_mut() {
record.boundary_actions.push(json!({
"kind": "rollback",
"slotId": SLOT,
"stateDigest": null,
}));
record
.macros
.extend(events.iter().map(|event| macro_json("rollback", event)));
}
}
fn flush_open(&mut self) {
let Some(record) = self.open.take() else {
return;
};
let line = json!({
"behaviour": {
"step": record.step.to_string(),
"admissions": record.admissions,
"ticksAdvanced": record.ticks.to_string(),
"brainTicks": record.brain_ticks.to_string(),
"remainder": record.remainder,
"ratesDigest": record.rates,
"spikesDigest": record.spikes,
"spikeCount": record.spike_count,
"decision": record.decision,
"mask": record.mask,
"macroEvents": record.macros,
"framebufferDigest": record.framebuffer,
"wramDigest": record.wram,
"rewards": record.rewards,
"rank": record.rank,
"acknowledgedBoundary": (record.step + 1).to_string(),
"boundaryActions": record.boundary_actions,
},
"operational": { "captures": record.captures },
});
self.write(&line);
}
/// Write the open transition and flush the file: on shutdown, and on drop.
pub fn finish(&mut self) {
self.flush_open();
if let Err(error) = self.out.flush() {
tracing::warn!(%error, "could not flush the frame trace");
}
}
}
impl Drop for FrameTrace {
fn drop(&mut self) {
self.finish();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_legacy_remainder_is_exact_nanoseconds() {
// The first twelve legacy frames' remainders, from the clock the fixture records.
let per_frame = 1000.0 / (4_194_304.0 / 70_224.0);
let mut remainder = 0.0f64;
for _ in 0..100_000 {
remainder += per_frame;
remainder -= remainder.floor();
let value = remainder_ns(remainder);
assert!(
value.get("numerator").is_some(),
"{remainder} was not exact: {value}"
);
}
assert_eq!(
remainder_ns(0.0),
json!({ "numerator": "0", "denominator": "1" })
);
// 0.5 ms is 500000 ns.
assert_eq!(
remainder_ns(0.5),
json!({ "numerator": "500000", "denominator": "1" })
);
// One 2^-15 ms step is 15625/512 ns.
assert_eq!(
remainder_ns(1.0 / 32_768.0),
json!({ "numerator": "15625", "denominator": "512" })
);
}
}

View file

@ -1,21 +1,15 @@
//! A `v6` checkpoint restored under `v7`: accepted with the opt-in, refused without it. //! A `v5` checkpoint restored under `v6`: accepted with the opt-in, refused without it.
//! //!
//! The unit tests in `flybrain-gb` cover the decision function and the adapter's own state //! The unit tests in `flybrain-gb` cover the decision function and the adapter's own state
//! migration separately. This is the two of them against one artefact: a real `FLYSIM01` //! migration separately. This is the two of them against one artefact: a real `FLYSIM01`
//! envelope carrying a `pokered-unique8-v6` compatibility string and a `v6` reward ledger — //! envelope carrying a `pokered-unique8-v5` compatibility string and a `v5` reward ledger —
//! written, encoded, decoded, and then put through exactly what `Sim::try_restore` puts a //! written, encoded, decoded, and then put through exactly what `Sim::try_restore` puts a
//! candidate through, and then one sample of a game in which items were already taken. //! candidate through.
//! //!
//! No ROM and no dataset, deliberately. Building a `Sim` would need both, and neither is part of //! No ROM and no dataset, deliberately. Building a `Sim` would need both, and neither is part of
//! the question: what decides a restore is the compatibility string and `import_state`, and what //! the question: what decides a restore is the compatibility string and `import_state`.
//! decides the seed is the first sample's read of the cartridge's own item bits.
//!
//! (`v5` -> `v6`, the catch rule's migration, was this same file; `v6` no longer migrates from
//! anything, so that pair is refused now like any other.)
use flybrain_gb::GameAdapter; use flybrain_gb::GameAdapter;
use flybrain_gb::MemoryReader;
use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::compatibility::{RestoreDecision, accepted_adapters, decide}; use flybrain_gb::compatibility::{RestoreDecision, accepted_adapters, decide};
use flybrain_gb::pokemon_red::PokemonRedReward; use flybrain_gb::pokemon_red::PokemonRedReward;
use flysim::store::{self, RuntimeState}; use flysim::store::{self, RuntimeState};
@ -30,30 +24,24 @@ fn compatibility(adapter: &str) -> String {
) )
} }
/// A `v6` reward ledger: `STATE_VERSION` 4, every field `v6` wrote, and **no** `talk:`, /// A `v5` reward ledger: `STATE_VERSION` 4, every field `v5` wrote, and **no** `catchCounts`.
/// `item:`, `hidden:` or `items:seeded` key in `seen`.
/// ///
/// Written out by hand rather than exported from an adapter, because an exported one would be a /// Written out by hand rather than exported from an adapter, because an exported one would be a
/// `v7` state with keys deleted — this is the shape the release box's checkpoints really carry, /// `v6` state with the counter deleted — this is the shape the release box's checkpoints really
/// field for field, including a `boundary:` key earned indoors (Red's staircase, map 38) that /// carry, field for field.
/// `v7` would not have paid for and keeps anyway. fn v5_reward() -> serde_json::Value {
fn v6_reward() -> serde_json::Value {
serde_json::json!({ serde_json::json!({
"version": 4, "version": 4,
"seen": [ "seen": ["adventure", "map:0", "early:outside", "dex:3", "boundary:0:edge:n:near"],
"adventure", "map:0", "early:outside", "dex:3", "boundary:0:edge:n:near",
"boundary:38:1:7:on"
],
"tiles": ["0:5:6", "0:5:7"], "tiles": ["0:5:6", "0:5:7"],
"tileCounts": { "0": 2 }, "tileCounts": { "0": 2 },
"wildWins": { "0:112:4": 2 }, "wildWins": { "0:112:4": 2 },
"catchCounts": { "176": 1 },
"replayBlocked": [], "replayBlocked": [],
"counts": { "counts": {
"milestone": 2, "exploration": 0, "map": 1, "species": 1, "milestone": 2, "exploration": 0, "map": 1, "species": 1,
"trainer": 0, "battle": 2, "badge": 0, "boundary": 2, "catch": 1 "trainer": 0, "battle": 2, "badge": 0, "boundary": 1
}, },
"total": 2.45, "total": 2.05,
"recent": [{ "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 }], "recent": [{ "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 }],
"last": { "species": { "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 } }, "last": { "species": { "kind": "species", "label": "OWNED #4", "brainMs": 1234.5, "value": 0.5 } },
"initialized": true, "initialized": true,
@ -67,7 +55,7 @@ fn v6_reward() -> serde_json::Value {
}) })
} }
fn v6_checkpoint() -> Vec<u8> { fn v5_checkpoint() -> Vec<u8> {
use flybrain_core::decoder::DecoderState; use flybrain_core::decoder::DecoderState;
use flybrain_core::lif::LifState; use flybrain_core::lif::LifState;
use flybrain_core::ordered::NumberMap; use flybrain_core::ordered::NumberMap;
@ -117,12 +105,12 @@ fn v6_checkpoint() -> Vec<u8> {
wall_ms: 1_790_000_000_000, wall_ms: 1_790_000_000_000,
rom_sha256: flybrain_gb::pokemon_red::SUPPORTED_ROM.to_string(), rom_sha256: flybrain_gb::pokemon_red::SUPPORTED_ROM.to_string(),
emulator_frame: 1_000_000, emulator_frame: 1_000_000,
compatibility: compatibility("pokered-unique8-v6"), compatibility: compatibility("pokered-unique8-v5"),
speed: 1.0, speed: 1.0,
buttons: 0, buttons: 0,
rank_since_ms: 1_000.0, rank_since_ms: 1_000.0,
last_event_id: 4_242, last_event_id: 4_242,
reward: v6_reward(), reward: v5_reward(),
ratchet: flybrain_gb::RatchetState { best: 3, attempts: 1, recoveries: 4, ..Default::default() }, ratchet: flybrain_gb::RatchetState { best: 3, attempts: 1, recoveries: 4, ..Default::default() },
emulator: vec![3; 64], emulator: vec![3; 64],
framebuffer: vec![0; 32], framebuffer: vec![0; 32],
@ -133,13 +121,13 @@ fn v6_checkpoint() -> Vec<u8> {
} }
#[test] #[test]
fn a_v6_checkpoint_is_refused_under_v7_without_the_opt_in() { fn a_v5_checkpoint_is_refused_under_v6_without_the_opt_in() {
let checkpoint = store::decode(&v6_checkpoint()).expect("the fixture decodes"); let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes");
let adapter = PokemonRedReward::new(); let adapter = PokemonRedReward::new();
let current = compatibility(adapter.id()); let current = compatibility(adapter.id());
assert_ne!(checkpoint.runtime.compatibility, current, "v7 is not v6"); assert_ne!(checkpoint.runtime.compatibility, current, "v6 is not v5");
for opt_in in [None, Some(""), Some("pokered-unique8-v5"), Some("some-other-adapter")] { for opt_in in [None, Some(""), Some("pokered-unique8-v4"), Some("some-other-adapter")] {
assert!( assert!(
matches!( matches!(
decide( decide(
@ -155,18 +143,9 @@ fn a_v6_checkpoint_is_refused_under_v7_without_the_opt_in() {
} }
} }
/// A flat 64 KiB address space: the one thing a sample reads.
struct Wram(Vec<u8>);
impl MemoryReader for Wram {
fn read8(&mut self, address: u16) -> u8 {
self.0[address as usize]
}
}
#[test] #[test]
fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_seeded() { fn a_v5_checkpoint_restores_under_v6_with_the_opt_in_and_the_counter_starts_at_zero() {
let checkpoint = store::decode(&v6_checkpoint()).expect("the fixture decodes"); let checkpoint = store::decode(&v5_checkpoint()).expect("the fixture decodes");
let mut adapter = PokemonRedReward::new(); let mut adapter = PokemonRedReward::new();
let current = compatibility(adapter.id()); let current = compatibility(adapter.id());
@ -175,25 +154,25 @@ fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_se
&checkpoint.runtime.compatibility, &checkpoint.runtime.compatibility,
&current, &current,
adapter.migrates_from(), adapter.migrates_from(),
&accepted_adapters(Some("pokered-unique8-v6")), &accepted_adapters(Some("pokered-unique8-v5")),
), ),
RestoreDecision::MigrateAdapter { from: "pokered-unique8-v6".to_string() } RestoreDecision::MigrateAdapter { from: "pokered-unique8-v5".to_string() }
); );
// The migration itself: `import_state`, exactly as `Sim::try_restore` calls it. // The migration itself: `import_state`, exactly as `Sim::try_restore` calls it.
adapter.import_state(&checkpoint.runtime.reward).expect("a v6 ledger is a valid v7 ledger"); adapter.import_state(&checkpoint.runtime.reward).expect("a v5 ledger is a valid v6 ledger");
let after = adapter.export_state(); let after = adapter.export_state();
assert_eq!(after["counts"]["talk"], serde_json::json!(0), "no conversation was ever paid"); assert_eq!(after["catchCounts"], serde_json::json!({}), "the new counter starts at 0");
assert_eq!(after["counts"]["item"], serde_json::json!(0), "nor any item"); assert_eq!(after["counts"]["catch"], serde_json::json!(0));
// And nothing else moved: every field the v6 state carried round-trips to the same value, // And nothing else moved: every field the v5 state carried round-trips to the same value,
// and v7 adds no field at all -- its ledgers are keys in `seen`. // and the only key v6 adds is the counter.
// //
// `counts` is the one field that is not byte-identical, and it is not a change of meaning: // `counts` is the one field that is not byte-identical, and it is not a change of meaning:
// it serializes every kind in the catalog, so a v7 state lists `talk` and `item` where a v6 // it serializes every kind in the catalog, so a v6 state lists `catch` where a v5 state had
// state had nothing to list. Every kind the v6 state did carry keeps its number. // nothing to list. Every kind the v5 state did carry keeps its number.
let before = v6_reward(); let before = v5_reward();
for (key, value) in before.as_object().unwrap() { for (key, value) in before.as_object().unwrap() {
if key == "counts" { if key == "counts" {
for (kind, count) in value.as_object().unwrap() { for (kind, count) in value.as_object().unwrap() {
@ -205,7 +184,7 @@ fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_se
.keys() .keys()
.filter(|kind| !value.as_object().unwrap().contains_key(*kind)) .filter(|kind| !value.as_object().unwrap().contains_key(*kind))
.collect(); .collect();
assert_eq!(added, vec!["talk", "item"], "v7 counts two more kinds and no others"); assert_eq!(added, vec!["catch"], "v6 counts one more kind and no others");
continue; continue;
} }
assert_eq!(&after[key], value, "{key} must survive the migration byte for byte"); assert_eq!(&after[key], value, "{key} must survive the migration byte for byte");
@ -216,29 +195,7 @@ fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_se
.keys() .keys()
.filter(|key| !before.as_object().unwrap().contains_key(*key)) .filter(|key| !before.as_object().unwrap().contains_key(*key))
.collect(); .collect();
assert!(added.is_empty(), "v7 adds no field: {added:?}"); assert_eq!(added, vec!["catchCounts"], "v6 adds one field and no others");
// The first sample of the restored game. Under v6 the fly took an item ball (global
// toggleable index 0x2a) and a hidden item (index 9); v6 paid for neither. The sample seeds
// both into the ledger and pays nothing, which is the "no retroactive payout" half.
let mut wram = Wram(vec![0; 0x10000]);
wram.0[ram::wStatusFlags6 as usize] = 1;
wram.0[ram::wPartyCount as usize] = 1;
wram.0[ram::wCurMapWidth as usize] = 10;
wram.0[ram::wCurMapHeight as usize] = 9;
wram.0[ram::wXCoord as usize] = 5;
wram.0[ram::wYCoord as usize] = 7;
wram.0[(ram::wToggleableObjectFlags + 0x2a / 8) as usize] |= 1 << (0x2a % 8);
wram.0[(ram::wObtainedHiddenItemsFlags + 1) as usize] |= 1 << 1;
assert!(adapter.sample(&mut wram, 2_000.0).is_empty(), "the seed pays nothing");
let seen = adapter.export_state()["seen"].clone();
for key in ["item:42", "hidden:9", "items:seeded"] {
assert!(seen.as_array().unwrap().contains(&serde_json::json!(key)), "{key} seeded");
}
assert!(
!seen.as_array().unwrap().iter().any(|key| key.as_str().unwrap().starts_with("talk:")),
"the talk ledger starts empty"
);
// The rest of what a restore reads is untouched by the migration. // The rest of what a restore reads is untouched by the migration.
assert_eq!(adapter.progress().rank, 3); assert_eq!(adapter.progress().rank, 3);
@ -249,11 +206,11 @@ fn a_v6_checkpoint_restores_under_v7_with_the_new_ledgers_empty_and_the_items_se
#[test] #[test]
fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() { fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() {
let adapter = PokemonRedReward::new(); let adapter = PokemonRedReward::new();
let accepted = accepted_adapters(Some("pokered-unique8-v6")); let accepted = accepted_adapters(Some("pokered-unique8-v5"));
let current = compatibility(adapter.id()); let current = compatibility(adapter.id());
// A v6 string whose state format also moved: a different build, not a rule change. // A v5 string whose state format also moved: a different build, not a rule change.
let other_abi = compatibility("pokered-unique8-v6").replace("199616", "199617"); let other_abi = compatibility("pokered-unique8-v5").replace("199616", "199617");
assert!(matches!( assert!(matches!(
decide(&other_abi, &current, adapter.migrates_from(), &accepted), decide(&other_abi, &current, adapter.migrates_from(), &accepted),
RestoreDecision::Refuse(_) RestoreDecision::Refuse(_)
@ -264,15 +221,4 @@ fn nothing_but_the_adapter_segment_may_differ_for_the_migration_to_apply() {
decide(&current, &current, adapter.migrates_from(), &[]), decide(&current, &current, adapter.migrates_from(), &[]),
RestoreDecision::Exact RestoreDecision::Exact
); );
// And v5 -> v7 is not a migration this adapter wrote, whatever the operator names.
assert!(matches!(
decide(
&compatibility("pokered-unique8-v5"),
&current,
adapter.migrates_from(),
&accepted_adapters(Some("pokered-unique8-v5,pokered-unique8-v6")),
),
RestoreDecision::Refuse(_)
));
} }

View file

@ -1,93 +0,0 @@
//! `FLY_TRACE`'s boundary half is the session framework's, field for field.
//!
//! The recorder writes `behaviour.boundaryActions` and `operational.captures` in the shapes of
//! `TraceBehaviour.boundaryActions` and `TraceOperational.captures` (step-v1 section 8, amended
//! 2026-09-23). This test records the two boundaries the legacy loop produces and reads them with
//! `fly-session-types` itself, by grafting them onto the shared baseline trace:
//!
//! - a rollback followed by the post-recovery checkpoint is a valid transition trace;
//! - a rung climb -- the milestone archive, *then* the ratchet's slot save -- is refused with the
//! capture-before-save error. That is the legacy order legacy-gameboy-v1 section 4 declares, and
//! the trace has to show it as it happens rather than tidy it away.
use fly_session_types::fixtures;
use fly_session_types::scalar::DomainType;
use fly_session_types::trace::TransitionTrace;
use flysim::trace::FrameTrace;
use serde_json::Value;
fn lines(path: &std::path::Path) -> Vec<Value> {
std::fs::read_to_string(path)
.expect("the trace")
.lines()
.map(|line| serde_json::from_str(line).expect("one JSON object per line"))
.collect()
}
/// The shared baseline transition with this boundary's actions and captures in place of its own.
fn grafted(record: &Value) -> Result<TransitionTrace, String> {
let file = fixtures::load("traces.json").expect("traces.json");
let mut baseline = file.get("baseline").expect("baseline").clone();
baseline["behaviour"]["boundaryActions"] = record["behaviour"]["boundaryActions"].clone();
baseline["operational"]["captures"] = record["operational"]["captures"].clone();
TransitionTrace::from_json(&baseline).map_err(|error| error.to_string())
}
#[test]
fn the_boundary_half_of_the_trace_is_the_session_frameworks() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("trace.jsonl");
let mut trace = FrameTrace::create(&path).expect("the trace file");
// The start: the boot checkpoint, before any transition.
trace.capture(1, 100);
// Transition 100 -> 101 ends in a stall rollback, then the post-recovery checkpoint.
trace.sugar(400.0);
trace.begin(100, 1_000.0);
trace.rolled_back(&[]);
trace.capture(2, 101);
// Transition 101 -> 102 climbs a rung: the archive first, then the ratchet captures.
trace.begin(101, 1_017.0);
trace.capture(3, 102);
trace.slot_saved(b"emulator state");
trace.finish();
drop(trace);
let lines = lines(&path);
assert_eq!(lines.len(), 4, "format, start, two transitions: {lines:?}");
assert_eq!(lines[0]["format"], flysim::trace::FORMAT);
assert_eq!(lines[1]["boundary"], "100");
assert_eq!(lines[1]["operational"]["captures"][0]["checkpointId"], "g1");
let rollback = &lines[2];
assert_eq!(rollback["behaviour"]["step"], "100");
assert_eq!(rollback["behaviour"]["admissions"][0]["kind"], "sugar");
assert_eq!(
rollback["behaviour"]["boundaryActions"][0]["kind"],
"rollback"
);
assert_eq!(rollback["operational"]["captures"][0]["afterActions"], 1);
let parsed = grafted(rollback).expect("a rollback and then its checkpoint is a valid trace");
assert_eq!(parsed.behaviour.boundary_actions.len(), 1);
assert_eq!(parsed.operational.captures.len(), 1);
let climb = &lines[3];
assert_eq!(
climb["behaviour"]["boundaryActions"][0]["kind"],
"save-slot"
);
assert_eq!(
climb["behaviour"]["boundaryActions"][0]["slotId"],
flysim::trace::SLOT
);
assert_eq!(
climb["behaviour"]["boundaryActions"][0]["stateDigest"],
flysim::trace::sha256_hex(b"emulator state")
);
assert_eq!(climb["operational"]["captures"][0]["afterActions"], 0);
let refused = grafted(climb).expect_err("the legacy archive precedes the slot save");
assert!(
refused.contains("before this boundary's slot saves"),
"refused for the declared reason: {refused}"
);
}

View file

@ -267,7 +267,7 @@ async fn the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_kil
let (status, versions) = service.get("/status"); let (status, versions) = service.get("/status");
assert_eq!(status, 200); assert_eq!(status, 200);
assert_eq!(versions["version"]["kernel"], json!("lif-1ms-f64-v2")); assert_eq!(versions["version"]["kernel"], json!("lif-1ms-f64-v2"));
assert_eq!(versions["version"]["adapter"], json!("pokered-unique8-v7")); assert_eq!(versions["version"]["adapter"], json!("pokered-unique8-v6"));
assert!( assert!(
versions["version"]["dataset"].as_str().unwrap_or_default().len() > 32, versions["version"]["dataset"].as_str().unwrap_or_default().len() > 32,
"the dataset fingerprint is in /status: {}", "the dataset fingerprint is in /status: {}",
@ -437,17 +437,6 @@ async fn the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_kil
assert_eq!(logged[0]["by"], json!("integration-test")); assert_eq!(logged[0]["by"], json!("integration-test"));
let on_disk = std::fs::read_to_string(dir.path().join("state/events.jsonl")).unwrap(); let on_disk = std::fs::read_to_string(dir.path().join("state/events.jsonl")).unwrap();
assert!(on_disk.contains("integration-test fed the fly sugar"), "{on_disk}"); assert!(on_disk.contains("integration-test fed the fly sugar"), "{on_disk}");
// And in the hot directory's journal, stamped with the frame it was applied before, which
// is what a shadow run replays (`flysim::journal`).
let journal_path = dir.path().join("hot").join(flysim::journal::FILE_NAME);
let journal = flysim::journal::read(&journal_path).expect("the sugar journal");
assert_eq!(journal.len(), 1, "{journal:?}");
assert_eq!(journal[0]["kind"], json!("sugar"));
assert_eq!(journal[0]["eventId"], json!(event_id));
assert_eq!(journal[0]["durationMs"], json!(400.0));
let stamped: u64 =
journal[0]["frame"].as_str().and_then(|frame| frame.parse().ok()).expect("a frame");
assert!(stamped >= 1, "stamped with a frame the emulator has run: {stamped}");
// A second pulse while the first is still being applied is refused, not stacked. // A second pulse while the first is still being applied is refused, not stacked.
let (status, body) = service.post( let (status, body) = service.post(

View file

@ -43,7 +43,6 @@ use flybrain_gb::{
AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter,
}; };
use flysim::config::Config; use flysim::config::Config;
use flysim::frame::LegacyFrame;
use flysim::macros::{MacroLayer, macro_layer}; use flysim::macros::{MacroLayer, macro_layer};
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
@ -93,8 +92,6 @@ struct Run {
gb: Emulator, gb: Emulator,
adapter: PokemonRedReward, adapter: PokemonRedReward,
layer: MacroLayer, layer: MacroLayer,
/// The stream's frame (`flysim::frame::LegacyFrame`), behind the stub readout.
legacy: LegacyFrame,
decoder: PopulationDecoder, decoder: PopulationDecoder,
channels: Vec<&'static str>, channels: Vec<&'static str>,
ms: f64, ms: f64,
@ -127,7 +124,6 @@ impl Run {
gb, gb,
adapter, adapter,
layer, layer,
legacy: LegacyFrame::new(),
decoder, decoder,
channels, channels,
ms: 0.0, ms: 0.0,
@ -165,14 +161,18 @@ impl Run {
}; };
let bound = self.layer.bound_channels(); let bound = self.layer.bound_channels();
let active = self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound)); let active = self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound));
self.legacy.execute(Some(&mut self.layer), &active, 0, self.ms, &mut self.gb, &self.adapter); let mask = {
let ledger = AdapterLedger(&self.adapter);
self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger).mask
};
self.gb.set_buttons(mask as u8);
self.gb.run_frame().expect("a frame should complete");
self.ms += MS_PER_FRAME; self.ms += MS_PER_FRAME;
self.frame += 1; self.frame += 1;
let evaluated = self let ms = self.ms;
.legacy self.payouts.extend(self.adapter.sample(&mut self.gb, ms));
.stub_advance(Some(&mut self.layer), &mut self.gb, &mut self.adapter, self.ms) let ledger = AdapterLedger(&self.adapter);
.expect("a frame should complete"); let _ = self.layer.observe(&mut self.gb, &ledger, ms);
self.payouts.extend(evaluated.rewards);
} }
fn catches(&self) -> Vec<&RewardEvent> { fn catches(&self) -> Vec<&RewardEvent> {

View file

@ -1,432 +0,0 @@
//! The engagement rewards against the real cartridge: a conversation indoors and an item ball.
//!
//! Gated on `FLY_ROM` *and* on a checkpoint, the way every ROM test in this workspace is, and
//! skips cleanly without either:
//!
//! ```sh
//! FLY_ROM="$HOME/roms/pokemon-red.gb" \
//! FLY_ENGAGE_CHECKPOINT=.local/checkpoints/<a rung-10 Pewter checkpoint> \
//! cargo test --release -p flysim --test rom_engage -- --nocapture
//! ```
//!
//! ## What only the cartridge can answer
//!
//! The synthetic traces in `pokemon_red/tests.rs` write `wFontLoaded`, `wSpriteIndex`,
//! `wToggleableObjectFlags` and the rest from the disassembly. They cannot say that an A press at
//! a person on this cartridge opens the box on a frame whose previous sample was the fly's own,
//! that `wSpriteIndex` names that person by the time the adapter samples, or that `PickUpItem`
//! raises the ball's bit on a frame the adapter sees. This test does, with the shipping adapter
//! sampling once a frame, and it restores the checkpoint's own `v6` reward ledger -- so it is also
//! the `v6` -> `v7` migration on real game state: the item keys are seeded from the cartridge's
//! bits and nothing already taken pays.
//!
//! ## How the fly is moved
//!
//! Not by the macro layer, and not by a brain: a small scripted walker with a breadth-first route
//! over the whole-map grid (`state::map_grid`), pressing one direction at a time, answering text
//! with B and battles with A. The question is what the adapter reads, not whether anything finds
//! its way there. From the Pewter checkpoint it walks south out of the city, down Route 2 into the
//! Viridian Forest north gate -- a building, where it talks to the old man twice -- and on into
//! the forest to the Antidote ball at (25, 11), which it picks up, and then, after rolling the
//! emulator back to before the pickup, picks up again.
use std::collections::VecDeque;
use flybrain_gb::adapter::RewardEvent;
use flybrain_gb::pokemon_red::macros::state::{Facing, Walkable};
use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::pokemon_red::{PokemonRedReward, catalog, engage, state};
use flybrain_gb::{DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons};
const MS_PER_FRAME: f64 = 1000.0 / 59.7275;
const PEWTER_CITY: u8 = 0x02;
const ROUTE_2: u8 = 0x0d;
const NORTH_GATE: u8 = 0x2f;
const VIRIDIAN_FOREST: u8 = 0x33;
/// `constants/item_constants.asm`: `ANTIDOTE` is `$0b`.
const ANTIDOTE: &str = "FOUND ITEM #11";
fn rom() -> Option<Vec<u8>> {
let path = std::env::var_os("FLY_ROM")?;
match std::fs::read(&path) {
Ok(bytes) => Some(bytes),
Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"),
}
}
fn checkpoint() -> Option<flysim::store::Checkpoint> {
let path = std::env::var_os("FLY_ENGAGE_CHECKPOINT")?;
Some(
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope"),
)
}
struct Run {
gb: Emulator,
adapter: PokemonRedReward,
ms: f64,
frames: u64,
payouts: Vec<RewardEvent>,
/// `(wCurMap, wCurMapTileset)` on the frame each payout in `payouts` was made.
payout_maps: Vec<(u8, u8)>,
}
fn delta(facing: Facing) -> (i16, i16) {
facing.delta()
}
fn mask(facing: Facing) -> u8 {
match facing {
Facing::Up => buttons::UP,
Facing::Down => buttons::DOWN,
Facing::Left => buttons::LEFT,
Facing::Right => buttons::RIGHT,
}
}
const FACINGS: [Facing; 4] = [Facing::Up, Facing::Down, Facing::Left, Facing::Right];
impl Run {
fn resume(rom: &[u8], checkpoint: &flysim::store::Checkpoint) -> Self {
let mut gb = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
.expect("binjgb should accept the cartridge");
gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state");
let mut adapter = PokemonRedReward::new();
// The checkpoint was written by `pokered-unique8-v6`: this is the migration.
adapter.import_state(&checkpoint.runtime.reward).expect("a v6 ledger is a v7 ledger");
Self { gb, adapter, ms: 0.0, frames: 0, payouts: Vec::new(), payout_maps: Vec::new() }
}
fn byte(&mut self, address: u16) -> u8 {
self.gb.read_wram(address)
}
fn frame(&mut self, mask: u8) {
self.gb.set_buttons(mask);
self.gb.run_frame().expect("a frame should complete");
self.ms += MS_PER_FRAME;
self.frames += 1;
let ms = self.ms;
let events = self.adapter.sample(&mut self.gb, ms);
let here = (self.byte(ram::wCurMap), self.byte(ram::wCurMapTileset));
self.payout_maps.extend(events.iter().map(|_| here));
self.payouts.extend(events);
assert!(self.frames < 200_000, "the walker is lost: {}", self.whereabouts());
}
fn whereabouts(&mut self) -> String {
format!(
"map {:#04x} at ({}, {}), battle {}, font {}",
self.byte(ram::wCurMap),
self.byte(ram::wXCoord),
self.byte(ram::wYCoord),
self.byte(ram::wIsInBattle),
self.byte(ram::wFontLoaded)
)
}
fn map(&mut self) -> u8 {
self.byte(ram::wCurMap)
}
fn at(&mut self) -> (u8, u8) {
(self.byte(ram::wXCoord), self.byte(ram::wYCoord))
}
/// Whatever is on screen that is not the fly's to walk through: a battle (A through it) or a
/// text box (B through it). Returns once the overworld is controllable again.
fn settle(&mut self) {
for tick in 0..20_000u32 {
let in_battle = self.byte(ram::wIsInBattle) != 0;
let open = self.byte(ram::wFontLoaded) & 1 != 0;
if !in_battle && !open && state::controllable(&mut self.gb) {
if self.byte(ram::wWalkCounter) == 0 {
return;
}
self.frame(buttons::NONE);
continue;
}
let press = if in_battle { buttons::A } else { buttons::B };
self.frame(if tick % 8 < 3 { press } else { buttons::NONE });
}
panic!("the screen never settled: {}", self.whereabouts());
}
/// Press `facing` until the player has moved one tile, the map has changed, or it is plain
/// that the step is refused (which turns the player to face that way).
fn step(&mut self, facing: Facing) {
let (map, from) = (self.map(), self.at());
for _ in 0..48 {
self.frame(mask(facing));
if self.map() != map || self.at() != from {
break;
}
if self.byte(ram::wIsInBattle) != 0 || self.byte(ram::wFontLoaded) & 1 != 0 {
break;
}
}
self.frame(buttons::NONE);
self.settle();
}
/// Breadth-first over the decoded map, people excluded, from where the player stands to the
/// nearest tile `goal` accepts; the first step of that route, or `None`.
fn route(&mut self, goal: &dyn Fn(u8, u8) -> bool) -> Option<Facing> {
let grid = state::map_grid(&mut self.gb).ok()?;
let people: Vec<(u8, u8)> =
state::npcs(&mut self.gb).iter().map(|npc| (npc.x, npc.y)).collect();
let (width, height) = (grid.width(), grid.height());
let start = self.at();
let mut first: Vec<Option<Facing>> = vec![None; usize::from(width) * usize::from(height)];
let mut seen = vec![false; first.len()];
let index = |x: u8, y: u8| usize::from(y) * usize::from(width) + usize::from(x);
let mut queue = VecDeque::from([start]);
seen[index(start.0, start.1)] = true;
while let Some((x, y)) = queue.pop_front() {
if (x, y) != start && goal(x, y) {
return first[index(x, y)];
}
for facing in FACINGS {
if grid.walled(x, y, facing) {
continue;
}
let (dx, dy) = delta(facing);
let (Ok(nx), Ok(ny)) =
(u8::try_from(i16::from(x) + dx), u8::try_from(i16::from(y) + dy))
else {
continue;
};
if nx >= width || ny >= height || seen[index(nx, ny)] {
continue;
}
if grid.walkable(nx, ny) != Walkable::Yes || people.contains(&(nx, ny)) {
continue;
}
seen[index(nx, ny)] = true;
first[index(nx, ny)] = if (x, y) == start { Some(facing) } else { first[index(x, y)] };
queue.push_back((nx, ny));
}
}
None
}
/// Walk until standing on a tile `goal` accepts, on this map.
fn walk_to(&mut self, goal: &dyn Fn(u8, u8) -> bool) {
let map = self.map();
for _ in 0..400 {
self.settle();
assert_eq!(self.map(), map, "the walk left the map: {}", self.whereabouts());
let (x, y) = self.at();
if goal(x, y) {
return;
}
let Some(facing) = self.route(goal) else {
// A grid refused on this frame, or a person in the way: let a frame go by.
self.frame(buttons::NONE);
continue;
};
self.step(facing);
}
let grid = state::map_grid(&mut self.gb);
let detail = match &grid {
Ok(grid) => {
let (x, y) = self.at();
let mut rows = String::new();
for ty in 0..grid.height().min(24) {
for tx in 0..grid.width() {
rows.push(if (tx, ty) == (x, y) {
'@'
} else {
match grid.walkable(tx, ty) {
Walkable::Yes => '.',
Walkable::No => '#',
Walkable::Unknown => '?',
}
});
}
rows.push('\n');
}
format!("reachable {}\n{rows}", grid.reachable_from(x, y))
}
Err(refusal) => format!("grid refused: {}", refusal.label()),
};
panic!("never reached the goal: {}; {detail}", self.whereabouts());
}
/// Walk to `(x, y)` and keep pressing `out` until the map changes.
fn leave_by(&mut self, x: u8, y: u8, out: Facing) {
let map = self.map();
self.walk_to(&|tx, ty| (tx, ty) == (x, y));
for _ in 0..8 {
self.step(out);
if self.map() != map {
// The warp's fade and the new map's first frames.
for _ in 0..60 {
self.frame(buttons::NONE);
}
self.settle();
return;
}
}
panic!("pressing {out:?} at ({x}, {y}) never left the map: {}", self.whereabouts());
}
/// Stand beside `(x, y)`, face it, press A once and let the conversation run to its end.
/// Returns the payouts the conversation produced.
fn press_a_at(&mut self, x: u8, y: u8) -> Vec<RewardEvent> {
self.walk_to(&|tx, ty| tx.abs_diff(x) + ty.abs_diff(y) == 1);
let (px, py) = self.at();
let facing = FACINGS
.into_iter()
.find(|facing| {
let (dx, dy) = delta(*facing);
i16::from(px) + dx == i16::from(x) && i16::from(py) + dy == i16::from(y)
})
.expect("a neighbour faces the target one way");
// Turn in place: the step is refused because the thing is in the way.
self.step(facing);
assert_eq!(self.at(), (px, py), "turning must not move the player");
self.press_a_here()
}
/// Press A where the player stands and faces, and let whatever it opens run to its end.
fn press_a_here(&mut self) -> Vec<RewardEvent> {
for _ in 0..4 {
self.frame(buttons::NONE);
}
let before = self.payouts.len();
for _ in 0..6 {
self.frame(buttons::A);
}
self.frame(buttons::NONE);
self.settle();
for _ in 0..8 {
self.frame(buttons::NONE);
}
self.payouts[before..].to_vec()
}
fn of_kind(&self, kind: &str) -> usize {
self.payouts.iter().filter(|event| event.kind == kind).count()
}
}
fn kinds(events: &[RewardEvent]) -> Vec<&'static str> {
events.iter().map(|event| event.kind).collect()
}
#[test]
fn a_conversation_indoors_and_an_item_ball_each_pay_exactly_once_on_the_cartridge() {
let Some(rom) = rom() else {
eprintln!("skipped: FLY_ROM is not set");
return;
};
let Some(checkpoint) = checkpoint() else {
eprintln!("skipped: no FLY_ENGAGE_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, &checkpoint);
if run.map() != PEWTER_CITY {
eprintln!("skipped: the checkpoint is on map {:#04x}, not Pewter City", run.map());
return;
}
let before = run.adapter.progress().counts;
// The restore's first sample: the v6 ledger holds no item keys, so this is the seed.
run.settle();
assert!(run.payouts.is_empty(), "the migration pays nothing: {:?}", run.payouts);
// Pewter City's south edge, onto Route 2.
let height = run.byte(ram::wCurMapHeight) * 2;
run.walk_to(&|_, y| y == height - 1);
let (x, y) = run.at();
run.leave_by(x, y, Facing::Down);
assert_eq!(run.map(), ROUTE_2, "{}", run.whereabouts());
// Route 2's door into the forest's north gate, `warp_event 3, 11`: the gate is south of
// the city, so its door is entered heading south, from the tile above it.
run.leave_by(3, 10, Facing::Down);
assert_eq!(run.map(), NORTH_GATE, "{}", run.whereabouts());
let tileset = run.byte(ram::wCurMapTileset);
assert!(engage::indoor(tileset), "the gate is a building (tileset {tileset})");
// The old man at (2, 5): one conversation, one payout, when the box closes.
let first = run.press_a_at(2, 5);
eprintln!("gate, first conversation: {first:?}");
let talks: Vec<&RewardEvent> =
first.iter().filter(|event| event.kind == catalog::kind::TALK).collect();
assert_eq!(talks.len(), 1, "one conversation, one payout: {first:?}");
assert_eq!(talks[0].value, 0.10);
assert!(talks[0].label.starts_with("TALKED TO #"), "{}", talks[0].label);
// The same man again, and again: not a farm.
for _ in 0..2 {
let again = run.press_a_at(2, 5);
assert!(
!again.iter().any(|event| event.kind == catalog::kind::TALK),
"a second conversation with the same person pays nothing: {again:?}"
);
}
// On through the gate, `warp_event 4, 7`, into the forest.
run.leave_by(4, 7, Facing::Down);
assert_eq!(run.map(), VIRIDIAN_FOREST, "{}", run.whereabouts());
assert!(!engage::indoor(run.byte(ram::wCurMapTileset)), "the forest is not a building");
// The Antidote ball at (25, 11). Save the game just before, to take it twice.
run.walk_to(&|tx, ty| tx.abs_diff(25) + ty.abs_diff(11) == 1);
let slot = run.gb.export_state().expect("an emulator state");
let first = run.press_a_at(25, 11);
eprintln!("forest, the ball: {first:?}");
let items: Vec<&RewardEvent> =
first.iter().filter(|event| event.kind == catalog::kind::ITEM).collect();
assert_eq!(items.len(), 1, "one pickup, one payout: {first:?}");
assert_eq!(items[0].value, 0.15);
assert_eq!(items[0].label, ANTIDOTE);
assert!(
!first.iter().any(|event| event.kind == catalog::kind::TALK),
"a ball is not a conversation, and the forest is not indoors"
);
// The ball is gone: pressing A at the empty tile pays nothing.
let empty = run.press_a_here();
assert!(!empty.iter().any(|event| event.kind == catalog::kind::ITEM), "{empty:?}");
// A rollback to the slot with the ball still there, the way the ratchet restores one, and
// the fly takes it again: the same item, and it does not pay twice.
run.gb.import_state(&slot).expect("the slot restores");
run.adapter.clear_transient();
run.frame(buttons::NONE);
run.settle();
let again = run.press_a_at(25, 11);
eprintln!("forest, the ball after a rollback: {again:?}");
assert!(
!again.iter().any(|event| event.kind == catalog::kind::ITEM),
"once per item for the run: {again:?}"
);
let after = run.adapter.progress().counts;
eprintln!(
"{:.1} brain minutes; talk {} -> {}, item {} -> {}; every payout (kind, (map, tileset)): {:?}",
run.ms / 60_000.0,
before[catalog::kind::TALK],
after[catalog::kind::TALK],
before[catalog::kind::ITEM],
after[catalog::kind::ITEM],
kinds(&run.payouts).iter().zip(&run.payout_maps).collect::<Vec<_>>()
);
// The gate's exits were stood beside and walked through, and none of them paid: an indoor
// exit pays nothing. What *does* show up under the gate's id is Route 2's door, the
// outdoor exit the fly took: for the thirty-odd frames of `PlayMapChangeSound` the cartridge
// has already written the new `wCurMap` while the header, the warp table and the tileset
// are still Route 2's, and the rule reads that frame as what it is -- an outdoor exit.
for (event, (map, tileset)) in run.payouts.iter().zip(&run.payout_maps) {
assert!(
!(event.kind == catalog::kind::BOUNDARY && engage::indoor(*tileset)),
"a boundary payout on an indoor map's own header (map {map}): {event:?}"
);
}
assert_eq!(run.of_kind(catalog::kind::TALK), 1);
assert_eq!(run.of_kind(catalog::kind::ITEM), 1);
assert_eq!(after[catalog::kind::TALK], before[catalog::kind::TALK] + 1);
assert_eq!(after[catalog::kind::ITEM], before[catalog::kind::ITEM] + 1);
}

View file

@ -42,7 +42,6 @@ use flybrain_gb::{
AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons, AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter, buttons,
}; };
use flysim::config::Config; use flysim::config::Config;
use flysim::frame::LegacyFrame;
use flysim::macros::{MacroLayer, macro_layer}; use flysim::macros::{MacroLayer, macro_layer};
use flysim::snapshot::MacroMode; use flysim::snapshot::MacroMode;
@ -148,8 +147,6 @@ struct Run {
gb: Emulator, gb: Emulator,
adapter: PokemonRedReward, adapter: PokemonRedReward,
layer: MacroLayer, layer: MacroLayer,
/// The stream's frame (`flysim::frame::LegacyFrame`), behind the stub readout.
frame: LegacyFrame,
/// The readout under test: the shipping decoder, fed by hand. /// The readout under test: the shipping decoder, fed by hand.
decoder: PopulationDecoder, decoder: PopulationDecoder,
/// The macro channels, in the decoder's own order, for the rotation. /// The macro channels, in the decoder's own order, for the rotation.
@ -405,7 +402,6 @@ impl Run {
gb, gb,
adapter, adapter,
layer, layer,
frame: LegacyFrame::new(),
decoder, decoder,
channels, channels,
ms, ms,
@ -524,7 +520,6 @@ impl Run {
gb, gb,
adapter, adapter,
layer, layer,
frame: LegacyFrame::new(),
decoder, decoder,
channels, channels,
ms, ms,
@ -820,15 +815,9 @@ impl Run {
self.talk_on_pad = talk_bound; self.talk_on_pad = talk_bound;
let active = let active =
self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound)); self.decoder.decode_bound(&rates(hot), self.ms, false, None, Some(&bound));
let (started, blocked, done) = { let (mask, started, blocked, done) = {
let decision = self.frame.execute( let ledger = AdapterLedger(&self.adapter);
Some(&mut self.layer), let decision = self.layer.decide(&active, 0, self.ms, &mut self.gb, &ledger);
&active,
0,
self.ms,
&mut self.gb,
&self.adapter,
);
let started: Vec<&'static str> = decision let started: Vec<&'static str> = decision
.events .events
.iter() .iter()
@ -851,7 +840,7 @@ impl Run {
}) })
.map(|event| event.name) .map(|event| event.name)
.collect(); .collect();
(started, blocked, done) (decision.mask, started, blocked, done)
}; };
// Row 54's own measure, taken before the starts below so that a macro that finishes and // Row 54's own measure, taken before the starts below so that a macro that finishes and
// another that starts on the same frame are not confused for one another. // another that starts on the same frame are not confused for one another.
@ -974,10 +963,15 @@ impl Run {
let (x, y) = self.tile(); let (x, y) = self.tile();
self.started_at = Some((self.map(), x, y)); self.started_at = Some((self.map(), x, y));
} }
self.gb.set_buttons(mask as u8);
self.gb.run_frame().expect("a frame should complete");
self.ms += MS_PER_FRAME; self.ms += MS_PER_FRAME;
self.frame let ms = self.ms;
.stub_advance(Some(&mut self.layer), &mut self.gb, &mut self.adapter, self.ms) self.adapter.sample(&mut self.gb, ms);
.expect("a frame should complete"); {
let ledger = AdapterLedger(&self.adapter);
let _ = self.layer.observe(&mut self.gb, &ledger, ms);
}
// Battle boundaries, after the frame: what a battle cost in macros, and whether it ended. // Battle boundaries, after the frame: what a battle cost in macros, and whether it ended.
let now_in_battle = self.in_battle() != 0; let now_in_battle = self.in_battle() != 0;
match (self.was_in_battle, now_in_battle) { match (self.was_in_battle, now_in_battle) {
@ -3188,621 +3182,3 @@ fn the_pewter_east_pad_is_never_one_dead_button_from_the_rung_ten_checkpoint() {
// ten brain minutes in. // ten brain minutes in.
assert!(minutes < 1.0, "the fly waited {minutes:.2} brain minutes for a window to lapse"); assert!(minutes < 1.0, "the fly waited {minutes:.2} brain minutes for a window to lapse");
} }
/// The row-58 checkpoint (Pewter City, outside the gym, taken during the loop), or `None` to skip.
fn door_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_DOOR_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// From Pewter City, the checkpoint taken while the fly was walking in and out of the gym's door.
///
/// **What was live** (2026-09-23, rank 10 PEWTER CITY, v0.5.3): for twenty-five minutes
/// `GO OBJECTIVE` into the Pewter Gym and `GO OUT` straight back out, with `GO ITEM`,
/// `GO FRONTIER`, `YES` and `NO` mixed in -- about 93 `GO OUT` and 47 `GO OBJECTIVE` per ten brain
/// minutes, every one `done`, and not one reward event. The watchdog saw ten distinct names.
///
/// **What the survey found** (`infra/docs/macros-traps.md` row 58): from the gym's doormat the
/// cartridge draws only the guide, already talked to, and hides BROCK and the Jr. Trainer for being
/// off the screen -- so the rung's list of people was empty, `GO OBJECTIVE` had nothing to aim at
/// inside and `GO OUT` was the pad; outside, `GO OBJECTIVE` aimed at the door. And three frames
/// the seam read as the fly's own were the cartridge's: a warp's tear, a battle's transition, and
/// a trainer walking up -- each of which wrote an entry that kept the room empty.
///
/// The claims, none of them about which button the fly presses:
///
/// - **the gym is not a door in and a door out**: at most three arrivals end in the fly walking
/// straight back out inside ten seconds, against one every few seconds on the base;
/// - **the fly goes up the room**: it stands at row 6 or above on map 54, where the Jr. Trainer
/// is, which it never does on the base.
///
/// Rung 11 is printed and not asserted: which button the fly presses at the leader is the fly's.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_DOOR_CHECKPOINT=.local/checkpoints/release-rank10-row58.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture the_gym
/// ```
#[test]
fn the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint() {
let rom = skip_without_rom!();
let Some(checkpoint) = door_checkpoint() else {
eprintln!("skipped: no FLY_DOOR_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert_eq!(run.map(), PEWTER_CITY, "the checkpoint is the town outside the gym's door");
let mut arrivals = 0u32;
let mut bounces = 0u32;
let mut arrived_at: Option<u32> = None;
let mut highest_row: Option<u8> = None;
let mut frames_in_gym = 0u32;
let mut badge = None;
let mut previous = run.map();
for frame in 0..108_000u32 {
run.frame();
let map = run.map();
if map != previous {
if map == PEWTER_GYM {
arrivals += 1;
arrived_at = Some(frame);
} else if previous == PEWTER_GYM {
if arrived_at.is_some_and(|at| frame - at < 600) {
bounces += 1;
}
arrived_at = None;
}
previous = map;
}
if map == PEWTER_GYM {
frames_in_gym += 1;
if let Some(player) = flybrain_gb::pokemon_red::state::player(&mut run.gb)
&& u32::from(player.map) == PEWTER_GYM
{
highest_row = Some(highest_row.map_or(player.y, |row| row.min(player.y)));
}
}
if badge.is_none() && run.adapter.progress().rank >= 11 {
badge = Some(frame);
}
}
let progress = run.adapter.progress();
eprintln!(
"{:.1} brain minutes: gym arrivals {arrivals}, straight back out {bounces}, frames in the \
gym {frames_in_gym}, highest row reached {highest_row:?}, macros {:?}, rank {} ({})",
run.ms / 60_000.0,
run.started,
progress.rank,
progress.rank_label
);
match badge {
Some(frame) => eprintln!(
"rung 11 at frame {frame} ({:.2} brain minutes)",
f64::from(frame) * MS_PER_FRAME / 60_000.0
),
None => eprintln!("rung 11 not reached inside the budget"),
}
assert!(arrivals > 0, "the fly never went through the gym's door: {:?}", run.route);
assert!(
bounces <= 3,
"{bounces} of {arrivals} arrivals walked straight back out: {:?}",
run.started
);
assert!(
highest_row.is_some_and(|row| row <= 6),
"the fly never went up the room past the doormat rows: highest row {highest_row:?}"
);
}
fn row60_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_ROW60_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// Route 1, from the checkpoint taken mid-trap: Squirtle L5 (TACKLE, TAIL WHIP) against wild
/// Pidgey and Rattata.
///
/// **What was live** (2026-09-23, v0.5.5, rank 9 after the reset to milestone 1): `MOVE 2` 183
/// times and `MOVE 1` once, "Nothing happened!" on the screen, twenty-five brain minutes with no
/// reward. TAIL WHIP took the Pidgey's DEFENSE to where the cartridge refuses it (the stat at 1,
/// or the stage at -6) and the pad kept dealing it beside TACKLE; every battle ended with Squirtle
/// fainted and the fly back home (`infra/docs/macros-traps.md` row 60). The checkpoint itself is
/// the frame Squirtle fainted.
///
/// The driver is the real palette with the live readout's measured favourite: `MOVE 2` whenever
/// the pad deals it, otherwise a uniform choice per hold -- a harness choice, not the fly's. The
/// claims:
///
/// - **no `MOVE n` is dealt for a move the cartridge answers with nothing while another move
/// would do something**, on any frame of the fly's own turn, and the run does reach that state;
/// - **a wild battle is won** inside the budget, which the base never does with this driver.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_ROW60_CHECKPOINT=.local/checkpoints/release-rank9-row60.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture tail_whip
/// ```
#[test]
fn tail_whip_at_its_limit_is_not_dealt_and_a_route_one_battle_is_won() {
use flybrain_gb::pokemon_red::macros::PokemonPalette;
use flybrain_gb::pokemon_red::state;
use flybrain_gb::pokemon_red::symbols::ram;
use flybrain_gb::{MacroPalette, MemoryReader, Started};
let rom = skip_without_rom!();
let Some(checkpoint) = row60_checkpoint() else {
eprintln!("skipped: no FLY_ROW60_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert_ne!(run.gb.read8(ram::wIsInBattle), 0, "the checkpoint is inside the Route 1 battle");
let budget = 72_000u32;
let hold_frames = 48u32;
let mut palette = PokemonPalette::new(SEED);
let mut rng = 20_260_923u32;
let mut running = false;
let mut since_decision = hold_frames;
let mut ms = run.ms;
let mut at_a_limit = 0u32;
let mut dealt_without_effect = 0u32;
let mut starts = std::collections::BTreeMap::<&'static str, u32>::new();
// (frames, the enemy fainted, the fly's Pokemon standing at the end)
let mut battles: Vec<(u32, bool, bool)> = Vec::new();
let mut current: Option<(u32, bool, bool)> = Some((0, false, false));
for _ in 0..budget {
palette.clock(ms);
let observed = {
let ledger = AdapterLedger(&run.adapter);
palette.observe(&mut run.gb, &ledger)
};
let names: Vec<&str> = observed.bindings.iter().map(|binding| binding.name).collect();
if let Some(battle) = state::battle(&mut run.gb)
&& battle.own_turn
&& let Some(own) = battle.own
{
let mut nothing = [false; 4];
let mut useful = [false; 4];
for (slot, entry) in own.moves.iter().enumerate() {
if let Some(entry) = entry.filter(|entry| entry.id != 0) {
nothing[slot] = state::move_without_effect(&mut run.gb, entry.id) == Some(true);
useful[slot] = entry.pp > 0 && !nothing[slot];
}
}
if nothing.iter().any(|flag| *flag) && useful.iter().any(|flag| *flag) {
at_a_limit += 1;
for (slot, name) in ["MOVE 1", "MOVE 2", "MOVE 3", "MOVE 4"].iter().enumerate() {
if nothing[slot] && names.contains(name) {
dealt_without_effect += 1;
}
}
}
}
let mut mask = 0u8;
{
let ledger = AdapterLedger(&run.adapter);
if running {
match palette.step(&mut run.gb, &ledger) {
Some(held) => mask = held,
None => running = false,
}
} else if since_decision >= hold_frames && !observed.bindings.is_empty() {
since_decision = 0;
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
let binding = observed
.bindings
.iter()
.find(|binding| binding.name == "MOVE 2")
.unwrap_or(&observed.bindings[rng as usize % observed.bindings.len()]);
if let Started::Running(_) = palette.start(binding.slot, &mut run.gb, &ledger) {
*starts.entry(binding.name).or_default() += 1;
running = true;
match palette.step(&mut run.gb, &ledger) {
Some(held) => mask = held,
None => running = false,
}
}
}
}
since_decision += 1;
run.gb.set_buttons(mask);
run.gb.run_frame().expect("a frame should complete");
ms += MS_PER_FRAME;
run.adapter.sample(&mut run.gb, ms);
let fighting = run.gb.read8(ram::wIsInBattle) != 0;
match (&mut current, fighting) {
(Some((frames, fainted, standing)), true) => {
*frames += 1;
if let Some(battle) = state::battle(&mut run.gb) {
*fainted |= battle.enemy.is_some_and(|enemy| enemy.hp == 0);
if let Some(own) = battle.own {
*standing = own.hp > 0;
}
}
}
(None, true) => current = Some((1, false, true)),
(Some(battle), false) => {
battles.push(*battle);
current = None;
}
(None, false) => {}
}
}
let won = battles.iter().filter(|battle| battle.1 && battle.2).count();
eprintln!(
"{:.1} brain minutes: battles ended {} (won {won}), (frames, enemy fainted, standing) \
{battles:?}, still in one {current:?}; own-turn frames with a move without effect beside a \
useful one {at_a_limit}, the refused move dealt on {dealt_without_effect}; starts \
{starts:?}; rank {}",
(ms - run.ms) / 60_000.0,
battles.len(),
run.adapter.progress().rank,
);
assert!(at_a_limit > 0, "the run never reached the trap's own state");
assert_eq!(
dealt_without_effect, 0,
"a MOVE n the cartridge answers with nothing was dealt beside one it does not"
);
assert!(won > 0, "no wild battle was won in {budget} frames: {battles:?}");
}
const ROUTE_4: u32 = 0x0f;
const MT_MOON_1F: u32 = 0x3b;
/// A rung-11 checkpoint just after the Boulder Badge, or `None` to skip.
///
/// Row 59's is the route survey's own: `examples/scene_probe.rs` driven from the row-58 checkpoint
/// with `FLY_PROBE_CATCH=route FLY_PROBE_PREFER="GO OBJECTIVE,TALK"` until rung 11, written by
/// `FLY_PROBE_SAVE_RANK=11` on the first safe overworld frame -- in the gym, beside BROCK.
fn badge_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_BADGE_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// From the badge: the fly takes the road to Mt. Moon instead of walking the Pewter end of Route 3.
///
/// **What the row-58 review measured** (the route survey carried past the badge, 2026-09-23):
/// 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 and `GO ROUTE` done on Pewter 537. The live fly was due to reach
/// the same state as soon as it earned the badge.
///
/// **What was wrong** (`infra/docs/macros-traps.md` row 59), two things stacked:
///
/// - the map graph had Route 4 east of Route 3 and a Mt. Moon door on Route 3. The headers put
/// Route 4 north of Route 3 and both of Mt. Moon's doors on Route 4, so Route 3's north edge,
/// the one road to the mountain, named no map and was nobody's first hop; and Route 4's two
/// sides, which the mountain cuts apart, were one node;
/// - Route 3's first trainer closed his challenge onto five frames of plain overworld before the
/// battle was decided, and the walk he interrupted was written as a refusal on the first of
/// them: (11, 6), the one gap between the road's west end and the rest of it, walled for the
/// session.
///
/// The claims, none of them about which button the fly presses:
///
/// - the fly is on **Route 4** and at **Mt. Moon's door** (map `0x3b`) inside the budget;
/// - Pewter City and Route 3 are **not a ring**: under forty crossings between them, against
/// over a thousand on the base.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_BADGE_CHECKPOINT=.local/checkpoints/survey-rank11-row59.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture the_road_to_mt_moon
/// ```
#[test]
fn the_road_to_mt_moon_is_not_a_ring_at_the_pewter_end_from_the_badge_checkpoint() {
let rom = skip_without_rom!();
let Some(checkpoint) = badge_checkpoint() else {
eprintln!("skipped: no FLY_BADGE_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert!(run.adapter.progress().rank >= 11, "the checkpoint is past the badge");
let mut crossings = 0u32;
let mut on_route_4: Option<u32> = None;
let mut at_mt_moon: Option<u32> = None;
let mut previous = run.map();
for frame in 0..288_000u32 {
run.frame();
let map = run.map();
if map != previous {
if (previous == PEWTER_CITY && map == ROUTE_3) || (previous == ROUTE_3 && map == PEWTER_CITY) {
crossings += 1;
}
previous = map;
}
if on_route_4.is_none() && map == ROUTE_4 {
on_route_4 = Some(frame);
}
if at_mt_moon.is_none() && map == MT_MOON_1F {
at_mt_moon = Some(frame);
break;
}
}
let progress = run.adapter.progress();
eprintln!(
"{:.1} brain minutes: Route 4 at {on_route_4:?}, Mt. Moon at {at_mt_moon:?}, Pewter / Route 3 \
crossings {crossings}, rank {} ({}), route {:?}, macros {:?}",
run.ms / 60_000.0,
progress.rank,
progress.rank_label,
run.route,
run.started
);
assert!(crossings < 40, "{crossings} crossings between Pewter City and Route 3: {:?}", run.started);
assert!(on_route_4.is_some(), "the fly never reached Route 4: {:?}", run.route);
assert!(at_mt_moon.is_some(), "the fly never reached Mt. Moon's door: {:?}", run.route);
}
/// Route 4's two sides, read off the cartridge: the warp table the geography's pieces are keyed
/// by, and the piece the fly is standing in on arrival from Route 3.
///
/// The table's door indices and tiles are the disassembly's (`data/maps/objects/Route4.asm`); this
/// is the same three warps read from `wWarpEntries` on the loaded map, and the piece named by the
/// decoded grid ([`geography::region_on`]) rather than by the doors.
#[test]
fn route_4s_doors_and_sides_are_the_cartridges_from_the_badge_checkpoint() {
use flybrain_gb::pokemon_red::macros::geography::{self, Region};
let rom = skip_without_rom!();
let Some(checkpoint) = badge_checkpoint() else {
eprintln!("skipped: no FLY_BADGE_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
let mut read = None;
for _ in 0..216_000u32 {
run.frame();
if run.map() != ROUTE_4 {
continue;
}
let Some(player) = flybrain_gb::pokemon_red::state::player(&mut run.gb) else { continue };
let Ok(grid) = flybrain_gb::pokemon_red::state::map_grid(&mut run.gb) else { continue };
if u32::from(player.map) != ROUTE_4 || grid.map() != player.map {
continue;
}
let warps = flybrain_gb::pokemon_red::state::warps(&mut run.gb);
read = Some((player, warps, geography::region_on(player.map, player.x, player.y, Some(&grid))));
break;
}
let Some((player, warps, region)) = read else {
panic!("the fly never stood on Route 4 with its grid decoded: {:?}", run.route);
};
eprintln!("on Route 4 at ({}, {}), piece {region:?}, warps {warps:?}", player.x, player.y);
let doors: Vec<(u8, u8, u8)> =
warps.iter().map(|warp| (warp.x, warp.y, warp.destination_map)).collect();
assert_eq!(
doors,
vec![(11, 5, 0x44), (18, 5, 0x3b), (24, 5, 0x3c)],
"the Pokécenter, the cave mouth and B1F's exit, in the table's order"
);
assert_eq!(region, Region::piece(0x0f, 0), "arrived from Route 3, on the cave mouth's side");
}
const MT_MOON_POKECENTER: u32 = 0x44;
/// The live checkpoint from inside row 59's ring (Route 4, rank 12 MT. MOON), or `None` to skip.
fn mt_moon_live_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_MT_MOON_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// From the live checkpoint taken inside the ring: the fly goes into Mt. Moon instead of in and
/// out of the Pokécenter beside it.
///
/// **What was live** (2026-09-23 22:20 UTC, v0.6.0, rank 12 MT. MOON, the objective Cerulean City):
/// on Route 4, per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103, five distinct
/// macros and two new tiles. Route 4 was one node on the map graph with Cerulean off its east
/// edge, which Mt. Moon cuts off from the cave mouth's side, so the objective aimed at ground no
/// walk could reach and the Pokécenter door was the way out that was left. The route survey from
/// this checkpoint on `main` walks Route 4 and the Pokécenter 930 times in 72,000 frames.
///
/// The claims, none of them about which button the fly presses, over twenty brain minutes on the
/// stub rotation: the fly is **inside Mt. Moon** (map `0x3b`), and Route 4's west side and its two
/// doors, the Pokécenter and the cave mouth, are **not a ring**: under twenty-five crossings in all.
/// The base makes 56 (18 through the Pokécenter's door, 38 through the cave's); the rotation walks
/// into the cave on the base too, and back out, and in, because from 1F the graph's Cerulean was
/// Route 4's east edge beside it.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_MT_MOON_CHECKPOINT=.local/checkpoints/release-rank12-route4.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture the_fly_goes_into_mt_moon
/// ```
#[test]
fn the_fly_goes_into_mt_moon_from_the_live_route_4_checkpoint() {
let rom = skip_without_rom!();
let Some(checkpoint) = mt_moon_live_checkpoint() else {
eprintln!("skipped: no FLY_MT_MOON_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert!(run.adapter.progress().rank >= 12, "the checkpoint is the rung the ring was on");
let mut crossings = 0u32;
let mut in_mt_moon: Option<u32> = None;
let mut on_route_4 = 0u32;
let mut previous = run.map();
for frame in 0..72_000u32 {
run.frame();
let map = run.map();
if map != previous {
let door = |other: u32| other == MT_MOON_POKECENTER || other == MT_MOON_1F;
if (previous == ROUTE_4 && door(map)) || (door(previous) && map == ROUTE_4) {
crossings += 1;
}
previous = map;
}
if in_mt_moon.is_none() && map == MT_MOON_1F {
in_mt_moon = Some(frame);
}
if map == ROUTE_4 {
on_route_4 += 1;
}
}
eprintln!(
"{:.1} brain minutes: Mt. Moon at {in_mt_moon:?}, crossings of Route 4's west doors {crossings}, \
frames on Route 4 {on_route_4}, rank {}, route {:?}, macros {:?}",
run.ms / 60_000.0,
run.adapter.progress().rank,
run.route,
run.started
);
assert!(crossings < 25, "{crossings} crossings of Route 4's west doors: {:?}", run.started);
assert!(in_mt_moon.is_some(), "the fly never went into Mt. Moon: {:?}", run.route);
}
fn row61_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_ROW61_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// Viridian Forest's south gate, from the checkpoint pulled during the ring.
///
/// **What was live** (2026-09-23, v0.5.5, rung 9 after the reset to milestone 1): for twenty
/// minutes `GO OBJECTIVE` into the forest's south gate and `GO OUT` straight back onto Route 2,
/// `GO WARP` back from the forest, `GO OBJECTIVE blocked` in the forest, no reward
/// (`infra/docs/macros-traps.md` row 61). The forest's only road to its north gate is a two-wide
/// corridor at x = 1-2 with a Bug Catcher standing on (2, 18) facing west. A walk up the corridor
/// steps onto (1, 18) and the trainer takes the joypad. His "!" bubble (about sixty frames, before
/// `wJoyIgnore` is set) and the five frames after his text (before `wCurOpponent` is) read as the
/// fly's own overworld: the push-back the walk had earned was written on the first frame after the
/// text, (1, 18) was walled for the session, and every later walk to the north gate had no road.
/// Row 59's thirty-frame settle alone keeps the wall out; this row reads `BIT_TRAINER_BATTLE`.
///
/// The driver is the route survey's: the real palette, one uniform choice per hold, xorshift
/// seeded 7 -- a harness choice, not the fly's. Before rows 59 and 61 it never reached the north
/// gate in 72,000 frames. The claims:
///
/// - **no button is offered on an overworld frame inside a trainer's challenge**
/// (`wStatusFlags7` bit 3 set), and the run does reach such frames;
/// - **no tile of the forest is walled by a trainer's challenge**: (1, 18) never enters the pushed
/// ledger;
/// - **the fly goes through the north gate onto Route 2 and into Pewter City** inside the budget.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 \
/// FLY_ROW61_CHECKPOINT=<the rank-9 checkpoint pulled during the ring, under .local/checkpoints> \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture forests_north_gate
/// ```
#[test]
fn a_trainers_challenge_does_not_wall_the_road_to_the_forests_north_gate() {
use flybrain_gb::pokemon_red::macros::PokemonPalette;
use flybrain_gb::pokemon_red::macros::cartridge::{PushedLedger, Tile};
use flybrain_gb::pokemon_red::state;
use flybrain_gb::{MacroPalette, MemoryReader, Started};
const FOREST: u8 = 0x33;
const NORTH_GATE: u8 = 0x2f;
const ROUTE_2: u8 = 0x0d;
const PEWTER: u8 = 0x02;
let rom = skip_without_rom!();
let Some(checkpoint) = row61_checkpoint() else {
eprintln!("skipped: no FLY_ROW61_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert_eq!(run.map(), 0x32, "the checkpoint is the forest's south gate");
let budget = 40_000u32;
let hold_frames = 48u32;
let mut palette = PokemonPalette::new(SEED);
let mut rng = 7u32;
let mut running = false;
let mut since_decision = hold_frames;
let mut ms = run.ms;
let mut arrivals: Vec<(u32, u8)> = Vec::new();
let mut last = None;
let mut walled_at: Option<u32> = None;
// Overworld frames (the shared reading) inside a trainer's challenge, and those that dealt a pad.
let mut engaged = 0u32;
let mut engaged_dealt = 0u32;
for frame in 0..budget {
palette.clock(ms);
let observed = {
let ledger = AdapterLedger(&run.adapter);
palette.observe(&mut run.gb, &ledger)
};
// `wStatusFlags7` bit 3, `BIT_TRAINER_BATTLE`, read here rather than through the seam.
if run.gb.read8(flybrain_gb::pokemon_red::symbols::ram::wStatusFlags7) & (1 << 3) != 0
&& flybrain_gb::pokemon_red::scene::detect(&mut run.gb)
== flybrain_gb::pokemon_red::macros::state::Scene::Overworld
{
engaged += 1;
if !observed.bindings.is_empty() {
engaged_dealt += 1;
}
}
let mut mask = 0u8;
{
let ledger = AdapterLedger(&run.adapter);
if running {
match palette.step(&mut run.gb, &ledger) {
Some(held) => mask = held,
None => running = false,
}
} else if since_decision >= hold_frames && !observed.bindings.is_empty() {
since_decision = 0;
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
let binding = &observed.bindings[rng as usize % observed.bindings.len()];
if let Started::Running(_) = palette.start(binding.slot, &mut run.gb, &ledger) {
running = true;
match palette.step(&mut run.gb, &ledger) {
Some(held) => mask = held,
None => running = false,
}
}
}
}
let _ = palette.take_finished();
since_decision += 1;
run.gb.set_buttons(mask);
run.gb.run_frame().expect("a frame should complete");
ms += MS_PER_FRAME;
run.adapter.sample(&mut run.gb, ms);
if walled_at.is_none() && palette.fences().0.pushed(FOREST, Tile::new(1, 18)) {
walled_at = Some(frame);
}
if let Some(player) = state::player(&mut run.gb)
&& last != Some(player.map)
{
last = Some(player.map);
arrivals.push((frame, player.map));
}
}
let first = |map: u8| arrivals.iter().find(|(_, at)| *at == map).map(|(frame, _)| *frame);
let north_gate = first(NORTH_GATE);
let route_2_north = north_gate
.and_then(|gate| arrivals.iter().find(|(frame, map)| *frame > gate && *map == ROUTE_2))
.map(|(frame, _)| *frame);
let pewter = first(PEWTER);
eprintln!(
"{:.1} brain minutes: challenge overworld frames {engaged}, a pad dealt on {engaged_dealt}; \
(1, 18) walled at {walled_at:?}; north gate {north_gate:?}, Route 2 \
after it {route_2_north:?}, Pewter City {pewter:?}; pushed {:?}; rank {}; arrivals {}",
(ms - run.ms) / 60_000.0,
palette.fences().0,
run.adapter.progress().rank,
arrivals.len(),
);
assert!(engaged > 0, "the run never reached a trainer's challenge");
assert_eq!(engaged_dealt, 0, "a pad was dealt inside a trainer's challenge");
assert_eq!(walled_at, None, "a trainer's challenge walled the forest's corridor");
assert!(north_gate.is_some(), "the fly never reached the forest's north gate: {arrivals:?}");
assert!(route_2_north.is_some(), "nor Route 2 through it: {arrivals:?}");
assert!(pewter.is_some(), "nor Pewter City: {arrivals:?}");
}

View file

@ -62,13 +62,6 @@ bind = "127.0.0.1:7400"
# Audio attachment rate. 48 kHz is Web Audio's native rate on Linux, so the page never resamples. # Audio attachment rate. 48 kHz is Web Audio's native rate on Linux, so the page never resamples.
# env: FLYSIM_FEED_AUDIO_HZ # env: FLYSIM_FEED_AUDIO_HZ
audio_hz = 48000 audio_hz = 48000
# Who serves `bind`: "direct" (flysim, the default) or "bus" (flysim publishes on an embedded
# flybus router and the `fly-edge` process serves the same bytes; docs/design/flybus.md).
# env: FLY_FEED_VIA, FLYSIM_FEED_VIA
via = "direct"
# The bus router's socket and artifact store in "bus" mode. tmpfs.
# env: FLY_BUS_DIR, FLYSIM_FEED_BUS_DIR
bus_dir = "/run/fly/bus"
[control] [control]
# http://127.0.0.1:7401 — docs/control-api.md. Loopback only; there is no auth because nothing # http://127.0.0.1:7401 — docs/control-api.md. Loopback only; there is no auth because nothing

View file

@ -209,22 +209,6 @@ EXTRA_RAM = (
# services/flysim/tools/resolve_wram.py is the second reading of it, from ram/wram.asm at # services/flysim/tools/resolve_wram.py is the second reading of it, from ram/wram.asm at
# this commit, bracketed by wFontLoaded and wForcePlayerToChooseMon. # this commit, bracketed by wFontLoaded and wForcePlayerToChooseMon.
'wCapturedMonSpecies', 'wCapturedMonSpecies',
# The engagement rewards (`docs/rewards-learning.md`, the operator 2026-09-23).
# `talk`: DisplayTextID copies its argument -- the sprite slot, or a sign's text id --
# into wSpriteIndex before it looks the text up, and the overworld's A press only
# reaches it while wWalkCounter is zero. `item`: an item ball is a sprite whose
# wMapSpriteExtraData entry is (item id, 0) -- LoadMapHeader writes that shape for
# an ITEM-flagged object_event and no other -- found in the map's
# wToggleableObjectList; PickUpItem hides it by setting its global bit in
# wToggleableObjectFlags, and FoundHiddenItemText sets the hidden item's bit in
# wObtainedHiddenItemsFlags, each only after GiveItem succeeded.
# services/flysim/tools/resolve_wram.py is the second reading of all six.
'wSpriteIndex',
'wWalkCounter',
'wMapSpriteExtraData',
'wToggleableObjectFlags',
'wToggleableObjectList',
'wObtainedHiddenItemsFlags',
) )

View file

@ -57,38 +57,6 @@ WANTED = {
# this one caught", and the only signal that needs no second rule to tell a # this one caught", and the only signal that needs no second rule to tell a
# catch apart from a gift, a trade or an evolution. # catch apart from a gift, a trade or an evolution.
'wCapturedMonSpecies': 'the species a ball just caught, 0 for none', 'wCapturedMonSpecies': 'the species a ball just caught, 0 for none',
# The engagement rewards (`docs/rewards-learning.md`, the operator 2026-09-23).
# DisplayTextID stores its argument here -- a sprite slot, or a sign's text id --
# before it looks the text up, so it names what a conversation is with.
'wSpriteIndex': "DisplayTextID's argument, a sprite slot or a text id",
# Non-zero for the frames of a step; the overworld only reads an A press at zero.
'wWalkCounter': 'frames left in the step the player is taking',
# Two bytes per sprite slot: (item id, 0) for an ITEM-flagged object_event,
# (trainer class, trainer number) for a TRAINER one, zeroes otherwise.
'wMapSpriteExtraData': 'per sprite slot: item id, or trainer class and number',
# One bit per global toggleable object; PickUpItem's HideObject sets an item
# ball's bit once GiveItem has succeeded.
'wToggleableObjectFlags': 'global hidden bits of every toggleable object',
# The current map's (sprite slot, global toggleable index) pairs, $ff-terminated.
'wToggleableObjectList': "this map's toggleable sprites and their global indices",
# One bit per hidden item, set by FoundHiddenItemText once GiveItem succeeded.
'wObtainedHiddenItemsFlags': 'hidden items already found',
# Row 60 (`docs/design/macros.md` 12.23): a move the cartridge will answer
# with "Nothing happened!" is not dealt. StatModifierUpEffect and
# StatModifierDownEffect (engine/battle/effects.asm) refuse on the stage byte
# (1 is -6, 7 normal, 13 is +6) and on the modified stat itself (1 or 999);
# SleepEffect, PoisonEffect and ParalyzeEffect on the target's status byte and
# type. Each is the battle_struct field or stage array those routines read.
'wPlayerMonStatMods': "the active Pokemon's six stat stages, 7 is normal",
'wEnemyMonStatMods': "the enemy's six stat stages, 7 is normal",
'wEnemyMonStatus': "the enemy's status condition byte",
'wEnemyMonType1': "the enemy's first type (wEnemyMonType2 follows it)",
'wEnemyMonAttack': "the enemy's modified Attack, Defense, Speed, Special",
'wBattleMonAttack': "the active Pokemon's modified Attack, Defense, Speed, Special",
# Mist and a substitute turn a stat-lowering move away, a substitute a
# poisoning one, and a target that must recharge is put to sleep whatever
# its status (MoveHitTest, CheckTargetSubstitute, SleepEffect).
'wEnemyBattleStatus2': "the enemy's Mist, substitute and recharge bits",
} }
@ -108,33 +76,9 @@ WANTED = {
COUNTED = { COUNTED = {
'NUM_HMS': ('constants/item_constants.asm', r'^\s*add_hm\s+\w+'), 'NUM_HMS': ('constants/item_constants.asm', r'^\s*add_hm\s+\w+'),
'NUM_TMS': ('constants/item_constants.asm', r'^\s*add_tm\s+\w+'), 'NUM_TMS': ('constants/item_constants.asm', r'^\s*add_tm\s+\w+'),
# The engagement rewards need two more, for the same reason: each is an
# `EQU const_value` over an enumeration, and each is the size of a declaration
# the cursor has to cross. `ds NUM_STATS` sits between wFontLoaded and
# wTrainerClass, the bracket of wWalkCounter; `flag_array NUM_CITY_MAPS` sits
# between wNumHoFTeams and wStatusFlags5, the bracket of the toggleable-object
# and hidden-item flags. Both are counted only up to the line that defines
# them, which is where `const_value` is read.
'NUM_STATS': (
'constants/battle_constants.asm', r'^\s*const\s+STAT_\w+', r'^DEF NUM_STATS\b'
),
'NUM_CITY_MAPS': (
'constants/map_constants.asm', r'^\s*map_const\s+\w+', r'^DEF NUM_CITY_MAPS\b'
),
} }
def count_in(root: Path, path: str, pattern: str, stop: str | None = None) -> int:
"""Lines of `path` matching `pattern`, up to the first line matching `stop`."""
text = (root / path).read_text()
if stop is not None:
match = re.search(stop, text, re.M)
if match is None:
raise SystemExit(f'{path}: no line matches {stop}')
text = text[: match.start()]
return len(re.findall(pattern, text, re.M))
def pinned(text: str) -> dict[str, int]: def pinned(text: str) -> dict[str, int]:
"""Every address `symbols.rs` carries today, by symbol name.""" """Every address `symbols.rs` carries today, by symbol name."""
return { return {
@ -151,20 +95,19 @@ def constants(root: Path) -> dict[str, int]:
BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left
out, which kills the cursor at any declaration that uses it. out, which kills the cursor at any declaration that uses it.
""" """
counted = {name: count_in(root, *spec) for name, spec in COUNTED.items()} counted = {
name: len(re.findall(pattern, (root / path).read_text(), re.M))
for name, (path, pattern) in COUNTED.items()
}
pending: dict[str, str] = {} pending: dict[str, str] = {}
sources = sorted((root / 'constants').glob('*.asm')) + sorted( sources = sorted((root / 'constants').glob('*.asm')) + sorted(
(root / 'constants').glob('*.inc') (root / 'constants').glob('*.inc')
) )
for path in sources: for path in sources:
text = path.read_text()
for name, value in re.findall( for name, value in re.findall(
r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', text, re.M r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M
): ):
pending.setdefault(name, value.strip()) pending.setdefault(name, value.strip())
# A name counted by `const` or `rb` is an expression over the running
# counter at its own line, so it overrides the raw `EQU const_value - 1`.
pending.update(enumerated(text))
out: dict[str, int] = dict(counted) out: dict[str, int] = dict(counted)
while pending: while pending:
progressed = False progressed = False
@ -186,91 +129,6 @@ def constants(root: Path) -> dict[str, int]:
return out return out
def enumerated(text: str) -> dict[str, str]:
"""The names one constants file defines by counting, as expressions.
rgbasm keeps two running counters the decomp enumerates with: `const_value`
(`const_def`, `const`, `const_skip`, `const_next` in macros/const.asm) and
`_RS` (`rsreset`, `rsset`, `DEF NAME rb/rw n`, `rb_skip`). This follows both
in file order and writes each name down as the *expression* the counter held
at its line, never as a number: [`constants`]' passes evaluate it with the
rest, so a count over a constant this tool cannot resolve stays unresolved
rather than becoming a guess. A counter form it does not know (a
`shift_const`, a non-literal step) kills that counter until the next reset.
"""
out: dict[str, str] = {}
value: str | None = None
step = '1'
rs: str | None = None
for raw in text.splitlines():
line = raw.split(';')[0].strip()
if not line:
continue
match = re.fullmatch(r'const_def(?:\s+([^,]+?))?(?:\s*,\s*(.+))?', line)
if match:
value = f'({match.group(1) or "0"})'
step = f'({match.group(2) or "1"})'
continue
match = re.fullmatch(r'(?:const|const_export)\s+(\w+)', line)
if match:
if value is not None:
out[match.group(1)] = value
value = f'({value} + {step})'
continue
match = re.fullmatch(r'const_skip(?:\s+(.+))?', line)
if match:
if value is not None:
value = f'({value} + {step} * ({match.group(1) or "1"}))'
continue
match = re.fullmatch(r'const_next\s+(.+)', line)
if match:
value = f'({match.group(1)})'
continue
if line.startswith(('shift_const', 'dw_const')):
value = None
continue
if line == 'rsreset':
rs = '(0)'
continue
match = re.fullmatch(r'rsset\s+(.+)', line)
if match:
rs = f'({match.group(1)})' if '_RS' not in match.group(1) else None
continue
match = re.fullmatch(r'(rb|rw)_skip(?:\s+(.+))?', line)
if match:
if rs is not None:
unit = 1 if match.group(1) == 'rb' else 2
rs = f'({rs} + {unit} * ({match.group(2) or "1"}))'
continue
match = re.fullmatch(r'(?:DEF|def)\s+(\w+)\s+(rb|rw)(?:\s+(.+))?', line)
if match:
if rs is not None:
out[match.group(1)] = rs
unit = 1 if match.group(2) == 'rb' else 2
rs = f'({rs} + {unit} * ({match.group(3) or "1"}))'
continue
match = re.fullmatch(r'(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+(.+)', line)
if match is None:
# Anything else may be a macro that moves a counter this does not
# follow (`add_tm` advances `const_value`), so both stop here.
if not line.startswith(('ASSERT', 'assert', 'EXPORT', 'export')):
value = None
rs = None
continue
if re.search(r'\b(?:_RS|const_value)\b', match.group(2)):
expression = match.group(2)
if '_RS' in expression:
if rs is None:
continue
expression = re.sub(r'\b_RS\b', rs, expression)
if 'const_value' in expression:
if value is None:
continue
expression = re.sub(r'\bconst_value\b', value, expression)
out[match.group(1)] = expression
return out
def number(token: str) -> int: def number(token: str) -> int:
token = token.strip() token = token.strip()
if token.startswith('$'): if token.startswith('$'):
@ -318,27 +176,16 @@ def size_of(expression: str, known: dict[str, int]) -> int:
return value * scale return value * scale
def macro_sizes( def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]:
root: Path, known: dict[str, int], fields: dict[str, list[tuple[str, int]]] | None = None """Sizes of the RAM struct macros, counted from their own declarations."""
) -> dict[str, int]:
"""Sizes of the RAM struct macros, counted from their own declarations.
With `fields`, also each macro's `\\1Name::` field labels and their offsets,
for a macro whose whole body was sized: `battle_struct wEnemyMon` declares
`wEnemyMonStatus` at the offset its own lines put it.
"""
out: dict[str, int] = {} out: dict[str, int] = {}
for path in sorted((root / 'macros').glob('*.asm')): for path in sorted((root / 'macros').glob('*.asm')):
text = path.read_text() text = path.read_text()
for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S): for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S):
name, body = match.group(1), match.group(2) name, body = match.group(1), match.group(2)
total = 0 total = 0
offsets: list[tuple[str, int]] = []
for line in body.splitlines(): for line in body.splitlines():
line = line.split(';')[0].strip() line = line.split(';')[0].strip()
field = re.match(r'^\\1(\w+)::', line)
if field is not None and total is not None:
offsets.append((field.group(1), total))
# A struct macro labels each field with its argument # A struct macro labels each field with its argument
# (`\\1YCoord:: db`), so the label is stripped and the # (`\\1YCoord:: db`), so the label is stripped and the
# declaration after it is what reserves the bytes. # declaration after it is what reserves the bytes.
@ -362,8 +209,6 @@ def macro_sizes(
break break
if total is not None: if total is not None:
out[name] = total out[name] = total
if fields is not None:
fields[name] = offsets
return out return out
@ -371,8 +216,7 @@ def walk(
root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False
) -> tuple[dict[str, int], list[str], int]: ) -> tuple[dict[str, int], list[str], int]:
"""Resolve every symbol of wram.asm the anchored cursor can reach exactly.""" """Resolve every symbol of wram.asm the anchored cursor can reach exactly."""
fields: dict[str, list[tuple[str, int]]] = {} macros = macro_sizes(root, known)
macros = macro_sizes(root, known, fields)
lines = (root / 'ram/wram.asm').read_text().splitlines() lines = (root / 'ram/wram.asm').read_text().splitlines()
cursor: int | None = None cursor: int | None = None
resolved: dict[str, int] = {} resolved: dict[str, int] = {}
@ -489,21 +333,6 @@ def walk(
continue continue
if line.endswith('::') or re.fullmatch(r'\.\w+', line): if line.endswith('::') or re.fullmatch(r'\.\w+', line):
continue continue
# A struct macro's own field labels, at the offsets its body puts them:
# held back like any other label until the next pinned address agrees.
invocation = re.fullmatch(r'(\w+)\s+(w\w+)', line)
if invocation is not None and cursor is not None:
for field, offset in fields.get(invocation.group(1), []):
name = invocation.group(2) + field
if name not in table:
pending_run[name] = cursor + offset
elif table[name] != cursor + offset:
problems.append(
f'{name}: wram.asm gives ${cursor + offset:04x}, '
f'symbols.rs pins ${table[name]:04x}'
)
else:
checked += 1
try: try:
if cursor is not None: if cursor is not None:
cursor += declaration_size(line, known, macros) cursor += declaration_size(line, known, macros)