91 KiB
Macro traps: the Viridian loop, and the audit beside it
Reproduced and fixed 2026-09-17 on the WSL development box, from the release box's own checkpoint
(.local/checkpoints/release-viridian-loop.checkpoint, never committed). docs/design/macros.md
section 12 is the contract throughout: knowledge lives inside macros, never in the choice, and
every fix below is a change to what a macro's target choice considers, or to which buttons a
scene deals — never to a ranking, a prior or a fallback action.
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
flysim --print-compatibility is byte-identical to v0.3.1:
lif-1ms-f64-v2/pokered-unique8-v5/75ba5d35…/fly-kc-mbon-rstdp-v2/binjgb:c60e138d…/pokered:0cd19d3b…/statefmt:199616-x86_64-unknown-linux-gnu
What was live
Rank 8 (VIRIDIAN CITY, next VIRIDIAN FOREST), 2 h 17 min on the rung, ratchet rollbacks 3/3 spent, stall meter 7:14 over a 2:00 window. The fly stood by a building door next to a girl, and the event log repeated every ~3 brain seconds:
GO NPC start, GO NPC done (150 ms), GO OUT start, GO OUT done (170 ms),
NEXT start, NEXT done, GO FRONTIER start, GO FRONTIER done (420 ms)
The pad showed only GO ROUTE and GO FRONTIER; GO OBJECTIVE was gone.
The mechanism
Six facts, in the order they compound. The frame counts are brain milliseconds at the emulator's 16.74 ms per frame, so 150 ms is nine frames and 420 ms is twenty-five.
- A walk across a town is longer than the frame cap.
FRAME_CAPis 600 frames (10 s) and a tile costsTILE_FRAMES + STEP_GAP= 30 frames at worst, so a walk from the south of Viridian to its north edge cannot finish inside one macro. EveryGO ROUTEaimed at the Route 2 connection endedTimeout. - A
Timeoutwrote the blocked ledger. Section 12.1 triggers the ten-brain-minute exclusion on "abortsBlockedorTimeout", so the connection the fly was half way to was excluded as unreachable — which is not what a frame cap measures. (It is now three strikes for a walk that ends nearer its goal, and immediate exclusion for one that does not: see row 1 and row 23 for why the first, simpler reading of this did not survive its own measurement.) - That is why GO OBJECTIVE left the pad. Not the rollback, and not "already reached": rung 9's
place is
VIRIDIAN_FOREST,geography::next_hopmakes the first hopROUTE_2, and the only exit taking it is the north edge — the exit the ledger had just excluded.objective_goalsfilters the blocked ones, came back empty, andprecondition(GoObjective)is!objective_goals(..).is_empty(). The two macros aim at oneTargetKey::Exit, so poisoning it for one poisoned it for both. - GO ROUTE then fell through to its last-resort tier. Tier 1 (a destination this run has not stood on) was empty once the connection was excluded and the town's interiors had been entered; tier 2 (toward the objective) was empty for the same reason; tier 3 was "all of them, nearest", and nearest is the house door the fly was standing at. Section 9.2 fixed which exits count as visited and left that fallback standing.
- GO OUT bounced straight back out. Inside,
exit_goalskeeps a doormat underfoot — "its press is the point" — and the interior's front door is writtenLAST_MAP, whichExit::destinationcould not resolve becausemacros::geographyhas no row for an ordinary Viridian house. An unnameable destination counted as unvisited, soGO OUTwas permanently first-tier fresh, aiming at the tile under the fly's feet:Arrival::Leavepresses DOWN, the map changes, and the macro isDonein ten frames. The same bounce runs on a staircase, which is what the local reproduction showed:GO WARPup,GO OUTstraight back down and out, once per hold. - Nothing in the cycle recorded anything. The macros all ended
Done, so no blocked entry was written; andGO NPC'sDonecame from the scene-change rule (completed = false) as the warp fired under it, which is exactly the path that does not write the reached ledger — so 12.1's "a reached person is retired" never fired for the girl by the door.GO FRONTIER's arrival press faced new ground it could not stand on, which the contract callsDoneand which leaves the tile a frontier for ever, because the exploration ledger only records ground somebody stood on. Four macros, ten to twenty-five frames each, nothing learned, once per hold.
Why GO OUT appeared on an outdoor map: it did not. path::exits classifies every warp of an
outdoor map as Way::Route, so GO OUT's precondition is false outdoors and it can only ever be
dealt indoors. The log interleaved two maps: the cycle crossed a warp twice every three seconds, so
GO OUT (and, in the local reproduction, GO WARP) came from the indoor half of the same three
seconds and the pad the operator read — GO ROUTE, GO FRONTIER — from the outdoor half.
The ratchet could not help. Ratchet::budget_spent is checked before either trigger, so at 3/3
attempts on one best rank no recovery fires again until the rank improves, which a loop cannot do.
The stall meter keeps measuring and observe keeps returning false — and separately, GO FRONTIER
netting one new tile every few minutes bumps coverage, which resets last_progress and disarms
the stall trigger on its own. The macro layer has to break its own loops.
The audit
Every macro and every scene binding, one row per way the fly can be left cycling or stranded.
"Test" names the fake-game test in
services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs unless it says otherwise.
| # | trap | trigger | test | fix, or why it is left |
|---|---|---|---|---|
| 1 | Timeout excludes the target of a walk that was making progress |
a walk longer than 600 frames; takes GO OBJECTIVE off the pad with GO ROUTE |
a_timeout_costs_a_closing_walk_a_strike_and_a_stalled_one_its_target |
fixed: a Timeout that ended nearer a goal costs one of TIMEOUT_STRIKES (three, section 4's number at the macro's scale) and the next hold resumes the walk; one that ended no nearer excludes the target at once |
| 2 | GO ROUTE's last-resort fallback re-picks the nearest door for ever |
every route leads somewhere visited and none is toward the objective | go_route_prefers_a_door_whose_interior_this_run_has_not_seen |
fixed: no tier 3 for Way::Route; the button leaves the pad. GO OUT keeps it, because a room has to be leavable |
| 2b | the same bounce on the stairs: up, straight back down, up again | a floor whose staircase leads somewhere the run has been | a_way_out_is_visited_only_when_the_map_on_the_other_side_is, a_front_door_nobody_can_name_is_not_somewhere_new |
fixed: no tier 3 for Way::Passage either — except on a map with no other way out (Red's bedroom, an upper floor), where dropping it would strand the fly |
| 3 | a front door nobody can name reads as somewhere new | any building with no row in macros::geography — most houses |
a_front_door_nobody_can_name_is_not_somewhere_new |
fixed: an unresolvable Way::Exit counts as visited; the only way onto an interior map is through its own front door |
| 4 | GO NPC / GO ITEM completes without moving |
the fly already stands beside the target and faces it | a_thing_the_fly_is_already_facing_is_talks_and_not_a_walks |
fixed: untalked() drops the tile-ahead target; TALK's precondition is exactly that state, so the press is what the pad offers |
| 5 | GO FRONTIER completes without moving, for ever |
the arrival press cannot enter the new ground (a ledge, a tile-pair rule, a person) | a_frontier_tile_whose_new_ground_cannot_be_stood_on_is_excluded |
fixed: Walk::stalled writes the blocked ledger on an otherwise-Done arrival |
| 6 | a no route refusal records nothing and repeats once per hold |
a bound walking macro whose goals are all unreachable (the precondition is the cheap question) | a_no_route_refusal_records_what_it_could_not_reach |
fixed: script reports every goal key it could not reach, start writes them, and the driver drains after every start, refusal included |
| 7 | own turn with an empty pad | trainer battle, no move with PP, no healthy reserve, no potion: ATTACK/SWITCH/ITEM/RUN all drop |
no_playable_scene_deals_an_empty_pad |
fixed: NEXT closes the own-turn row, and ITEM joins it as section 12 lists it |
| 8 | forced switch with an empty pad | healthiest_other() is None in a menu the game will not let the fly cancel |
no_playable_scene_deals_an_empty_pad |
fixed: SWITCH, NEXT |
| 9 | Unknown offers one A press and A cannot leave |
the Pokédex, the trainer card, OPTION — all land in Unknown (docs/design/macros-wram.md) |
a_forced_switch_plans_one_entry_and_the_other_scenes_plan_their_one_move |
fixed: NEXT, BACK; B is what leaves all three |
| 10 | a yes/no box can only ever be answered YES | every prompt in the game; YES/NO were in no plan at all |
same | fixed: Dialog deals NEXT, YES, NO. See row 21 for what is still approximate about it |
| 11 | the start menu cannot be closed by anything but B, and CONFIRM is on no pad |
Menu planned CLOSE alone |
same | fixed: CLOSE, CONFIRM, BACK, which is section 12's own set |
| 12 | eight overworld buttons into six rows cut GO FRONTIER |
a room with a person, an object, stairs and a known objective | the_overworld_plan_never_truncates_the_frontier_away |
fixed: the tail survives; the middle gives way |
| 13 | the warp bounce itself: GO OUT from the mat it walked in over |
standing on an interior doormat | covered by rows 2 and 3 (trap_hunt before/after) |
left: it does move the fly to another map, and the contract keeps a doormat underfoot; the loop is broken on the way in instead |
| 14 | the name-entry / nickname screen | catching a Pokémon, then A = YES on "give a nickname?" | — | left, contract call: it reads Unknown and needs START or the "ED" cell; NEXT/BACK cannot leave. Putting MENU (START) on the Unknown pad is a section 12 change, not a macro one |
| 15 | YES/NO are dealt for every text box, not only an open choice |
any plain dialog | row 10's test | left, no observable: pokered has no "the game wants a button" flag (macros-wram.md), and A and B both advance a plain box, so nothing is lost |
| 16 | the mart cannot buy | MacroState::shop_stock defaults empty over live WRAM |
the_shop_row_needs_money_and_stock (existing) |
left: the seam's own narrowing; the two buttons are now planned and bind the day the stock list is read |
| 17 | the PC can only be left | section 12's PC set is LEAVE |
— | left, contract: nothing in the macro vocabulary deposits or withdraws |
| 18 | MENU is on no pad at all |
the overworld row is full at six | — | left: no room at SLOTS = 6; raising it is a feed (game.palette[].slot) and stage change |
| 19 | a cursor macro waits 180 frames and reports Blocked |
ATTACK or RUN started in the last frames of own_turn: listing() is None for most of a battle |
— | left: one wasted hold, no loop — the turn moves on, and the macro carries no target to poison |
| 20 | a rollback writes neither ledger | the ratchet fires mid-walk | existing driver.rs tests |
left, contract (12.1: "a rollback's cancellation writes neither — that is the loop's doing, not the map's") |
| 21 | the session ledgers do not survive a checkpoint | a restart re-offers every target once | — | left, contract (12.1: "a restored run offers every target once more") |
| 22 | the ratchet stops helping at 3/3 attempts on a rung | budget_spent() precedes both triggers; and one new tile resets the stall window |
ratchet.rs tests |
left, contract: "deliberately conservative and can leave an unproductive run unrecovered". It is why the macro layer must break its own loops, and why trap_hunt exists |
| 23 | A* oscillation into Walkable::Unknown |
a walk that keeps moving but re-plans back and forth over the screen-buffer edge | a_timeout_costs_a_closing_walk_a_strike_and_a_stalled_one_its_target |
fixed by the strike count, which is why it is strikes and not "did the player move": that first reading traded the Viridian loop for this one, twelve timeouts every two brain minutes over two tiles, measured in the first after-run |
| 24 | a walk that can only get closer to an unreachable goal carries no target | GO FRONTIER, whose goals are one key each, so "the key every goal shares" is None and the search returns its closest-approach route |
a_walk_that_can_only_get_closer_still_names_the_target_it_set_out_for |
fixed: the nearest goal is what the heuristic drove the walk toward, so it is what the walk set out for — without it the timeout recorded nothing and the same walk was available again on the next hold |
| 25 | the mart cannot buy (row 16, reopened) | MacroState::shop_stock defaulted empty over live WRAM |
the_four_purchases_are_bound_by_the_counters_own_stock |
fixed: wItemList is the open counter's own list and an item's place in it is its cursor index (docs/design/macros-wram.md section 7). Viridian stocks no Potion, so BUY POTION is correctly off the pad in the first mart the fly walks into |
| 26 | a mart clerk and a nurse cannot be reached at all | every one of the four tiles around either of them is wall or floor behind the desk | a_counter_is_the_only_way_to_reach_a_clerk_or_a_nurse |
fixed: wTilesetTalkingOverTiles and IsSpriteOrSignInFrontOfPlayer's counter branch — the tile two away, facing in, is a place to talk from. Without it GO SHOP refuses no route once per hold for ever |
| 27 | the errand loops: the fly walks into the mart, out, and in again | an errand keyed on anything but "this run has been inside" | the_errand_is_outstanding_once_per_area_per_run |
fixed: areaVisited(kind, area) is written on arrival and never cleared, so the errand is paid once per area per run. A purchase marks nothing |
| 28 | RUN is bound for every wild battle, so the fly flees fights it is winning ("we run away a lot") |
any wild battle at all | run_is_off_the_pad_in_a_wild_battle_the_fly_is_not_losing |
fixed: section 13.1's losing — under a third of HP with no healthier reserve, or no PP anywhere with nothing to switch to. A fled battle pays nothing and teaches nothing |
| 29 | the bag opened from a battle's ITEM entry has no cursor to read | choosing ITEM in a battle | item_reaches_the_potion_by_reading_the_bags_cursor |
fixed: BattleMenu::Bag (macros-wram.md 7.1). ITEM used to open the bag, wait out CURSOR_WAIT pressing nothing, and report Blocked — every potion the fly ever chose ended that way |
| 30b | the pad has a button that cannot move the fly anywhere ("the macro buttons disappear and everything just hangs there") | every route visited, every person and object excluded, the near frontier covered, no objective hop | a_map_with_every_ledger_against_it_still_offers_a_way_out, the_last_resort_prefers_the_exit_toward_the_objective |
fixed: ways' last resort offers the visited exit toward the objective (ignoring the blocked window), and frontier_aims falls back to the nearest unstood tile of the whole map. Reached only when nothing else is on the pad, so row 2's loop does not come back |
| 31 | an empty pad in a playable scene is indistinguishable from a hang | any of the causes listed in section 13.1 | the_empty_pad_clock_runs_only_while_a_playable_scene_binds_nothing, a_running_macro_is_not_an_empty_pad |
measured, not acted on: game.padEmptyMs and fly_pad_empty_seconds. Nothing presses for the fly, so waiting is the doctrine working; what was missing was being able to tell it from a stall |
| 32b | rows 12 and 18 are retired | — | the_overworld_plan_never_truncates_the_frontier_or_the_menu_away |
retired by section 14: a slot per macro type, so nothing is truncated off a pad and MENU is on every overworld |
| 33b | TALK cannot reach over a counter |
standing at a mart's or a centre's counter facing the person behind it | a_counter_is_the_only_way_to_reach_a_clerk_or_a_nurse |
fixed: facing_target looks two tiles ahead over a counter. It looked one, found the counter tile, answered nothing -- so GO SHOP could walk the fly to a counter and the press that opens it was not on the pad there. The two macros between them could reach a mart and not buy in it |
| 34b | the errand is spent on the building's own doormat | arriving in a mart or a centre, where exit_goals keeps a mat underfoot (row 13) and any walk that presses DOWN warps out |
go_shop_enters_the_mart_once_and_walks_to_its_counter (ROM-gated) |
fixed: while an amenity's counter is unfaced, nothing on its pad leaves the building (palette::counter_pending) -- the same rule row 29 gave the rung's own target. The measured culprit was GO OBJECTIVE, not GO OUT: the rung's place is on another map, so the objective's goal was the mat the fly was standing on. The errand is paid once per area per run, so that bounce spent the one visit and nothing could bring the fly back |
| 35 | the screen-buffer tile read disagrees with itself on a map smaller than the screen | any interior smaller than ten tiles by nine -- most houses, both amenities | a_counter_is_the_only_way_to_reach_a_clerk_or_a_nurse (the map-id half) |
worked around, not fixed: the same tile of the Viridian mart reads walkable-and-counter from one of the fly's tiles and wall-and-not-counter from another two tiles away, because an 8x8 map cannot centre under a ten-by-nine view and the player is no longer at the buffer's fixed point. The counter reach is taken from the map id instead. The predicate itself is unchanged and is still the walk's pricing, which is why the purchase below is not ROM-proven. This is the next thing to measure |
| 37 | the private-property tile: a text box that reopens every frame the fly stands on one tile, for most of a run | standing on (19, 9) of Viridian City without the Pokédex — ViridianCityCheckGotPokedexScript prints "This is private property!" and walks the player down, every frame — and that tile is one of the four approach offers around the sleeping old man at (18, 9) |
a_tile_the_cartridge_pushes_the_fly_off_is_not_a_tile_to_walk_to, a_scripted_push_back_records_the_tile_it_happened_on |
fixed: a session ledger keyed on the tile, with no window, that every aim and the route filter on. Measured 53,266 of 54,377 text-box frames in twenty brain minutes on that one tile, 991 answered YES. The blocked ledger could not close it — it is keyed on the target, so it excluded a villager for ten brain minutes, said nothing about the ground, and reopened |
| 37a | the same tile, still crossed | excluding it as a goal only | the route half of the same test | fixed: route_avoiding treats it as impassable. The goal filter alone took (19, 9) from 53,266 text-box frames to 12,919, because the A* went on routing across it toward somewhere else and the script fires on any frame the fly stands there. With the route filter it is 1,538 |
| 36 | a counter faced is lost to the next hold | GO SHOP ends at the counter looking at the clerk; a hold is 800 ms and whichever macro wins the next one turns the fly away |
— | left, bounded: TALK is on the pad there, and the reached window brings GO SHOP back in ten brain minutes, so it is one wasted approach per window rather than a loop. It is why the purchase is unit-proven and not ROM-proven, and closing it would mean a macro that presses A at what it walked to -- which is TALK's and the fly's (section 3, 2026-09-16) |
Fifteen fixed, eight left — six of those eight because the contract says so or there is nothing in WRAM to ask (14, 15, 17, 20, 21, 22), and two (13, 19) because they are bounded and the fix would be bigger than the trap. Rows 1, 2b, 23 and 24 were each found by running the hunt after the previous fix: three of the five loops in this file only became visible once the one in front of them was gone.
2026-09-17, sections 13 and 14. Rows 25 to 32b above. Row 16 is reopened and closed (the mart can buy), row 19's cursor wait is closed for the one list it actually bit on (row 29), and rows 12 and 18 are retired by the slot-per-type pad. Rows 25 to 29 came out of the scene audit asked for — "check to make sure each scene has the full complement of options" — and rows 30b and 31 out of "sometimes the macro buttons disappear and everything just hangs there", which is the first trap in this file that is correct behaviour the audience cannot tell from a fault. It is measured rather than acted on, because nothing presses for the fly.
The trap hunt
services/flysim/crates/flysim/examples/trap_hunt.rs runs macros mode from a checkpoint for N
brain minutes over the sim loop's own frame order and flags every two-brain-minute window (one
window every 15 brain seconds, so a loop is caught wherever it starts) in which either
- the fly stood on fewer than 4 distinct
(map, tile)s, or - one macro sequence repeated more than 10 times, at any period up to 8.
A window with no macro in it is not flagged: silence waits, and that is the doctrine working.
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_MINUTES=20 cargo run --release -p flysim --example trap_hunt
20 brain minutes from the Viridian checkpoint, seed 20260917, 4 sweep threads, same connectome and same cartridge in both runs.
Before (main at v0.3.1, d53f278)
| measure | value |
|---|---|
| distinct (map, tile) over 20 brain minutes | 152 |
| macros started | 1374 |
| done / blocked / timeout / refused | 1365 / 4 / 5 / 4 |
| windows flagged | 66 of 73 |
| windows under 4 distinct tiles | 29 |
| worst window | 2 tiles, 150 macros |
| longest repeat | GO FRONTIER, GO WARP, GO OUT, NEXT x37 |
The first and last flagged windows of the run, as the example printed them:
| at (brain min) | tiles | macros | repeated sequence |
| 0.25 | 105 | 72 | `GO FRONTIER, GO WARP, GO OUT, NEXT` x11 |
| 1.75 | 5 | 149 | `NEXT, GO FRONTIER, GO WARP, GO OUT` x37 |
| 8.00 | 5 | 149 | `GO OUT, NEXT, GO FRONTIER, GO WARP` x36 |
| 12.50 | 2 | 149 | `NEXT, NEXT, NEXT, NEXT, GO ITEM, NEXT, NEXT` x11 |
| 18.00 | 2 | 149 | — |
Thirty-seven of the flagged windows are one rotation or another of that four-macro cycle, and the
run ends stuck: from 11 brain minutes on, every window holds two tiles and about 149 macros, with
a second cycle of NEXT … GO ITEM … NEXT inside it. No recovery fired — the ratchet was at 3/3 on
rung 8 when the checkpoint was taken, so budget_spent refuses before either trigger.
After (fix/macros-traps)
| measure | value |
|---|---|
| distinct (map, tile) over 20 brain minutes | 194 |
| macros started | 291 |
| done / blocked / timeout / refused | 186 / 5 / 99 / 1 |
| windows flagged | 51 of 73 |
| windows under 4 distinct tiles | 1 |
| worst window | 2 tiles, 12 macros |
| longest repeat | GO FRONTIER x19 |
The same slice of the after-run:
| at (brain min) | tiles | macros | repeated sequence |
| 1.50 | 93 | 28 | `GO FRONTIER` x12 |
| 2.75 | 19 | 19 | `GO FRONTIER` x19 |
| 11.00 | 44 | 93 | `NEXT, GO FRONTIER, GO WARP, GO OUT` x12 |
| 13.00 | 64 | 72 | `GO FRONTIER, GO WARP, GO OUT, NEXT` x12 |
| 18.00 | 5 | 12 | `GO FRONTIER` x12 |
The hard cycle is gone: no window repeats a sequence of the four macros more than 12 times (it was 37), and only one window in the run holds fewer than four tiles (it was 29). A fifth more ground is covered on a fifth of the macros — 291 against 1374 — which is the shape of the fix: the walks now run to the frame cap and resume instead of finishing in ten frames and starting again.
Two residuals, both bounded and both left, named here rather than papered over:
- 41 windows are flagged for one macro repeating:
GO FRONTIERx11–x19, each one spending the full 600-frame cap. Viridian City is wider than ten seconds of walking, so a frontier tile across it takes several macros to reach; the windows that carry these repeats cover 5 to 93 tiles, so the fly is exploring rather than stuck.TIMEOUT_STRIKESis what bounds it: three caps on one target and the target is excluded for the ten-minute window. - 10 windows still show the building cycle, at x11–x12 rather than x37, and over 27 to 64 tiles rather than 2 to 5. The fly walks into a building, does something and comes out; it is a three-repeat-per-minute pattern with real ground under it, not the ten-frame bounce.
The sequence rule at period 1 therefore has a known false-positive shape — a legitimately repeated explorer — and the tile rule is the one that separates the two. A future tightening is to flag a repeat only when the window's tile count is also low; the thresholds here are the ones the operator asked for and they are left as specified.
Gates
cargo test --workspacewithFLY_ROMset: green exceptflysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed, which fails identically on main atd53f278on this box — it asserts a 30 Hz publish rate and a debug build here reaches 10.2–10.4 Hz. Pre-existing, unrelated to the macro layer (that test runsmacros.mode = raw).cargo clippy --all-targets: clean.infra/tests/lint.sh: ALL CHECKS PASSED.- No TypeScript touched, so npm was not run.
2026-09-17 later: every walk in Viridian times out, and none completes
Reproduced and fixed the same day on the WSL development box, from the release box's own checkpoint
(.local/checkpoints/release-viridian-timeouts.checkpoint, never committed) — a second stall on the
same rung, four hours after the loop above was fixed and v0.3.2 deployed. docs/design/macros.md
section 12.3 is the contract for what changed.
What was live
Rank 8 (VIRIDIAN CITY), 02:04 to 07:00 UTC, five hours with no rung and no completed macro. The last 40 KB of the event log held nothing but starts and timeouts:
116 GO ITEM start 117 GO ITEM timeout
36 GO ROUTE start 36 GO ROUTE timeout
12 GO OBJECTIVE start 12 GO OBJECTIVE timeout
No done, no blocked, no refused, and nothing else at all. The gaps between bursts are the ten
brain minutes of the blocked ledger: three caps on one target excluded it, and with every candidate
excluded the pad was empty until the window expired.
The mechanism
Measured rather than reasoned, from the checkpoint over the sim loop's own frame order, with the
per-macro rows examples/trap_hunt.rs now prints:
| macro | outcome | n | mean frames | mean tiles | mean net | mean reach |
|---|---|---|---|---|---|---|
| GO ITEM | timeout | 36 | 600 | 2.0 | 0.0 | 1.0 |
| GO OBJECTIVE | timeout | 2 | 600 | 2.0 | 0.0 | 1.0 |
| GO ROUTE | timeout | 6 | 600 | 2.0 | 0.0 | 1.0 |
Forty-four walks, forty-four timeouts, every one of them over exactly two tiles with a net displacement of zero and a maximum reach of one tile. So the cap was not short of the map: the fly never got more than one tile from where each walk began. Three facts compound.
- The closest-approach answer walks toward what it cannot reach. With no goal reachable,
path::routereturns the route to the tile that gets nearest one, so the walk always had somewhere to go — andGO ITEM's goals are the four tiles around one object, which in Viridian are behind the gym's fence, a ledge or a building for most of the city. - The A* re-planned after every tile. The walkable predicate's window is the ten-by-nine screen buffer and it moves with the player, so the search's map changed under the walk's feet every step. The walk stepped to the closest-approach tile, the re-plan from the new tile answered with the tile it had just left, and the pair traded the fly back and forth until the cap. This is row 23 of the audit above, which three strikes bounded and did not fix.
- A frame cap that had been one tile nearer once read as progress.
best_distance < start_distanceis true after a single step toward the goal, so 12.2's rule charged a strike rather than excluding anything. Three strikes, ten brain minutes of exclusion, and the same walk again — 165 times.
The 600-frame cap is a real second bound behind all this, and it is fixed too: Viridian City is
about twenty tiles by eighteen, a tile costs TILE_FRAMES + STEP_GAP = 30 frames at worst, and the
walk from the checkpoint's tile to the Route 2 connection is thirty-odd tiles. No amount of not
oscillating would have finished it inside ten seconds.
What changed
All of it inside the macros; the decoder, the reward catalog, the adapter version and the
compatibility string are untouched (flysim --print-compatibility byte-identical to v0.3.2, below).
Section 12.3 has the contract; the audit table gains two rows.
| # | trap | trigger | test | fix |
|---|---|---|---|---|
| 25 | a closest-approach walk oscillates over the walkable window's edge for the whole cap, and the cap reads as progress | any goal walled off from where the fly stands: an item behind the gym fence, a north-edge tile behind trees | a_goal_walled_off_from_the_fly_is_excluded_without_a_press, the_walk_commits_to_its_route_across_the_walkable_windows_edge |
fixed: a goal the full-map search cannot reach is excluded at start — nothing pressed, every goal key to the blocked ledger — and the route is planned once and followed while the player is on it, re-planned only when a step is refused, the plan runs out, or the player is not where the plan left it |
| 26 | a walk longer than ten seconds can never finish, and starts again from the fly's tile every hold | a town wider than FRAME_CAP frames of walking, which is every town |
a_walk_longer_than_the_old_cap_finishes_because_it_keeps_making_progress, the_walk_budget_is_the_plan_plus_a_floor_and_stops_at_a_minute, a_walk_the_ceiling_cuts_short_resumes_from_where_it_stopped |
fixed: the budget is 24 frames per planned tile plus FRAME_CAP as a floor, capped at 60 s of brain time; a walk still closing on a goal is not out of frames; the remaining route is kept per (map, TargetKey) and the next start carries it on from the tile it was suspended at |
Two smaller ones from the same reading, both in row 25's tests:
- A refused step is a wall in the direction it refuses. The survey method
(
docs/design/macros-wram.md) is what settles how the cartridge encodes these:HandleLedgesmatches a triple of facing, the tile stood on and the tile in front againstLedgeTilesand hops the player two tiles, and the ledge tile is absent from every tileset's passable list — sostate::walkablealready calls a ledgeNoand the search treats it as a wall in both directions, declining the hop rather than getting it wrong.TilePairCollisionsLandand...Waterare the ones nothing in the collision list can predict: they refuse a step between two tiles that are each passable. Those are measured — a step that spends its whole window with the player where it started becomes a directed wall for the rest of that walk, and the re-plan goes round. A person in the way has the same shape and costs the walk one detour. - A rollback drops a suspended route, because a route is a list of directions from one tile and a rollback moves the fly off it (12.1: "a rollback's cancellation writes neither — that is the loop's doing, not the map's").
The trap hunt, before and after
Same invocation as the section above, 20 brain minutes, seed 20260917, 4 sweep threads, same
connectome and same cartridge in both runs, from release-viridian-timeouts.checkpoint:
FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
FLY_TRAP_CHECKPOINT=.local/checkpoints/release-viridian-timeouts.checkpoint \
FLY_TRAP_MINUTES=20 cargo run --release -p flysim --example trap_hunt
| measure | before (main, v0.3.2 db393d9) |
after (fix/macros-walks) |
|---|---|---|
| distinct (map, tile) over 20 brain minutes | 2 | 142 |
| macros started | 44 | 1,144 |
| done | 0 | 1,129 |
| blocked / timeout / refused | 0 / 44 / 0 | 0 / 14 / 1 |
| windows flagged | 39 of 73 | 50 of 73 |
| windows under 4 distinct tiles | 39 | 50 |
| worst window | 2 tiles, 12 macros | 2 tiles, 150 macros |
| walks that spent their whole cap | 44 of 44 | 14 of 1,144 |
| mean net displacement of a timed-out walk | 0.0 tiles | 6.4 tiles (GO ITEM) |
The stall is gone: the walks complete. A fly that covered two tiles in twenty brain minutes now covers 142, on 1,129 completed macros where there were none, and the fourteen walks that still spend their budget are long ones — 9 to 14 tiles of ground each, 6 to 8 tiles of net displacement — which resume on the next hold instead of excluding what they were aimed at.
And the trap behind it: the scene reads dialog and stays there
The hunt's window count went up, and that is the honest reading of it rather than a regression in
the fix. From 5.75 brain minutes to the end of the run every window holds two tiles and about 149
macros, and the macros in them are NEXT (607) and NO (327) and nothing else — one A press and
one B press, for fourteen brain minutes. Every overworld walk in the run, and all 142 tiles of
ground, happen in the first 5.75 minutes. The scene tally says the same thing across the whole run:
| scene | frames |
|---|---|
| dialog | 41,012 |
| overworld | 26,197 |
| unknown | 4,464 |
So the next trap is one of two things and this run does not settle which: a box whose text an A
press and a B press genuinely cannot advance (the shape of rows 14 and 15 above — no WRAM observable
says what a box is waiting for, and MENU (START) is on no pad at all), or a dialog reading that
is wrong, off map tiles that look like a box. Settled the same day in the section below: it is the
first, and the box is a cartridge gate.
Correction to a first reading of this, kept because it was written down. The run ends in scene
dialogwith the adapter's mode readingOVERWORLD, and that was recorded here as the detector and the adapter disagreeing. They do not:PokemonRedReward::modeisOVERWORLDfor every frame that is not a battle, a boot, a transition or the Safari — it is not a dialogue gate at all. The adapter's dialogue gate issafe_for_snapshot, which does readwFontLoaded, and it agreed with the detector throughout.
The ROM test measures the same wall from the other end: macros mode reaches the mart's door in 3.2
brain minutes on 27 macros and then never leaves Viridian City in seven brain hours — 1,500,000
frames, 30,946 macros, 30,921 of them done, ending at tile (19, 9) with 7,535 NEXTs, 7,515
YESes and 7,515 NOs spent on it. GO OBJECTIVE toward Route 2 is behind that wall, which is why
the test asserts the mart door and the completion of walks rather than Route 2.
Named here rather than worked around, and left for its own run: it is a scene-detection or a pad question, not a walk one, and the walks are what this section fixed. That run is the section below.
What this exposed, and did not fix
Row 29's proof was EVENT_OAK_GOT_PARCEL at 27,167 frames on 504 macros from this checkpoint. It
held for exactly one trajectory. The battle sub-states above change what the fly does in Route 1's
grass on the way to the errand — it chooses a move now instead of pressing A at the text — and the
rotation's phase inside Oak's lab moved with it: from the same checkpoint the fly reaches the lab,
presses TALK ninety-four times, and does not deliver in 900,000 frames (16 brain hours),
bouncing 0↔40 with GO OUT running 753 times.
GO OUT running at all in the room the rung is in means row 29's suppression had lifted, and it
lifts when objective_targets empties — which the talked ledger does, because it filters the
objective's own target. So a fly that has talked to everyone in the lab once, without the delivery
firing, stops being held in the room. That is a gap in row 29 rather than in this fix, and the fix
here only changed which trajectory finds it.
| # | trap | trigger | test | fix, or why it is left |
|---|---|---|---|---|
| 31 | the talked ledger retires the objective's own target, so the room stops holding the fly | a rung whose conversation did not fire the first time: the wrong person talked to, or a script with a precondition | — | left, named and measured: objective_targets filters talked, and 12.5 argued the reached ledger should not apply to the objective for exactly this reason. The same argument applies to talked and was not made: a rung's conversation is not "had" until the rung is earned. It needs its own run, because "talked and the rung still unearned" is also how a fly would grind an unearnable rung for ever |
The ROM test for row 29 is therefore narrowed to what the macros can be held to — the parcel carried
and undelivered, the lab reached by way of Pallet Town and never through the shut road, and TALK
taken in the room the rung is in — with the delivery measured rather than asserted. A game-blind
rotation does not aim, and one A press at one particular person is aiming; that claim belongs to a
run with a brain in it and a reward for the rung, which is the same thing
macros_mode_leaves_the_house_and_reaches_oaks_lab has recorded about the starter since 2026-09-16.
Gates
cargo test --workspacewithFLY_ROMandFLY_TRAP_CHECKPOINTset: green exceptflysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed, which fails identically on main atdb393d9on this box: a debug build publishes at 9.46 Hz against the test's 30 Hz contract and the ladder is not loaded when/statusis first read. Pre-existing and unrelated to the macro layer (that test runsmacros.mode = raw). The macro suites are green: 278 inflybrain-gb's lib and both ROM tests inflysim'srom_macros_modecargo clippy --all-targets: clean.infra/tests/lint.sh: ALL CHECKS PASSED.flysim --print-compatibility: the same 648 bytes as the string recorded at the top of this file, every component identical. The decoder, the reward catalog and the adapter version are untouched by this branch.- No TypeScript touched, so npm was not run.
2026-09-17 later still: the dialog residual is a real box, and the corner test was thin
The section above left the residual as one of two things. Measured on the WSL development box from the same checkpoint, with the real connectome driving macros mode, it is the first of them — and the investigation found the second as a latent hazard rather than the cause, so both are recorded here.
What the bytes say
examples/trap_hunt.rs now counts, once per frame, how the dialog branch's two halves agree:
wFontLoaded's bit 0 (the WRAM flag DisplayTextIDInit sets and CloseTextDisplay clears) against
the drawn TextBoxBorder at screen (0, 12)-(19, 17), read both as the four corners the detector used
and as the whole figure. Twelve brain minutes, 43,004 frames:
| the dialog branch's two halves | frames |
|---|---|
| font set, corners drawn, whole border drawn | 18,775 |
font set, corners drawn, border not (a false dialog) |
0 |
| font set, corners not drawn (menu or unknown) | 0 |
| corners drawn, font clear (harmless: overworld) | 315 |
No frame in the run was misclassified. Every one of the 18,775 dialog frames was a fully drawn
text box, and the scene-run tally says the same from the other side: the longest unbroken dialog
run is 248 frames, four seconds, against 6,245 frames for overworld. The fly was not stuck in
a box; it was going in and out of one.
What it is: a scripted gate at (19, 9)
The periodic trace (FLY_TRAP_TRACE_SECONDS) puts the fly at map 1, tile (19, 9) from 6.00 brain
minutes to the end of the run, with:
trace 6.00 min scene=dialog player=Player { map: 1, x: 19, y: 9, facing: Up } ahead=None
font=0x01 textbox=0x01 joy=0 sim=0 flags5=0x00 move=0x00 corners=(0x79,0x7b,0x7d,0x7e)
trace 7.00 min scene=unknown player=Player { map: 1, x: 19, y: 10, facing: Down } ahead=None
font=0x00 textbox=0x01 joy=0 sim=0 flags5=0x80 move=0x00 corners=(0x40,0x39,0x50,0x23)
trace 7.67 min scene=dialog player=Player { map: 1, x: 19, y: 9, facing: Down } ahead=None
font=0x01 textbox=0x01 joy=0 sim=1 flags5=0x80 move=0x00 corners=(0x79,0x7b,0x7d,0x7e)
Three things in that, and together they name the box:
ahead=None. Nothing the macros can see is on the tile in front — no object sprite, no sign. So the box is not one the fly walked up to and talked to; it fires on the step.sim=1andflags5=0x80.wSimulatedJoypadStatesIndexis non-zero andwStatusFlags5bit 7 is set: the cartridge is driving the player with its own joypad states. That is a script moving the fly, not the fly moving.- The fly oscillates between (19, 9) and (19, 10), facing Up then Down. It steps north, the script fires, a box opens, the script walks it back south, the box closes, and the objective sends it north again.
That is Viridian City's blocking NPC on the road north — the gate that stands between the fly and
ROUTE_2 until the parcel errand is done. NEXT and NO do advance the box; the 248-frame runs
are the proof. What the fly cannot do is pass, and rung 9's place is on the other side, so
GO OBJECTIVE walks it back into the gate every hold. The Unknown frames in the middle are the
scripted push-back, which is the detector working: a frame where the buttons do not reach the player
is not the overworld.
What was fixed: the drawn half of the test
The cause is not a detector bug, but the investigation measured one anyway, and it is the reason the
question was worth asking: 315 frames of 43,004 had all four corner positions holding the frame's
own tile ids with no text box anywhere (400 frames of 60,000 on the stub-driven ROM test). The
frame tiles $79, $7b, $7d and $7e are ordinary ground in the overworld tilesets — the same
range TilePairCollisionsLand names $76 and $78 in — so the four-corner test fires on the map
several times a minute, and the only thing between that and a wrong scene was wFontLoaded.
So the WRAM flag stays the gate, and the drawn half now reads the whole TextBoxBorder — four
corners, both horizontal runs, both vertical runs, 76 bytes rather than 4 — in state::text_box and
in state::start_menu, which had the same reading. A map cannot draw that by accident.
No N-frame persistence rule was added, and that is a measurement rather than a preference: with the border read whole there were zero disagreeing frames to debounce, and a persistence rule costs correctness in the other direction — a real box would read as the previous scene for N frames, which is exactly long enough for a macro to start against the wrong palette.
| # | trap | trigger | test | fix, or why it is left |
|---|---|---|---|---|
| 27 | four map tiles are read as a text box | the overworld tilesets use the frame's tile ids as ground; 315 frames of 43,004 drew all four corners with no box | four_map_tiles_that_look_like_a_box_are_not_a_dialog, the_start_menus_box_is_read_the_same_way, the_map_is_never_read_as_a_text_box_from_the_stalled_checkpoint (ROM) |
fixed: wFontLoaded stays the gate and the drawn test reads the whole TextBoxBorder; start_menu likewise |
| 28 | the objective walks the fly into a scripted gate once per hold | rung 9's place is past Viridian's blocking NPC; the script opens a box and pushes the fly back, so the walk never fails and nothing is recorded | — | left, named: it is a target-ledger question, not a detector one. A walk whose arrival is answered by wSimulatedJoypadStatesIndex and a scripted push-back has been refused by the cartridge and should enter the blocked ledger; that is a macros change with its own measurement |
The trap hunt, after the detector change
Same invocation, 20 brain minutes, seed 20260917, 4 sweep threads, from
release-viridian-timeouts.checkpoint. The detector change is meant to be a no-op on this run — every
frame it reclassifies is one no run has produced — and it is:
| measure | before the detector change | after |
|---|---|---|
| distinct (map, tile) over 20 brain minutes | 142 | 142 |
| macros started | 1,144 | 1,144 |
| done / blocked / timeout / refused | 1,129 / 0 / 14 / 1 | 1,129 / 0 / 14 / 1 |
| windows flagged | 50 of 73 | 50 of 73 |
frames read as dialog |
41,012 | 41,012 |
longest unbroken dialog run |
248 | 248 |
Identical, measure for measure, which is what a no-op looks like when it is written down. The
branch's own counters over the same 71,673 frames: 41,012 frames of a real, fully drawn box,
0 the corner test would have called dialog without a border behind it, and 682 frames —
about one per cent of the run — where the map drew all four corners with the font flag clear. The
rate is why the test was worth hardening; the zero is why nothing about this run changed.
The residual itself is untouched and expected to be: it is trap 28, the scripted gate, and it is a macros change rather than a detector one.
Gates
cargo test --workspacewithFLY_ROMandFLY_TRAP_CHECKPOINTset: green exceptflysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed, which fails identically on main on this box (a debug build publishes at 9.46 Hz against the test's 30 Hz contract). Pre-existing and unrelated; that test runsmacros.mode = raw. The scene and macro suites are green: 280 inflybrain-gb's lib and all three ROM tests inflysim'srom_macros_modecargo clippy --all-targets: clean.infra/tests/lint.sh: ALL CHECKS PASSED.flysim --print-compatibility: the same 648 bytes, every component identical. The scene detector is not in the compatibility string and no checkpoint carries a scene.- No TypeScript touched, so npm was not run.
2026-09-17, row 28: the gate at (18, 9), and the rung under the rank
Row 28 was left named: "the objective walks the fly into a scripted gate once per hold". Settled
the same day from the same checkpoint, on the WSL development box. docs/design/macros.md section
12.4 is the contract.
Which script, and what unlocks it
examples/scene_probe.rs gained a FLY_PROBE_CATCH=script mode that drives macros mode until
wSimulatedJoypadStatesIndex is non-zero on the fly's map and then dumps everything. It caught it
at frame 27,053:
The cartridge took the joypad at frame 27053 (7.55 brain minutes).
The frame before, the player was on map 0x01 at (18, 10).
- scene: `Dialog` text box: open=true waiting=true
- font=0x01 textbox=0x01 joy=0 sim=1 flags5=0x80 corners=(0x79,0x7b,0x7d,0x7e)
- player: Player { map: 1, x: 18, y: 10, facing: Down }
- sprites the macros can see:
slot 4 picture 0x0d at (17, 9) facing Down
slot 5 picture 0x48 at (18, 9) facing Down (an object, not a person)
- The fly stepped north out of (18, 10), into (18, 9), where a still sprite sits — picture
0x48, pastFIRST_STILL_SPRITE, soNpc::personcalls it an object andGO NPCnever offered it. A person stands beside it at (17, 9). - The box's own tiles decode through pokered's charmap (
$80-$99= A-Z,$a0-$b9= a-z,$7f= space,$e7=!) to "This is private property!". sim=1andflags5=0x80: the cartridge is walking the player with joypad states of its own. It ends with the fly back on (18, 10) facing Down. 240 frames later, with nothing pressed, the box is still up — it waits for a press, andNEXTorNOdoes advance it.
What unlocks it is in the save, not in the road. The probe's event dump:
- rank 8 (VIRIDIAN CITY), badges 0
- 4 named events set
- EVENT_FOLLOWED_OAK_INTO_LAB
- EVENT_OAK_ASKED_TO_CHOOSE_MON
- EVENT_GOT_STARTER
- EVENT_GOT_OAKS_PARCEL
EVENT_GOT_OAKS_PARCEL is set and EVENT_OAK_GOT_PARCEL is not: the parcel is carried and
undelivered. RUNGS makes rung 6 Flag("EVENT_OAK_GOT_PARCEL") and rung 7
Flag("EVENT_GOT_POKEDEX"), so rungs 6 and 7 were never earned — and rung 7 in particular was
not, which is the question asked of this save. The rank reads 8 anyway, because rank_from takes
the maximum over satisfied rungs and rung 8 is Visited(VIRIDIAN_CITY), a map the fly walked to.
GameAdapter::objective was rank + 1, so it answered rung 9 — VIRIDIAN_FOREST, whose first hop
is ROUTE_2, north, through the gate. The fly was being sent at a road the cartridge keeps shut
until Oak has his parcel, once per hold, for eight hours.
What changed
All of it read-only on the adapter's side and inside the macros on the other; no payouts, no version strings, no checkpoint format.
| # | trap | trigger | test | fix, or why it is left |
|---|---|---|---|---|
| 28 | the objective walks the fly into a scripted gate once per hold | a rung earned out of order carries the rank past the rungs skipped under it, and rank + 1 names the wrong one |
a_rung_earned_out_of_order_does_not_skip_the_ones_under_it, the_objective_is_the_errand_and_macros_mode_walks_to_it (ROM) |
fixed: the objective is the lowest rung not satisfied. The catalog already knew rung 6's place; the arithmetic was picking the wrong rung out of it |
| 28a | a conversation the cartridge ends by pushing the fly back is marked talked | any blocking script: the gate, a guard, an NPC that walks you off | a_conversation_the_game_ends_by_moving_the_fly_is_not_talked_to, a_conversation_that_walks_the_fly_off_its_tile_is_not_talked_to |
fixed: the talked entry is pending until the box closes with the fly on the tile it pressed from and nothing driving it |
| 28b | a fly that answers NO retires what it said no to |
every yes/no box, the catching tutorial included | a_fly_that_answers_no_has_not_talked_to_anything |
fixed: a finished NO drops the pending entry; YES and NO both stay on the pad |
| 28c | a macro the cartridge answers by moving the fly records nothing | the gate, and every scripted push-back | a_walk_the_cartridge_pushes_back_excludes_what_it_was_walking_to |
fixed: a scene change with MacroState::scripted excludes the target for the window; an ordinary scene change still records nothing |
| 28d | a person walked to and not talked to is retired for the session | GO NPC completes on arrival, and the fly is free never to press A |
a_reached_person_is_skipped_for_the_window |
fixed: reached is a window, like blocked. Oak is the case that found it — the fly stood in front of him, chose something else, and could never be offered him again |
| 29 | GO OBJECTIVE walks onto the objective map's doormat and GO OUT walks straight back out |
the objective is an interior whose door leads to a town this run has stood on: tier 3 is the only tier that offers it, and it does | — | left, named and measured: it is trap 13 with an engine behind it. Dropping tier 3 on the objective's own map breaks the cycle and replaces it with a stall in the room — measured: the fly stayed in Oak's lab for 600,000 frames and delivered nothing. Both readings are dead ends; the next one has to keep the room leavable and give the fly a reason to stay |
The trap hunt, before and after
Same invocation, 20 brain minutes, seed 20260917, 4 sweep threads, from
release-viridian-timeouts.checkpoint:
| measure | before (main, v0.3.4) | after (fix/viridian-gate) |
|---|---|---|
| distinct (map, tile) | 142 | 137 |
| macros started | 1,144 | 1,335 |
| done / blocked / timeout / refused | 1,129 / 0 / 14 / 1 | 1,332 / 1 / 2 / 0 |
| windows flagged | 50 of 73 | 35 of 73 |
| frames in a text box | 41,012 | 0 |
longest unbroken dialog run |
248 | — |
| frames in the overworld | 26,197 | 51,542 |
| frames in a battle | 0 | 1,720 (a wild battle at 1.03 min) |
The gate loop is gone. The fly spends no frames at all in a text box over twenty brain minutes, where it spent 41,012 of 71,673 before; it leaves Viridian City southward, walks Route 1 and gets into a wild battle — the first battle any run from this checkpoint has had. The ROM test measures the same thing deterministically: Oak's lab, where the errand is, in 1.4 brain minutes on 55 macros, by way of Route 1 and Pallet Town, and never through the shut road.
Two residuals, both named above and neither papered over:
- Row 29, the lab-door bounce. From 12.5 brain minutes the run holds
GO OBJECTIVE, GO FRONTIER, GO OUT, NEXTat x11 to x28 over five tiles. It is trap 13, which was left as bounded, with an engine behind it now that the objective names the room. The fix attempted and rolled back is recorded in the row: it stalls the fly in the room instead. - The delivery itself is not something a game-blind rotation does. Over 600,000 frames — eleven
brain hours — in and around the lab the stub readout never pressed A at Oak. That is the same
finding
macros_mode_leaves_the_house_and_reaches_oaks_labrecords for the starter: "a game-blind stub does not pressTALKin front of a Pokéball on purpose". So Route 2 is not asserted by any test on this branch, and the claim that macros mode passes the gate belongs to a run with a brain in it and a reward for the rung. What is asserted is that the fly is now sent at the errand rather than at the wall.
unknown also rose, from 4,464 frames to 18,411, with a longest run of 135 frames: the fly is
crossing warps and taking scripted frames because it is moving between maps rather than standing in
one text box. It is named here as a thing to watch rather than a finding.
Gates
cargo test --workspacewithFLY_ROMandFLY_TRAP_CHECKPOINTset: green exceptflysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed, which fails identically on main on this box (a debug build publishes at 9.46 Hz against the test's 30 Hz contract). Pre-existing and unrelated; that test runsmacros.mode = raw. Everything else is green: 285 inflybrain-gb's lib, all 8 of its ROM tests, and all 4 inflysim'srom_macros_modecargo clippy --all-targets: clean.infra/tests/lint.sh: ALL CHECKS PASSED.flysim --print-compatibility: the same 648 bytes, every component identical. The rank ladder, the reward catalog and the checkpoint format are untouched;objectiveis a read accessor.- No TypeScript touched, so npm was not run.
2026-09-17, row 29: the lab door, and a rung that is a conversation
Row 29 was named and measured in the section above and left for its own run. It went live the same
morning — GO OBJECTIVE done, GO FRONTIER done, GO OUT done, NEXT done every three brain seconds at
Oak's lab door, from about 09:00 UTC — so this is that run. docs/design/macros.md section 12.5 is
the contract.
The mechanism, in one line
Section 12.4 aimed the objective at the right rung; the rung's place was still only a map, and
GO OBJECTIVE's arrival on a map is that map's own door. So the fly walked to the lab's doormat,
the map changed, the walk was Done, and GO OUT's last-resort tier offered the same door back.
Nothing in the room was the objective, so nothing held the fly in it.
What changed
| # | trap | trigger | test | fix |
|---|---|---|---|---|
| 29 | GO OBJECTIVE arrives on the objective map's doormat and GO OUT walks straight back out |
a rung earned by a conversation whose place is only a map: the parcel, the Pokédex, the starter, every badge | an_objective_that_names_a_person_is_walked_to_and_faced, the_ways_out_leave_the_pad_while_the_objectives_own_thing_is_here, macros_mode_delivers_the_parcel_from_the_stalled_checkpoint (ROM) |
fixed: MapPlace::target names a PlaceKind (Person/Object), GO OBJECTIVE arrives beside it facing it — GO NPC's own arrival, tile-ahead excluded so TALK takes over — and the ways out have no candidates while that target is on this map, untalked and unexcluded |
| 29a | the room becomes a trap when the objective's thing cannot be reached | a target walled off, or none | the same tests | fixed by the ledger: GO OBJECTIVE's no route refusal writes the blocked keys, the objective's list empties, and the ways out are candidates again. This is what the earlier rollback lacked |
| 29b | the header names a rung nothing is working toward | any rung earned out of order | the_next_rung_label_comes_from_the_adapter_ladder_and_holds_at_the_top |
fixed: milestone.next is GameAdapter::next_rung, the lowest rung not earned (docs/feed-protocol.md, 2026-09-17) |
The proof, on the cartridge
macros_mode_delivers_the_parcel_from_the_stalled_checkpoint, from
release-viridian-timeouts.checkpoint with the game-blind rotating stub:
| stage | frames | brain minutes |
|---|---|---|
| Oak's lab (south, by Route 1 and Pallet Town) | 5,150 | 1.4 |
EVENT_OAK_GOT_PARCEL — the parcel delivered |
27,167 | 9.0 |
EVENT_GOT_POKEDEX — rung 7, the same script |
0 | 9.0 |
504 macros for the whole errand. This is the first rung that is a conversation any harness in this
repository has earned, and the sibling tests' own note — "a game-blind stub does not press TALK
in front of a Pokéball on purpose" — is why: the stub still does not aim, but the macro now walks it
to the one thing that matters and hands it TALK with nothing else on the pad to walk away with.
The trap hunt, before and after
Same invocation, 20 brain minutes, seed 20260917, 4 sweep threads, from the same checkpoint:
| measure | before (main, v0.3.5) | after (fix/objective-target) |
|---|---|---|
| distinct (map, tile) | 137 | 136 |
| macros started | 1,335 | 977 |
| done / blocked / timeout / refused | 1,332 / 1 / 2 / 0 | 973 / 1 / 2 / 0 |
| windows flagged | 35 of 73 | 6 of 73 |
GO OUT starts |
321 | 2 |
NEXT starts |
533 | 41 |
| frames in the overworld | 51,542 | 69,458 of 71,673 |
frames reading unknown |
18,411 | 355 |
| frames in a text box | 0 | 140 |
| longest unbroken overworld run | 6,245 | 28,851 (8 brain minutes) |
The bounce is gone: GO OUT ran twice in twenty brain minutes where it ran 321 times, and the six
windows still flagged are all in the first 1.25 minutes and all for NEXT repeating at period one
over 40 to 109 tiles — the known false-positive shape of a legitimately busy explorer, which the
section above already records. The unknown frames that came with crossing warps every three
seconds went with them, from 18,411 to 355.
GO OBJECTIVE now walks: 492 starts, all done, 71 frames and 1.7 tiles of net displacement each,
against 267 starts of 26 frames and 0.0 net before. That is the difference between arriving at a
door and walking to somebody.
Gates
cargo test --workspacewithFLY_ROMandFLY_TRAP_CHECKPOINTset: green exceptflysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed, which fails identically on main on this box (a debug build publishes at 9.46 Hz against the test's 30 Hz contract). Pre-existing and unrelated; that test runsmacros.mode = raw. 23 suites green, including 287 inflybrain-gb's lib, all 8 of its ROM tests and all 5 inflysim'srom_macros_modecargo clippy --all-targets: clean.infra/tests/lint.sh: ALL CHECKS PASSED.flysim --print-compatibility: the same 648 bytes, every component identical.MapPlaceandmilestone.nextare neither in the compatibility string nor in a checkpoint.- No TypeScript touched, so npm was not run.
2026-09-17, row 30: the move list is the fly's turn
Live at 14:56 UTC, rank 9, Viridian Forest (map 51), an hour and forty-one minutes on the rung: a
wild Kakuna L6, Bulbasaur at 8/28, the FIGHT move list open with TACKLE at 0 of 40 PP, and the event
log NEXT start / NEXT done every 268 brain milliseconds for over an hour.
docs/design/macros.md section 12.6 is the contract.
The mechanism
Battle::own_turn was matches!(menu, BattleMenu::Main { .. }) — the top-level menu alone. So a
frame with the move list open was neither the fly's turn nor a forced switch, which is the
between-turns branch, whose pad is the single NEXT added by the v0.2.4 deadlock fix. NEXT is one
A press. A is "confirm the cursor's entry". The cursor sat on TACKLE, TACKLE had no PP, the game
printed its refusal, the box closed, the list was still there, and the next hold pressed A again.
Everything else about it was already right: best_move has skipped 0-PP moves since it was written,
and ATTACK's script already goes straight to the move when the list is open. The button was simply
not on the pad.
| # | trap | trigger | test | fix |
|---|---|---|---|---|
| 30 | the move list open reads as "between turns", and NEXT presses A on a move with no PP |
any turn where the list is open and the cursor's move is out of PP | the_move_list_open_is_the_flys_turn, each_battle_menu_deals_its_own_pad, macros_mode_chooses_a_move_from_an_open_list_on_the_cartridge (ROM) |
fixed: any battle menu accepting input is own_turn; the pad is the sub-state's (ATTACK/BACK over the move list, SWITCH/BACK over the party list) and NEXT is on none of them |
| 30a | a turn with every move out of PP has nothing on the pad | the last Pokémon, out of PP, where the game wants Struggle | attack_confirms_anyway_when_every_move_is_out_of_pp |
fixed: with the list open ATTACK confirms the cursor's own slot, which is how Struggle happens |
| 30b | a battle's opening frames read as an open move list | MoveSelectionMenu's coordinates before the engine fills wBattleMon* |
macros_mode_chooses_a_move_from_an_open_list_on_the_cartridge waits for a settled list |
fixed: a list whose cursor the seam cannot place is not accepting input — measured on the cartridge as Moves { cursor: None, count: 2 } with own: None |
| 30c | the battle bag has no pad of its own | choosing ITEM in a battle | — | left, no observable: BattleMenu has no item variant and the bag reports no cursor through the seam, so an open battle bag reads as between turns and NEXT advances it. ITEM needs a potion and a hurt Pokémon to be offered at all, so the bag is rarely opened; a pad for it needs a verified reading first |
The proof, on the cartridge
macros_mode_chooses_a_move_from_an_open_list_on_the_cartridge, from
release-viridian-timeouts.checkpoint: the fly walks into a wild battle of its own accord at 0.4
brain minutes (Route 1's grass is on the way to the errand), raw A presses advance the opening text
until the move list is settled, and then
- the frame reads
Scene::Battle { own_turn: true }, where it used to readown_turn: false; - the pad is
ATTACKandBACK, and notNEXT; move_list_choicenames slot 0 with 28 PP,ATTACKconfirms it, and the list closes — the turn proceeds, with the eightNEXTs afterwards being the turn's own text, which is the between-turns pad doing its job.
The 0-PP case itself is pinned synthetically, because a fake can set a PP counter to zero and a
cartridge cannot be talked into it inside a test: attack_over_an_open_move_list_chooses_a_move_that_has_pp
puts TACKLE at 0 PP beside two moves that have some and asserts the cursor lands on a move with PP,
and attack_confirms_anyway_when_every_move_is_out_of_pp empties all three.
Gates
cargo test --workspacewithFLY_ROMandFLY_TRAP_CHECKPOINTset: green exceptflysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed, which fails identically on main on this box (a debug build publishes at 9.46 Hz against the test's 30 Hz contract). Pre-existing and unrelated; that test runsmacros.mode = raw. 24 suites green, including 290 inflybrain-gb's lib, all 8 of its ROM tests and all 7 inflysim'srom_macros_modecargo clippy --all-targets: clean.infra/tests/lint.sh: ALL CHECKS PASSED.flysim --print-compatibility: the same 648 bytes, every component identical. Scene detection is in neither the compatibility string nor a checkpoint.- No TypeScript touched, so npm was not run.
2026-09-17, rows 32 and 33: a doormat the ledger cannot record, and a road that does not exist
Live at 17:32 UTC, four hours and eighteen minutes on rung 9 (VIRIDIAN FOREST, next PEWTER CITY),
with the fly on map 50 — VIRIDIAN_FOREST_SOUTH_GATE, the ten-by-eight gate house between
Route 2 and the forest. The pad was GO FRONTIER and nothing else; GO OBJECTIVE, GO ROUTE
and GO OUT were absent; the last 40 KB of the event log was 175 GO FRONTIER start/done pairs,
each done about 420 brain milliseconds after its start — one tile — and a new one every 800 ms.
uniqueLocations read 1181. docs/design/macros.md section 12.7 is the contract.
Reproduced on the WSL development box from the release box's own checkpoint
(.local/checkpoints/release-rank9-20260917T1732.checkpoint, never committed). The reproduction is
faithful to the minute: from 5.25 to 12.00 brain minutes every window of the hunt holds two
tiles and about 149 macros, and the repeated sequence is GO FRONTIER x150.
What the bytes say
examples/scene_probe.rs gained a pad dump — every candidate list the overworld pad rests on,
for the map a checkpoint is standing on — and it prints the whole trap in one screen. The ground,
. walkable, # wall, ? off the screen buffer, v recorded in the exploration ledger, @ the
fly:
y 3 ?- .v .v .v .v .v #- .v #- #-
y 4 ?- .v .v .v .v .v .v .v .- #-
y 5 ?- .v .v .v .v .v #- .v #- #-
y 6 ?- .v .v .v .v .v #- .v #- #-
y 7 ?- .v .v .v .v .- .-@.v .v .v #-
Three tiles of that map are walkable and have never been recorded, and the fly is standing on
one of them: (4, 7) and (5, 7) are the gate's two southern doormats, and (8, 4) is the tile the
gate's guard is standing on. So path::frontier answered four aims every hold, for ever:
- `path::frontier`: [((3,7), Right), ((4,6), Down), ((5,7), Left), ((7,4), Right)]
- the pad: ["GO OBJECTIVE", "GO OUT", "GO NPC", "GO FRONTIER"] (session ledgers empty at t=0)
- `next_hop(0x32, 0x02)` = Some(13), neighbours [13, 51]
- Warp(0) at (4, 0) way Passage into Some(51) -> map_visited true
- Warp(1) at (5, 0) way Passage into Some(51) -> map_visited true
- Warp(2) at (4, 7) press Down way Exit into None -> destination Some(13), map_visited true
- Warp(3) at (5, 7) press Down way Exit into None -> destination Some(13), map_visited true
- `ways(Exit)` = [Warp(2), Warp(3)] `ways(Passage)` = [] `ways(Route)` = []
Two mechanisms, and the second is the one that shut the pad.
- A doormat is walkable, standable ground the reward ledger can never record.
MacroState::tile_visitedwasGameAdapter::tile_visitedalone, which is theexplorationpayout's ledger:sampleinserts a coordinate only on a frame its gate accepts, and that gate rejectswMovementFlags & 0xc7— bits 0, 1 and 2 areBIT_STANDING_ON_DOOR,BIT_EXITING_DOORandBIT_STANDING_ON_WARP. The checkpoint's own frame readsmove=0x04, standing on a warp. So every warp tile in the game was a permanent frontier, in every town and every house; the gate house is where it became the whole pad, because its south wall is two doormats and the walk to one is a single tile.GO FRONTIER's 627 starts in the before-run average 26 frames and one tile of net displacement each, which is the live log's 420 ms exactly. ROUTE_2is one map id whose ground is in two halves.next_hop(0x32, PEWTER_CITY)answeredROUTE_2— the south door, the one the fly had walked in by — because Route 2's north edge touches Pewter and the graph had one node for the whole map. Physically the halves are separated by a belt of trees that needs CUT and the only way between them is the forest. Everything else follows from that one wrong hop:objective_goalsaimedGO OBJECTIVEback out of the south door, so the fly ping-ponged gate → Route 2 → gate (the before-run's trace: map 50 at 0 min, map 13 at 1–5 min, map 50 from 6 min on) until the blocked ledger excluded both doormat warps, after which the macro had no candidate and left the pad;ways(Way::Exit)'s three tiers all emptied for the same reason: the destination (Route 2) is visited, so nothing is fresh; tier 2 asks the same wrong hop and the excluded doors are gone from the list; soGO OUTleft the pad too;ways(Way::Passage)— the north doors into the forest, which are the way to Pewter — has no tier 3 on a map that has aWay::Exit, tier 1 is empty because the forest is visited, and tier 2 wanted a destination of 13. SoGO WARPwas never on the pad at all.GO ROUTEis indoors-absent by construction (plan::overworlddealsGO OUTindoors), which is why the operator saw three buttons missing and not four.
What changed
All of it inside the macros and the harness. The decoder, the reward catalog, the adapter version
and the compatibility string are untouched (--print-compatibility byte-identical, below).
| # | trap | trigger | test | fix, or why it is left |
|---|---|---|---|---|
| 32 | a warp tile is a frontier no ledger can retire | any doormat; the whole pad where the doormats are the only unrecorded ground | the_tile_the_fly_stands_on_is_visited_although_the_run_ledger_records_nothing, macros_mode_leaves_the_forests_south_gate_and_reaches_its_north_one (ROM) |
fixed: the reward gate stays as it is and the macro layer keeps its own StoodLedger — session state beside talked/blocked/reached, written once per frame from the tile the fly stands on, never checkpointed, OR'd with the adapter's. A scripted frame records nothing, because the coordinates and the loaded map header are then from different frames |
| 32a | a tile a sprite stands on is a frontier for as long as the sprite stands there | a villager in the open, an item ball on a floor tile: the gate's guard at (8, 4) | a_tile_a_sprite_is_standing_on_is_not_new_ground |
fixed: path::frontier drops it as the ground to stand on and as the new ground beyond. Row 5 bounded this at one walk per window; dropping it is what lets the frontier exhaust, which is what "the button leaves the pad" needs |
| 33 | the map graph collapses a map whose ground is in two pieces, and answers a road that does not exist | ROUTE_2, whose halves are joined only through Viridian Forest; it takes GO OBJECTIVE, GO OUT and GO WARP off the pad together |
route_2s_halves_are_told_apart_by_the_row_the_fly_is_standing_on, the_way_to_pewter_from_the_forests_south_gate_is_north_through_the_forest, a_split_maps_pieces_divide_its_neighbours_between_them |
fixed: geography::Region is a map and a piece of it; next_hop is asked from the piece the fly is standing in; one SPLIT row carries the two pieces' neighbour lists and the two doorway rows they are told apart by (11 and 43, surveyed from the cartridge) |
| 33a | tier 2 of ways gives up when the graph knows no route, even when a door names the objective's map outright |
a map with no row in the table whose every destination is visited: no tier has a candidate and the button leaves the pad | a_door_into_the_objectives_own_map_is_toward_it_with_no_route_in_the_graph |
fixed: the answer objective_goals already had, now in toward_objective as well |
| 33b | maps 46, 48 and 49 are still not on the graph | a route through Diglett's Cave, the Route 2 trade house or the Route 2 gate | — | left, the table's standing rule: a building no rung place needs a route through is not on the graph, and 49's two doors are both warps of ROUTE_2 itself, so it is a shortcut within one map rather than a way between two. A route the table does not carry is not offered; nothing is guessed |
| 34 | a battle where every move is out of PP has no way to reach Struggle | best_move is None, so ATTACK leaves the main battle menu, so FIGHT never opens and the move list row 30a confirms Struggle from is never reached |
attack_is_on_the_pad_with_no_pp_anywhere_and_unbound_with_no_moves_at_all, a_turn_with_nothing_to_attack_switch_or_flee_with_still_has_a_button, macros_mode_ends_a_turn_with_no_move_left_on_the_cartridge (ROM) |
fixed in the section below: the precondition is "is there a move list to open", and the script confirms FIGHT and stops, because CheckPlayerHasUsableMoves answers it without opening the list — measured on the cartridge |
Row 34, measured: the turn with nothing left to attack with
examples/scene_probe.rs gained FLY_PROBE_CATCH=noattack, which drives macros mode until the
fly's own turn has had ATTACK off the pad for a run of frames and then dumps the battle. From
this checkpoint it caught it at frame 355,143 — 99.1 brain minutes — on map 51 at tile (4, 30),
which is the same tile the 1.2-million-frame ROM run ends on:
- scene: `Battle { own_turn: true, forced_switch: false }`
- the pad: ["macro_back"]
- battle: Wild, own_turn, menu Party { cursor: 0 }
- enemy: species 84 level 5, 18/18
- the Pokemon that is out: slot 0 species 0x99 level 12, 11/34, Healthy
- move 0: Move { id: 33, pp: 0 } (TACKLE)
- move 1: Move { id: 45, pp: 0 } (GROWL)
- move 2: Move { id: 73, pp: 0 } (LEECH SEED)
- move 3: None
- party of 1
- bag: Poke Ball x1, Potion x1
A pad of one button, in a battle. Every move is at 0 PP, so best_move answers None and
ATTACK's precondition over the top-level menu fails; the party is one Pokémon, so SWITCH fails;
RUN is on the pad and the cartridge refuses it (60 RUN starts, all blocked); NEXT is one A
press on whatever the cursor holds, which opens the party list, whose pad is SWITCH and
BACK and whose SWITCH is unbound — so the only button left is BACK, which closes it again.
ITEM, BACK, NEXT, once per hold, which is exactly the three names the ROM run's counts
showed still moving.
Row 30a already says "with the list open ATTACK confirms the cursor's own slot, because Struggle
is the cartridge's answer there" — and the list is only ever reached through FIGHT, which is the
button this state takes off the pad. That is row 34, and it is left for its own run rather than
patched here: the fix is one precondition and the measurement it needs is a checkpoint in this
state, which this one is not.
The survey: Route 2's own warp table
FLY_PROBE_MAP=13 cargo run --release -p flysim --example scene_probe, sixty frames after the
fly stood on it, because wCurMap changes several frames before the header it names is loaded:
map size: MapSize { width: 20, height: 72 }
connections: north: true, south: true, east: false, west: false
warps: (12, 9)->46 (3, 11)->47 (15, 19)->48 (16, 35)->49 (15, 39)->49 (3, 43)->50
The two forest gates are rows 11 and 43, and those are the doorways SPLIT tells the two
halves apart by: a tile belongs to the piece whose doorway row it is nearer. The number comes from
the warp table rather than from a claim about where the trees are, and the only place the answer
could be wrong is a tile in the impassable belt between them — ground the fly cannot stand on.
The trap hunt, before and after
20 brain minutes, seed 20260917, 4 sweep threads, same connectome and same cartridge in both runs:
FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
FLY_TRAP_CHECKPOINT=.local/checkpoints/release-rank9-20260917T1732.checkpoint \
FLY_TRAP_MINUTES=20 cargo run --release -p flysim --example trap_hunt
| measure | before (main, v0.3.9 a844d56) |
after (fix/loop-20260917T1732) |
|---|---|---|
| distinct (map, tile) over 20 brain minutes | 78 | 193 |
| macros started | 864 | 349 |
| done / blocked / timeout / refused | 860 / 3 / 0 / 0 | 322 / 17 / 10 / 1 |
| windows flagged | 54 of 73 | 49 of 73 |
| windows under 4 distinct tiles | 16 | 0 |
| worst window | 2 tiles, 150 macros | 17 tiles, 76 macros |
| longest repeat | GO FRONTIER x150 |
NEXT x13 |
GO FRONTIER starts / mean frames / mean net tiles |
627 / 26 / 1.0 | 11 / 64 / 0.8 (max net 8) |
GO WARP starts / mean frames / max net tiles |
6 / 9 / 0 | 28 / 624 / 29 |
| frames in a battle | 2,489 | 13,913 |
| frames in a text box | 3,411 | 0 |
The loop is gone, and so is its shape. No window in the after-run holds fewer than four
distinct tiles, where sixteen of them held two; the longest repeated sequence is NEXT at period
one, which is battle text and the known false-positive shape this file has recorded since the first
section; and GO FRONTIER runs eleven times in twenty brain minutes instead of 627, covering up to
eight tiles a time instead of one. GO WARP is what replaces it: 28 starts averaging 624 frames
and reaching up to 29 tiles of net displacement, which is the fly walking north across Viridian
Forest. The battle frames are the honest cost of that — the forest is wild grass and trainers, and
2,489 frames of battle became 13,913.
The fly leaves the gate house northward, into Viridian Forest, in 155 frames on one macro (the ROM test below), where four hours of stream never left it at all.
The proof, on the cartridge
macros_mode_leaves_the_forests_south_gate_northward, from this checkpoint with the game-blind
rotating stub:
| what | measured |
|---|---|
| frames to leave the gate house | 155 (2.6 brain seconds) |
| macros to leave it | 1 |
| the first map out of it | VIRIDIAN_FOREST (51) — northward, where it used to be Route 2 |
| smallest overworld pad the gate house ever dealt, over 33.5 brain minutes | 3 buttons |
| macros over the whole run | 1,037: 958 done, 60 blocked, 18 timeout, 2 refused |
The direction is the claim. Before this branch the first map out of the gate was Route 2, southward, and the fly came straight back; the pad was one button for four hours. The pad dump above and the one after the fix differ in exactly three lines:
- `next_hop(0x32, 0x02)` = Some(13) `ways(Passage)` = []
+ `next_hop(Region { map: 50, part: 0 }, 0x02)` = Some(51) `ways(Passage)` = [Warp(0), Warp(1)]
- objective_goals = [Aim { tile: (4, 7), .. }, Aim { tile: (5, 7), .. }] # the south doormats
+ objective_goals = [Aim { tile: (4, 0), .. }, Aim { tile: (5, 0), .. }] # the north doors
- the pad: ["GO OBJECTIVE", "GO OUT", "GO NPC", "GO FRONTIER"]
+ the pad: ["GO OBJECTIVE", "GO OUT", "GO NPC", "GO WARP", "GO FRONTIER"]
Route 2's north half is not asserted, and that is a measurement rather than a trim. 1,200,000
frames (5.6 brain hours) from this checkpoint cover six maps and 20,595 macros — into the forest,
back out, down to Route 2, a blackout to Pallet Town and the walk back north — and never reach map
47. From about 600,000 frames on the only macros that start are ITEM, BACK and NEXT: ATTACK
does not start once more and every overworld count freezes. That is row 34 in the table above, and
it is its own run.
Gates
cargo test --workspacewithFLY_ROM,FLY_TRAP_CHECKPOINTandFLY_GATE_CHECKPOINTset: green exceptflysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed, which fails identically on main on this box (a debug build publishes well under the test's 30 Hz contract). Pre-existing and unrelated; that test runsmacros.mode = raw.cargo clippy --all-targets: clean.infra/tests/lint.sh: ALL CHECKS PASSED.flysim --print-compatibility: byte-identical to main, 648 bytes, diffed rather than eyeballed (flysimbuilt froma844d56and from this branch, same ROM and same dataset). The exploration ledger's payout gate, the reward catalog and the checkpoint format are untouched; the stood ledger is session state in the executor layer and reaches no checkpoint.- No TypeScript touched, so npm was not run.
2026-09-17, row 34: the turn with nothing left to attack with
Row 34 was named and measured in the section above and left for its own run. This is that run.
docs/design/macros.md section 12.8 is the contract.
The mechanism, in one line
Section 12.6 made ATTACK confirm the cursor's own slot over an open move list "because Struggle
is the cartridge's answer there" — and the move list is only ever reached through FIGHT, whose
button's precondition was best_move(..).is_some(). So the one turn with nothing left to attack
with took ATTACK off the top-level pad and the way to Struggle with it.
What the cartridge does with FIGHT and no PP, measured
examples/scene_probe.rs gained FLY_PROBE_CATCH=nopp — drive macros mode until the fly's own
turn has a Pokémon out whose every move is at 0 PP — and then, with raw presses, backs out to the
top-level menu, puts its cursor on FIGHT, confirms it and watches what happens. Caught at frame
181,616 (50.7 brain minutes) from release-rank9-20260917T1732.checkpoint:
- battle: Trainer, own_turn, menu Moves { cursor: Some(1), count: 3 }
- the Pokemon that is out: slot 0 species 0x99 level 12, 6/34, Healthy
- move 0: Move { id: 33, pp: 0 } move 1: Move { id: 45, pp: 0 } move 2: Move { id: 73, pp: 0 }
- party of 1
- after backing out with B: Main { cursor: 0 }
- menus after confirming FIGHT, in order: ["Some(None)"]
- pulses with the move list open: 0
The move list never opens. CheckPlayerHasUsableMoves answers FIGHT itself — "has no moves
left!", Struggle, and own_turn goes false on the next frame — so the macro's whole job from the
top-level menu is to confirm FIGHT. A second cursor step would press A at text.
Two things the same survey settled, both recorded rather than assumed:
- the list can be open with nothing having PP (it was, on the frame this caught), so section
12.6's rule stays:
ATTACKconfirms the cursor's slot there, earns "There's no PP left for this move!" and the list again, andBACKbeside it leads to the menu where FIGHT works. One wasted press, not a loop. - the first
noppcatch was a false positive and is kept here because it was run: the condition wasbest_move(..).is_none(), which is also true on a battle's opening frames before the engine copies the active Pokémon intowBattleMon*(row 30b). It caught frame 17,142 with full PP and wrote a checkpoint of it. The condition is now the state itself — a Pokémon that is out, with every move it has at 0 PP.
What changed
| # | trap | trigger | test | fix, or why it is left |
|---|---|---|---|---|
| 34 | ATTACK leaves the top-level pad on the one turn with no PP, so FIGHT never opens and Struggle is unreachable |
any Pokémon whose every move is out of PP; with a party of one and a RUN the cartridge refuses it is the whole turn |
attack_is_on_the_pad_with_no_pp_anywhere_and_unbound_with_no_moves_at_all, a_turn_with_nothing_to_attack_switch_or_flee_with_still_has_a_button, macros_mode_ends_a_turn_with_no_move_left_on_the_cartridge (ROM) |
fixed: can_fight replaces best_move(..).is_some() over the top-level menu — the question is whether there is a move list to open — and the script confirms FIGHT and stops |
| 34a | the party list with a one-Pokémon party deals BACK alone |
ITEM opens it to choose who to heal, and its one entry is the Pokémon already out |
a_turn_with_nothing_to_attack_switch_or_flee_with_still_has_a_button pins it as BACK |
left, by construction: there is nothing to choose, so backing out is the press. A second button there would be a macro that completes without moving, which section 12.2 calls a trap. What changed is where BACK leads — a menu with ATTACK on it |
Before and after, on the same 1.5 million frames
FLY_PROBE_CATCH=noattack is row 34's own symptom detector: the fly's own turn with ATTACK off
the pad for 600 consecutive frames. Same checkpoint, same stub, same budget:
| measure | before (main, v0.3.10 36e6b8c) |
after (fix/loop-row34) |
|---|---|---|
first own turn with no ATTACK on the pad |
frame 355,143 (99.1 brain minutes) | none in 1,500,000 frames |
| the pad it dealt there | ["macro_back"] |
— |
And the ROM test, from the checkpoint FLY_PROBE_SAVE wrote of the caught turn:
| what | measured |
|---|---|
ATTACK on the pad of the fly's own turn |
yes, on the first frame |
| the macro starts and runs on the cartridge | 1 start, done |
| smallest pad the fly's own turn ever dealt | 2 buttons |
| the battle ends | 1,535 frames (0.4 brain minutes) on 33 macros |
Which way the battle ends is not asserted: with 6 of 34 HP against a trainer the measured run faints and blacks out, and that is the game rather than the macro layer.
The trap hunt, as a regression
Row 34's state is fifty brain minutes past the gate-house checkpoint, so twenty brain minutes of hunting cannot reach it and the 1.5-million-frame sweep above is this row's own measurement. The hunt is run anyway, for what it says about everything else. Same invocation, 20 brain minutes, seed 20260917, 4 sweep threads:
| measure | v0.3.10 36e6b8c |
after (fix/loop-row34) |
|---|---|---|
| distinct (map, tile) over 20 brain minutes | 193 | 380 |
| macros started | 349 | 643 |
| done / blocked / timeout / refused | 322 / 17 / 10 / 1 | 554 / 75 / 13 / 2 |
| windows flagged | 49 of 73 | 59 of 73 |
| windows under 4 distinct tiles | 0 | 6 |
| frames in a battle | 13,913 | 36,501 |
| longest unbroken battle | 845 | 11,130 (3.1 brain minutes) |
ATTACK starts |
0 | 87 (13 done, 74 blocked) |
Twice the ground, and the reason the other numbers moved is that the fly now fights. ATTACK
was on no pad at all in the v0.3.10 run from this checkpoint; it runs 87 times here, the battle
frames go from 13,913 to 36,501, and the six windows under four tiles are all inside one
eleven-thousand-frame trainer battle beginning at 16.89 brain minutes — one tile, sixty-odd macros,
no repeated sequence at any period. A three-minute battle is a window the tile rule cannot tell
from a stall, which is the same known false-positive shape this file records for NEXT at period
one, in the other direction.
Two residuals, named rather than papered over:
- 74 of the 87
ATTACKstarts endBlocked, at a mean of 204 frames and no ground covered. That is row 19 of the audit — "a cursor macro waits 180 frames and reportsBlocked:ATTACKorRUNstarted in the last frames ofown_turn, becauselisting()isNonefor most of a battle" — which was left as "one wasted hold, no loop". It is the same trap at a much higher rate, becauseATTACKis now bound on every turn rather than only on turns with PP. It carries no target, so it poisons no ledger, and the run's own numbers say it costs coverage nothing; a cheaper wait is a tuning change with its own measurement. - the tile rule and a long battle. Six windows of one tile with no repeat is a fly standing still in a trainer battle. The hunt's thresholds are the operator's and are left as specified.
Gates
cargo test --workspacewithFLY_ROM,FLY_TRAP_CHECKPOINT,FLY_GATE_CHECKPOINTandFLY_NOPP_CHECKPOINTset: green exceptflysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed, which fails identically on main on this box. Pre-existing and unrelated; that test runsmacros.mode = raw.cargo clippy --all-targets: clean.infra/tests/lint.sh: ALL CHECKS PASSED.flysim --print-compatibility: byte-identical to main, 648 bytes, diffed rather than eyeballed. A precondition and a script are in neither the compatibility string nor a checkpoint.- No TypeScript touched, so npm was not run.
Sections 13 and 14: the trap hunt, before and after
Twenty brain minutes, seed 20260917, 4 sweep threads, same connectome and same cartridge, from the
Viridian timeouts checkpoint the release container wrote. Both arms are driven by the hunt's
FLY_TRAP_STUB=1 rotation over the same thirty-two channel names, and that is not a detail:
tools/build_flywire.py re-deals every macro_<type> population whenever a type is added — 206
neurons thirty-one ways is not twenty-two ways plus nine — so a run driven by the brain cannot
separate "the macros got worse" from "the populations moved". The stub ticks the brain and replaces
only the readout, so the two arms differ in the macro code and in nothing else.
FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
FLY_TRAP_CHECKPOINT=.local/checkpoints/<the Viridian timeouts checkpoint> \
FLY_TRAP_MINUTES=20 FLY_TRAP_STUB=1 cargo run --release -p flysim --example trap_hunt
| measure | before (main) | after (feat/macros-shops) |
|---|---|---|
| distinct (map, tile) over 20 brain minutes | 166 | 229 |
| macros started | 82 | 95 |
| done | 79 | 80 |
| blocked / timeout / refused | 2 / 1 / 0 | 0 / 15 / 0 |
| windows flagged | 4 of 73 | 0 of 73 |
frames in overworld |
59,425 | 61,582 |
longest unbroken overworld run |
36,957 | 18,438 |
frames in dialog |
2,203 | 8,114 |
Both of the hunt's criteria improve: more ground (166 → 229 tiles) and fewer flagged windows
(4 → 0). docs/loop-review.md asks for exactly those two.
Three things about it that are not improvements and are recorded rather than buried.
- Timeouts went 1 → 15. Fifteen of ninety-five macros spent their budget. None of them is a loop — no window flagged and the tile count went up — so they are long walks: the errands cross a city, and a walk that ends nearer its goal than it began costs a strike and resumes on the next hold rather than excluding anything (row 1). It is the shape row 1 designed for, and it is worth watching rather than celebrating.
- Dialog frames went 2,203 → 8,114, and 4,731 of the after-run's are one box at (20, 29) of
Viridian City — beside
bg_event 21, 29, a Trainer Tips sign the fly read and advanced slowly. The before arm spent 9,475 frames in a battle instead. The two runs simply go different places. - The trap in row 37 is one this branch exposed rather than introduced. (19, 9) is the
cartridge's behaviour and
approachhas always offered the four tiles around a person; what put the fly in that corner of Viridian is the mart and centre errands. Neither arm of the earlier brain-driven pair stood on it. Latent, then exposed, then closed.
What the earlier brain-driven pair said, and why it is not the gate
Recorded because it was run and because it is what found row 37. Same checkpoint, no stub:
| measure | before (main, brain) | after (branch, brain, before row 37) |
|---|---|---|
| distinct (map, tile) | 196 | 109 |
| macros started | 698 | 1,403 |
| windows flagged | 31 of 73 | 63 of 73 |
frames in dialog |
24,411 | 49,286 |
Half the ground and twice the flagged windows — and the location tables the hunt gained for this
work said why in one line: 53,266 of 54,377 text-box frames on tile (19, 9) of Viridian City,
991 of them answered YES. That is row 37. The population re-deal is why this pair cannot be the
gate; the stub pair above is.
The three readings of (19, 9), in the order they were measured:
| text-box frames on (19, 9) | |
|---|---|
| before row 37 | 53,266 |
| the tile excluded as a goal | 12,919 |
| and as a wall to the route | 1,538 |
The middle row is why the fix has two halves: the A* went on routing across the tile toward somewhere else, and the script fires on any frame the fly stands there.
padEmptyMs is not measured by this run: examples/trap_hunt.rs does not read the feed header,
so the only thing said about it here is that it exists and is exported. Whether a playable scene
ever deals nothing on the release box is the watchdog's gauge to answer, over a run longer than
twenty brain minutes.