flybrain/docs/design/macros.md
claude 4b93f9dc7f docs: the trap hunt for section 15, before and after
286 distinct tiles become 489 over twenty brain minutes from the rung-9
checkpoint, the median flagged window holds 55 tiles instead of 16, GO FRONTIER
runs 64 walks worth up to twelve net tiles instead of four worth one, and one
walk spends its cap where six did.

The cost is named rather than buried: flagged windows go 61 to 70 and windows
under four tiles 5 to 14, and every one of those is a window spent inside a
battle -- a fly that covers more ground walks into more grass, 48,756 battle
frames becoming 50,413 with the longest battle 9,549 frames becoming 13,411.
This is the first measurement in that file where more ground and fewer flags do
not both hold, and it is stated as such.
2026-09-22 05:15:30 +00:00

1178 lines
91 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Macro palette: scene-appropriate actions for the fly
Queued by the operator 2026-09-16 ("give the system something more than randomly mashing buttons");
contract written by Fable the same day. `docs/stream-mvp-plan.md` has the assessment. This file is
the binding contract for the agents building it; where it differs from prose elsewhere, this wins.
## 1. Doctrine
- **The readout stays fixed.** The population decoder, its channels (UP DOWN LEFT RIGHT as one
exclusive group; A B as pulses; START SELECT on boot) and its numbers do not change. Palette
mode is a *scene-dependent meaning* of those same channels, applied after the decoder, in the
game layer: UP means "slot 0", DOWN "slot 1", LEFT "slot 2", RIGHT "slot 3", A "slot 4",
B "slot 5". A scene defines up to six macros and binds them to slots; an unbound slot is
"no action".
- **The fly chooses the action.** Nothing chooses for it: no default macro, no fallback action
on timeout, no scripted objective. If the fly's channels are silent, the game waits.
- **A macro is a button script**, executed by the sim on the emulator with the same button
register the fly's raw buttons use. There is still no button endpoint on the control API and
no button path from the bridge or the page.
- **Disclosed.** The honesty panel sentence becomes "the fly chooses the action"; the mode
(`RAW` or `PALETTE`), the scene, the palette and the chosen macro are on screen at all times.
- **Raw mode remains** and is the default until measured; the mode is a config/env knob and a
`[macros]` block in `flysim.toml`, never a chat command.
## 2. Scenes (Pokémon Red, pret/pokered at the pinned commit in `docs/design/ladder.md`)
```rust
pub enum Scene {
Title, // no palette: boot variant of the readout applies
Overworld, // player can walk
Dialog, // a text box is open and waiting
Menu, // start menu / submenu open, outside battle
Battle { own_turn: bool, forced_switch: bool },
Shop, // mart buy/sell menu
Pc, // PC / storage menu
Unknown, // detection failed: treated like Dialog (advance only)
}
```
Detection is from WRAM only, sampled once per game frame after the frame, by
`pokemon_red::scene::detect(&mut dyn MemoryReader) -> Scene`. Sources and offsets are listed in
`docs/design/macros-wram.md` with the pokered symbol names and the verification method for each.
## 3. Palettes
Small on purpose (3 to 6 slots). Slot binding is fixed per scene so the meaning of a channel is
stable inside a scene. Every macro has a name of at most 14 characters for the screen.
| scene | slot 0 (UP) | slot 1 (DOWN) | slot 2 (LEFT) | slot 3 (RIGHT) | slot 4 (A) | slot 5 (B) |
| --- | --- | --- | --- | --- | --- | --- |
| Overworld | `GO EXIT` nearest unvisited warp or map-edge exit | `GO NPC` nearest visible person and face it | `GO ITEM` nearest interactable object or sign, and face it | `WANDER` 8 random steps (raw flail, bounded) | `TALK` press A | `MENU` open start menu |
| Dialog | `NEXT` advance text | `NEXT` | `NEXT` | `NEXT` | `YES` A | `NO` B |
| Menu | `CLOSE` back out to overworld | `CLOSE` | `CLOSE` | `CLOSE` | `CONFIRM` A | `BACK` B |
| Battle own turn | `ATTACK` best damaging move with PP | `SWITCH` healthiest other Pokémon | `ITEM` potion if own HP < 50% and one is held, else no action | `RUN` (wild only; trainer: no action) | `ATTACK` | `BACK` |
| Battle forced switch | `SWITCH` healthiest | `SWITCH` | `SWITCH` | `SWITCH` | `SWITCH` | no action |
| Shop | `BUY POTION` if money allows | `BUY BALL` if money allows | `LEAVE` | `LEAVE` | `CONFIRM` | `LEAVE` |
| Pc | `LEAVE` | `LEAVE` | `LEAVE` | `LEAVE` | `CONFIRM` | `LEAVE` |
"Best damaging move" is the highest base power move with PP, type effectiveness applied from the
ROM's type chart; ties by index. "Healthiest" is highest HP fraction, not fainted, not the active
one. Visit sets for `GO EXIT` are the adapter's existing per-map exploration ledger.
**Amended 2026-09-16, after the smoke run** (`infra/docs/macros-bench.md`). Palette mode left the
house eleven times faster than raw and then stalled in Oak's lab, one rung short of the starter,
and the two causes were both in this row:
- **Slot 2 was `LOOK`, and `LOOK` was `TALK`.** Both pressed A at the tile ahead; they differed
only in a precondition, so the palette spent a channel on a duplicate and nothing in it ever
*walked* the fly to a thing. The starter Pokéballs are `object_event`s with `SPRITE_POKE_BALL`
and item balls, signs and bookshelves are the same kind of target — none of them people, so
`GO NPC` is not for them. Slot 2 is now **`GO ITEM`** ("nearest object"), the same walk-and-turn
as `GO NPC` over the other half of the map's object data: the object sprites
(`picture >= FIRST_STILL_SPRITE`) and the `bg_event` sign tiles. `TALK`'s gloss becomes
"talk / look", because that is what the one A press was doing all along. `GO NPC` is now
"nearest **person**" in fact as well as in its gloss: the two macros partition the sprite list
rather than both reading all of it.
- **"Unvisited" was never true.** `GO EXIT` asked `MacroState::exit_visited`, whose default is
`false`, so every exit read as unvisited and the *nearest* one always won — which, for a fly
standing outside its own front door, is that front door. It is now wired to the adapter's
`boundary` ledger through `GameAdapter::exit_visited`, a read accessor that changes no version
string: an exit counts as visited once the ledger holds either half of its `boundary` key, i.e.
once this run has stood on it or beside it. "Found", not "used" — the `:on` half alone is
unobservable for a town door, which fires on the step onto it. The fallback to every exit when
the unvisited set is empty is unchanged.
## 4. Execution
```rust
pub struct MacroId(pub u8); // slot within the current scene's palette
pub struct Palette { pub scene: Scene, pub slots: [Option<MacroSpec>; 6] }
pub struct MacroSpec { pub name: &'static str, pub kind: MacroKind }
pub trait MacroExecutor {
/// Begin a macro. Returns Err if the slot is unbound or the precondition fails; nothing
/// is pressed in that case.
fn start(&mut self, palette: &Palette, slot: MacroId, memory: &mut dyn MemoryReader)
-> Result<(), MacroRefused>;
/// Called once per game frame while running: returns the button mask to hold this frame.
/// `None` means the macro has finished (or aborted) and the fly is consulted again.
fn step(&mut self, memory: &mut dyn MemoryReader) -> Option<u8>;
fn running(&self) -> Option<&'static str>;
}
```
- A macro owns the buttons from `start` until `step` returns `None`. Hard cap 600 frames
(10 s); a macro that exceeds it aborts with `MacroAbort::Timeout`, logged and shown.
- A scene change mid-macro (a wild battle starting during `GO EXIT`) aborts the macro at the
next frame; the fly is consulted on the new scene's palette.
- Between macros the sim consults the decoder's current channels exactly as raw mode does; the
active exclusive channel or the most recent pulse selects the slot. A decision is taken at
most once per `holdMs`; while no channel is active nothing happens.
- Overworld movement uses A* over the current map's walkable tiles (collision from the tileset's
collision table and the map blocks, warps and connections from the tables the adapter already
reads) with one step per tile and a per-step check that the player moved; three failed steps
abort with `MacroAbort::Blocked`.
- Battle and menu macros navigate by reading cursor state from WRAM, never by counting presses.
## 5. Learning and rewards
Unchanged reward catalog and adapter version. Every macro start and finish is a `FeedEvent`
(`kind: macro`, label = name, outcome = done/blocked/timeout/refused) so the ticker shows it and
the event log records it. Reward attribution is what the design is for: the KC->MBON rule
already reinforces the recent spike history on payout; the interval between a macro decision and
its payout is now one macro long.
## 6. Feed and control
- `docs/feed-protocol.md` header gains `game.scene: string`, `game.mode: "raw" | "palette"`,
`game.palette: {slot: number, name: string}[]`, `game.macro: {name, sinceMs} | null`. Additive,
protocol version unchanged.
- `docs/control-api.md`: `/status` mirrors the same fields. No new endpoints.
- Stage (the operator, 2026-09-16): the palette is always visible directly under the Game Boy screen,
not inside a tab. Layout (the operator): "slide the fly over and put the macro palette right next to
it": the strip under the screen becomes the 3D fly on one side, shrunk to make room, and the
palette beside it filling the rest of the strip's width; the two share one baseline and one
frame so they read as a single instrument. Six cells in the D-pad/A/B order, each with the macro's name and a two or
three word gloss of what it does in this scene ("GO EXIT · nearest door"), the button it hangs
off as a small glyph, empty cells shown dim as "—". When the fly chooses one the cell lights
and stays lit while the macro runs, then shows its outcome for a beat (done / blocked / timeout).
Scene changes re-deal the cells with the same halfway-arcade motion the rest of the rail uses. A
MODE chip (RAW / PALETTE) sits by the version chip. The existing button afterglow stays, since
the macro's presses are real presses. Ticker shows macro events. Terse copy, PNG mockups for
The operator before it is wired.
## 7. Measurement (the gate for turning it on in release)
From the same archived checkpoint, 6 brain hours each, unthrottled, on the dev box: raw mode vs
palette mode, reporting rungs reached, time to each rung, macro outcome counts, and reward per
brain hour. Ship palette mode as default only if it reaches rung 6 or higher in fewer brain hours
in 3 of 3 runs. The decoder preset is not touched in either arm.
## 8. Work split
- **Agent A, scenes and WRAM:** `docs/design/macros-wram.md`, `pokemon_red/scene.rs`
(`Scene`, `detect`), and the state accessors macros need: party (species, HP, max HP, moves,
PP, status), enemy species and HP, battle menu and cursor state, text box open flag, start menu
state, shop state, money, bag contents, NPC sprite positions and facing, collision for the
current map (walkable predicate over tile coordinates), warps and connections. Tests: synthetic
WRAM traces and ROM-gated checks from the archived snapshots under the repo's fixture policy
(never the ROM itself).
- **Agent B, executor:** `pokemon_red/macros/` with `Palette::for_scene`, A* pathing, the
macro scripts and `MacroExecutor`. Depends on A's accessors by the signatures in
`macros-wram.md`; until A lands, B codes against a trait `GameState` declared in
`macros/state.rs` and A implements it.
- **Agent C, integration (after A and B):** sim loop mode, feed and status fields, stage chip and
palette cells, event log, measurement harness (`examples/palette_bench.rs`), docs.
Not for release until section 7 passes and the operator has seen the screen.
## 9. Plan mode: the map plans, the fly drives (the operator, 2026-09-16 ~16:30 UTC: "we want the fly
## to act not roll dice" … "yes. go frontier. approved.")
A third mode beside `raw` and `palette`: `FLY_MACRO_MODE=plan`. The doctrine sentence for it is
"the map plans, the fly drives".
- **The plan.** Each scene has a deterministic policy that orders its macros by game knowledge
into a plan; the palette shows that order, top row first. Overworld: `GO OBJECTIVE` (path
toward the next unreached ladder rung's place, using the map, warp, connection and object data
the adapter already reads; if the rung has no known place, fall through), then `GO EXIT`
(unvisited), `GO ITEM` (untalked object), `GO NPC` (untalked person), then `GO FRONTIER`.
Dialog: `NEXT`. Menu: `CLOSE`. Battle: `ATTACK`, `SWITCH` when the active Pokémon is under
a quarter HP and a healthier one exists, `RUN` for a wild battle when the party is weak.
Shop and PC: `LEAVE`. No macro is ever started by the policy itself.
- **The drive.** The fly's readout chooses when and how hard, never which: any active direction
channel executes the plan's next step; A repeats the step just finished; B skips to the next
fallback in the order; silence waits. One decision per `holdMs` as before; a running macro
owns the buttons; scene change re-plans.
- **`GO FRONTIER`** replaces `WANDER` in every mode: walk to the nearest tile bordering ground
this run has never stood on, using the exploration ledger, and face it. No random steps
remain anywhere in the palette.
- **Screen.** Same strip: rows in plan order, the running row lit, the MODE chip says `PLAN`.
Honesty panel line for this mode: "the map plans, the fly drives".
- **Gate.** Same bench as section 7, three arms: raw, palette, plan.
### 9.1 Building exits, floor changes and routes (the operator, 2026-09-16: "make a distinction between building exit and something like stairs")
`GO EXIT` conflated three different moves. Every warp on a map is classified by *where it goes*,
from the destination map id the adapter already reads out of `wWarpEntries`:
- an **exit** — its destination is an outdoor map, i.e. leaving a building;
- a **passage** — its destination is another interior map: stairs, a ladder, a door between rooms;
- a **route** — on an outdoor map, a step off a connected map edge, or a door into a building
whose interior this run has not visited.
"Outdoor" is `constants/map_constants.asm`'s own ordering rather than a tileset read: the towns,
Indigo Plateau, Saffron and every route are map ids `$00`..`$24` (`ROUTE_25`), and `REDS_HOUSE_1F`
at `$25` begins the interiors. A warp whose destination is `LAST_MAP` (`$ff`) — which is how
pokered writes a building's own front door — counts as outdoors, because that is what it is on
every interior map in the game. Verified with the survey method on the cartridge
(`tests/rom_macros.rs`): Red's ground floor has one passage (the staircase, destination `$26`) and
two exits (the doormats, destination `$ff`), and Pallet Town has no exits and no passages, only
routes.
So `GO EXIT` becomes three macros:
| macro | gloss | target |
| --- | --- | --- |
| `GO OUT` | leave building | nearest unvisited exit |
| `GO WARP` | floor change | nearest unvisited passage |
| `GO ROUTE` | next area | nearest unvisited connection, or door into an unvisited interior |
The overworld palette keeps six slots and depends on which side of a door the fly is standing:
| slot | indoors | outdoors |
| --- | --- | --- |
| 0 (UP) | `GO OUT` | `GO ROUTE` |
| 1 (DOWN) | `GO WARP` | `GO NPC` |
| 2 (LEFT) | `GO ITEM` | `GO ITEM` |
| 3 (RIGHT) | `GO FRONTIER` | `GO FRONTIER` |
| 4 (A) | `TALK` | `TALK` |
| 5 (B) | `MENU` | `MENU` |
Indoors `GO NPC` is not on a channel at all; it stays in plan mode's fallbacks, because a room
with a person in it almost always has that person between the fly and the door, and `TALK` already
reaches whatever is ahead.
The plan's overworld order becomes `GO OBJECTIVE`, then `GO OUT` or `GO ROUTE`, `GO ITEM`,
`GO NPC`, `GO WARP`, `GO FRONTIER` — with `GO WARP` promoted above `GO OUT` when a passage on
this map leads to the objective's own map, which is what "the objective is on another floor of the
same building" is observable as.
**Amended 2026-09-16, after the first cartridge run of plan mode**
(`services/flysim/crates/flysim/tests/rom_plan.rs`): the way onward — `GO OUT` or `GO ROUTE` —
ranks *below* `GO ITEM`, `GO NPC` and `GO WARP` when the fly is standing on the objective's own
map. Every place the rung catalog carries is a map and nothing finer, so arriving on the
objective's map makes `GO OBJECTIVE` fall through, and with the order above unchanged the next
entry is `GO OUT` — which walks straight back out of the building the rung is in. The cartridge
showed it: plan mode reached Oak's lab in four hundred frames and then bounced between the lab and
Pallet Town, once per hold, for as long as it was driven. Leaving the map the ladder is asking for
is the one move the map knows is wrong, so it goes last but for the frontier.
**Amended 2026-09-16 (the operator, the contract answer to the rung-4 stall):** `TALK` belongs in the
overworld order, and it ranks **first** whenever the fly is facing an untalked object or person.
Otherwise it is absent from the plan: a tile ahead with nothing on it is not a reason to press A.
So the plan's two-step for a starter Pokéball, a sign or a villager is `GO ITEM` (or `GO NPC`) and
then `TALK`, and A keeps its drive meaning of "repeat the step just finished".
Without it plan mode could not take a starter at all, which is what the first plan smoke hour
measured: rung 4 in 0.005 brain hours and then fifty-nine minutes on it, because the one press that
accepts a Pokéball can only come from the plan and the plan did not carry it
(`infra/docs/macros-bench.md`).
Building it took three attempts on the cartridge, and the two halves of "facing an untalked thing"
turned out to live in different places:
- **Facing something** is the plan's own test: the tile ahead is one of the map's object sprites,
one of its people or one of its `bg_event` signs.
- **Untalked** is `GO ITEM`'s and `GO NPC`'s choice of target — "nearest *untalked*" is the nearest
thing with a tile beside it this run has never stood on — so the walk that puts the fly in front
of something is what makes it untalked, and a thing the run has already stood beside is not
walked to again. It cannot be answered from *in front of* the thing: nothing in WRAM records
that a shelf was read, the fly's own tile enters the exploration ledger three stable samples
after it arrives (before the next decision is due), and a rule about the thing's *other* sides
never closes for an object on a table, which has only one. Both readings were measured and both
failed — the first offered the press once in 549 walks, the second offered it 615 times at one
shelf — and neither took a starter.
- **What bounds the press is the drive**, which is where a repeat belongs, and it needed two rules
that are now part of what "scene change re-plans" means. The plan is re-derived every frame
either way; what the cursor does is: it is kept **per scene**, so the dialog a press opens does
not put the overworld back at the press that opened it; and it returns to rank 0 whenever the
plan's **head changes**, because a different macro at rank 0 is the map saying there is a better
thing to do than the one the fly was working down towards. `TALK` is exactly that kind of entry:
it becomes rank 0 the moment `GO ITEM` finishes facing a ball.
With both rules the stub readout of `services/flysim/crates/flysim/tests/rom_plan.rs` — one
direction burst per `holdMs`, no A, no B, no brain — takes a starter from Oak's lab in 3.5 brain
minutes on 96 macros.
### 9.2 Visited, untalked, and the way to the next town (the operator, 2026-09-16 ~22:10 UTC: "fly is looping")
Kept by section 12: this is knowledge inside macros, not ranking. The stream showed a fly on rung 5
in Pallet Town cycling through the town's houses — GO ROUTE, GO WARP, TALK, GO OUT, GO ROUTE —
and never taking the Route 1 connection north. Four things were wrong at once and all four are in
this chain.
- **"Visited" is a question about the map on the other side.** A connection or a door counts as
visited only once the run has stood on the map it leads to (`GameAdapter::map_visited`), never
because the fly once stood on the boundary tile. The old test was the adapter's `boundary`
ledger, which pays for standing *on or beside* an exit, so one walk along the top row of Pallet
Town marked the way to Route 1 used up while a front door the fly had been through twice still
read fresh — and with every route "visited" the fallback to all of them let *nearest* pick a
house door, once per hold, for ever. `exit_visited` stays on the adapter as the boundary
question; it is no longer what "somewhere new" means.
- **A map edge knows where it goes.** `wCurMapConnections` is four bits and this crate has no
reviewed symbol for the connection headers beside it, so a step off an edge used to carry no
destination at all — invisible to "unvisited interior" and unusable as "the way to Viridian".
`macros/geography.rs` is a static adjacency table (the routes and towns of
`constants/map_constants.asm` with the connection tables of `data/maps/headers`, plus the doors
the ladder's places need), and `next_hop` is breadth-first over it. A map with no row has no
known neighbours: an exit into it counts as unvisited and `GO OBJECTIVE` falls through. Nothing
is guessed.
- **Ranking.** Exits into a map this run has not stood on come first, and among those a
*connection* before a door: the next area is a bigger unknown than a room off this one. When
everything of a kind is exhausted, the ones that take the first hop toward the objective; failing
that, all of them, nearest.
- **`GO OBJECTIVE` crosses maps.** When the rung's place is on another map, the goal is whichever
of this map's exits takes the first hop of the breadth-first route, asked again on arrival. The
search itself steps into `Walkable::Unknown` at eight tiles a step — a map edge is off the screen
buffer by definition, so a search that refused every unknown tile could not plan one step toward
Route 1 from the middle of town. A known way round always wins, and a wrong guess costs three
failed steps.
- **"Untalked" is a ledger, not a guess about the ground.** A `talked` ledger keyed by map and
object index — sprite slot, or a sign's text id — is written when a `TALK` *finishes* facing the
thing. `GO NPC` and `GO ITEM` are offered only for targets it does not hold, with no fallback to
the talked ones, so a map whose people have all been talked to leaves the button off the pad
entirely; `TALK` is on the pad only while the tile ahead holds something untalked. The two
readings tried before it — the ground beside the thing, and the thing's other sides — each failed
in a different direction, and the "never stood on a tile beside it" one never closes at all for a
villager standing in the open, which is why the fly talked to the same people all hour. The
ledger lives in the executor layer and never reaches the checkpoint: a restored run offers every
person once more, which is the honest answer for a ledger that did not survive.
**Rung places** (`pokemon_red/mod.rs`). A place is a map and, where the game makes one derivable,
an edge of that map; no rung carries a tile, because a tile would have to come from map data in a
ROM bank this crate cannot reach. Two are conditional, because the game moves them: rungs 4 and 5
are Pallet Town's **north connection** until `EVENT_FOLLOWED_OAK_INTO_LAB` — Oak stops the fly on
the path out of town and walks it in — and the lab afterwards; rung 6 is **Viridian's mart** until
`EVENT_GOT_OAKS_PARCEL` and the lab after it, because the rung is the parcel *delivered* and the
errand starts two maps north. The old catalog answered "Oak's lab" at rank 5, which told a fly
standing beside the lab to go back into it. Badge rungs name the gym where the interior ordering
derives its id from a verified anchor (Pewter, Cerulean, Viridian) and the town otherwise. Still
unknown, and left `None`: rung 19, the Thunder Badge, because Vermilion Gym's id is not derived.
## 10. Biased slots: the scene weighs the fly's vote (the operator, 2026-09-16 ~18:45 UTC: "make each
## macro slot its own button and have a bias per scene. battles are very structured.
## exploration, less so." … "ok good. plan it build it push it.")
Replaces the drive rules of section 9 (direction executes / A repeats / B skips). Mode name
stays `plan`; sections 9's policy ordering, `GO OBJECTIVE`, `GO FRONTIER` and the exit split are
unchanged.
- **Each slot is its own channel.** The six slots keep the fixed D-pad/A/B binding of section 1,
so a slot's channel is the fly's population for that button. The decoder is not modified; the
game layer reads its per-channel normalized scores.
- **The scene's prior.** The section 9 policy ranks the scene's bound macros; the rank becomes a
prior over slots: rank 0 gets 1.0, rank 1 gets 0.5, rank 2 gets 0.25, and so on; unbound
slots 0.
- **The blend.** Once per `holdMs`, for every bound slot,
`score = w(scene) * prior[slot] + (1 - w(scene)) * fly[slot]`, where `fly[slot]` is the
channel's normalized score in [0, 1]; the slot with the highest score starts, ties to the
lower rank. The fly must be awake: if every `fly[slot]` is below the readout's own activity
floor, nothing starts (silence still waits; the scene never acts alone).
- **Weights `w(scene)`** (the operator, defaults; env-tunable `FLY_MACRO_BIAS_<SCENE>`):
battle 0.9, battle-switch 0.95, dialog 0.95, menu 0.9, shop 0.9, pc 0.9, overworld 0.3,
unknown 0.5. Overworld is mostly the fly; battles are mostly the plan.
- **Screen.** Each cell gains a thin weight bar (the scene's prior for that slot, scaled by
`w`) under the gloss; the cell that fired lights as before. Honesty line for this mode: "the
scene weighs the fly's vote". MODE chip still `PLAN`.
- **Feed.** `game.palette[i]` gains `prior: number` (0..1, already weighted by `w`) — additive.
- **Gate.** Same bench, arms raw / palette / plan(biased); report macros chosen by rank.
**Amended 2026-09-16 (the operator, after the first plan(biased) hour): `fly[slot]` is the spread across
the bound slots, not the distance above each channel's own baseline.**
```
fly[slot] = (score[slot] - min) / (max - min) over the bound slots at that decision
```
so the fly's strongest bound channel votes 1.0 and its weakest 0.0. Nothing starts when the
strongest bound channel is at or under the readout's activity floor, and nothing starts when every
bound slot reads exactly alike — a fly with no preference to express is not a fly choosing the
head. The one exception is a plan of a single entry, which has no spread at all: an awake fly votes
1.0 for it, because otherwise a one-entry plan could never run and a dialog is a one-entry plan.
The first reading of "the channel's normalized score in [0, 1]" was each channel against its own
baseline, `clamp(score - 1, 0, 1)`, and the measured hour is what ruled it out: the readout's score
is a ratio around 1 and a settled network's directions sit inside a 9% spread (`docs/readout.md`),
so every vote landed in 0..0.1, every decision went to rank 0, and the fly chose **0 of 4,399
macros** — the blend was the plan alone (`infra/docs/macros-bench.md`). Against the spread, a fly
that leans at all leans by 1.0, which is what makes `w = 0.3` "mostly the fly" and `w = 0.95`
"mostly the plan" mean what the weights were chosen to mean. The weights, the prior ladder, the
tie rule, the screen and the feed field are unchanged.
## 11. Macro channels of their own (the operator, 2026-09-16 ~21:15 UTC: "not double mappings on
## existing buttons" … "the real keys and the macros. all pressed by neurons like buttons.")
Section 10's blend stays, but the six macro slots stop borrowing the button channels. The eight
Game Boy buttons keep their eight descending-neuron populations (`command_0..7`, round-robin over
the 1,305 descending neurons). The six macro slots get six populations of their own, `macro_0..5`,
built from the 96 mushroom body output neurons (MBONs) split round-robin into six groups of 16.
This is the honest choice: the MBONs are exactly the neurons whose input synapses the reward rule
changes, so a learned preference for a macro in a scene is the mushroom body doing what it does in
the real fly. The roles are a dataset artifact (`circuit-roles.json`, `tools/build_flywire.py`),
regenerated with checksums updated in `tools/artifact-checksums.txt`; the neuron ids, edges and
kernel are untouched, so the compatibility string must not move (rates by role are restored by
name; new roles start at zero).
- Decoder: a second exclusive group `macros` with channels `m0..m5` over `macro_0..5`, same
hold, hysteresis, fatigue and blocked rules as the direction group; no pulses. In `plan` mode
the macro group's normalized scores are the fly's votes of section 10; the button group is
ignored for decisions but still drives the on-screen button afterglow so the audience sees the
motor side alive. In `raw` and `palette` modes the macro group is unused.
- Screen: each palette cell's glyph becomes its channel, `M1`..`M6`, and the SENSES rate bars
gain a MACROS row beside DRIVE and A/B.
- Feed: `game.palette[i].channel: string` (additive). Honesty line: "the mushroom body picks
the macro; the descending neurons press the buttons".
**Amended minutes later (the operator: "better yet map each macro type to a new motor neuron").** Not
six slot channels that change meaning per scene: every macro TYPE gets a population of its own,
stable across scenes, so a channel always means the same action and learning can attach to it.
Types (22): GO OBJECTIVE, GO OUT, GO WARP, GO ROUTE, GO ITEM, GO NPC, GO FRONTIER, TALK, MENU,
NEXT, YES, NO, CLOSE, CONFIRM, BACK, ATTACK, SWITCH, ITEM, RUN, BUY POTION, BUY BALL, LEAVE.
Populations `macro_<type>` are drawn round-robin from the pool of the 96 MBONs plus the 110
brain motor neurons (206 neurons, 9 or 10 per type; a single neuron would be too noisy to read,
a population is what a button is too). The eight buttons keep `command_0..7`. Decoder: the
`macros` exclusive group has 22 channels; at a decision only the channels of the scene's bound
macros compete (the others are masked, the same mechanism as a blocked channel), and section 10's
blend uses their normalized scores. Screen: a cell's glyph is the type's short channel tag (e.g.
`MB·GO`, `MB·ATK`), and the SENSES tab's MACROS row shows the bound channels' rates.
## 12. Simplification: macros are buttons (the operator, 2026-09-16 ~21:40 UTC: "it seems like we're
## defeating what we set out to do … perhaps a simplification." … "good. build it.")
Supersedes sections 9, 9.1 (the drive rules), 10 and the blend of section 11. Kept from them:
scene detection, the executor and every macro script, GO OBJECTIVE / GO OUT / GO WARP /
GO ROUTE / GO ITEM / GO NPC / GO FRONTIER, the exit/stairs/route split, the visited and talked
ledgers, the rung places, and the per-type populations of section 11.
- **Macros are buttons.** Each macro type is a button on the pad, pressed by its own population
(`macro_<type>`, section 11), decided by the decoder's `macros` exclusive group with the same
hold, hysteresis, fatigue and blocked rules as the direction group. Whichever bound channel
wins, starts. No ranking, no prior, no scene weight, no cursor.
- **The scene decides which buttons exist.** Overworld: GO OBJECTIVE, GO OUT or GO ROUTE,
GO WARP (indoors), GO ITEM, GO NPC, GO FRONTIER, TALK (only when facing something untalked),
MENU. Dialog: NEXT (YES / NO when a choice is open). Menu: CLOSE, CONFIRM, BACK. Battle own
turn: ATTACK, SWITCH (ITEM when a potion is held and HP is low; RUN in a wild battle). Battle
text between turns: NEXT (it waits for a press; live deadlock fixed in v0.2.4). Forced
switch: SWITCH. Shop: BUY POTION, BUY BALL, LEAVE. PC: LEAVE. Unbound channels are masked
from the decision. A precondition failure means the button is not on the pad.
- **Knowledge lives inside macros**, never in the choice. The only thing that can make the
choice deliberate is the reward rule acting on the MBON populations.
- **Modes:** `raw` (buttons only) and `macros` (buttons plus the scene's macro buttons; a
raw button press and a macro cannot overlap: while a macro runs it owns the pad). `plan`
and `palette` are removed; env values `palette`/`plan` map to `macros` with a warning for one
release.
- **Screen:** cells in a fixed order by type, present cells lit dim, absent cells hidden, the
running cell lit; glyph = the channel tag; no weight bar. MODE chip: RAW / MACROS. SENSES
gets the MACROS rate row. Honesty line: "the scene sets which buttons exist; the fly presses;
the mushroom body learns which".
- **Feed:** `game.macroMode: "raw" | "macros"`, `game.palette[i]` keeps `slot`, `name`,
`gloss`, `channel`; `prior` removed.
- **Gate:** bench arms raw vs macros, 6 brain hours from the live checkpoint.
### 12.1 Target ledgers (the operator, 2026-09-16 ~23:30 UTC, 45 minutes live in Viridian City)
The event log cycled `GO ITEM start, GO ITEM blocked` — the nearest object was unreachable from
where the fly stood — and `GO NPC start, GO NPC done`, walking to the same person again and again,
while `GO OBJECTIVE` toward Route 2 sat on the pad unchosen. Two ledgers, both inside the macros
where section 12 says knowledge lives, both read by the *target* choice and neither by the choice
of macro:
- **Blocked.** When `GO ITEM`, `GO NPC`, `GO OBJECTIVE`, `GO OUT`, `GO WARP`, `GO ROUTE` or
`GO FRONTIER` aborts `Blocked` or `Timeout`, the target it set out for — the exit, the thing or
the tile, keyed with the map — is excluded from that macro's target choice for **10 brain
minutes** (`FLY_MACRO_BLOCKED_MINUTES`). So the macro picks the next candidate, and with no
candidate left the button is not on the pad. A window rather than a permanent entry because
"unreachable" is a fact about where the fly was standing: a ledge or a person in a doorway stops
being in the way once the fly has moved, and a target excluded for ever would lose the map its
item.
- **Reached.** When `GO ITEM` or `GO NPC` *completes* — arrived and facing, which is all either
one promises — the target is retired for the rest of the session. Only a completed `TALK` marked
a person talked before this, and the fly is free never to press A, which is why the same
villager was worth walking to for forty-five minutes. A later `TALK` writes the talked ledger,
which excludes it anyway. `GO FRONTIER` needs no entry here: a frontier tile stood on stops
being a frontier through the exploration ledger, which is now a test rather than a claim.
The abort's target is the one the walk set out for, recorded at `start` beside `TALK`'s facing
entry, because by the time a walk gives up the candidate list has moved on. Both ledgers are
session state in the executor layer and neither reaches the checkpoint, exactly like `talked`: a
restored run offers every target once more. A rollback's cancellation writes neither — that is the
loop's doing, not the map's. The decoder, the reward catalog, the adapter version and the
compatibility string are untouched (648 bytes, `0d9bfde7…707fa`).
**Screen, same review:** the lit cell hid the macro's name — `--ink-0` on a 30% accent wash, which
measures 5.4:1 and reads as pale green-grey type on muddy amber. The accent moves to the row's
*glyph chip* (page accent behind the darkest ink, 9.5:1) and the row keeps a 16% wash the name
stands clear of (7.4:1); the three outcome states take the same arrangement at 10%. Theme tokens
throughout, so the three themes keep their own palettes. `apps/stage/tests/e2e/macros.spec.ts`
holds the floor at 4.5:1 for the lit row and each outcome, and the baselines are re-shot.
### 12.2 Traps: a macro that completes without moving (2026-09-17, rung 8, two hours seventeen)
`infra/docs/macros-traps.md` is the reproduction, the audit of every macro and scene binding, and
the before/after of the loop detector this added
(`services/flysim/crates/flysim/examples/trap_hunt.rs`). The rule the whole of it comes from, and
the one to hold new macros to:
> **A macro that completes without moving because its precondition is already satisfied where the
> fly stands is a trap.** It must either not be on the pad, or count as reached.
The live loop was GO ROUTE into a house, GO OUT straight back out, GO NPC at the girl by the door
and GO FRONTIER one tile, every three brain seconds, on five tiles, with GO OBJECTIVE off the pad.
What changed, all of it inside the macros:
- **The frame cap is not a fact about the target.** A `Timeout` no longer excludes what the walk was
aimed at when the walk ended *nearer* a goal than it began: it costs one of `TIMEOUT_STRIKES`
(three, section 4's own number at the macro's scale) and the next hold resumes the walk. A
`Timeout` that ended no nearer excludes the target at once. Without this, every attempt at a
connection more than ten seconds' walk away excluded it for ten brain minutes — and took
`GO OBJECTIVE` off the pad with it, because both aim at the same `TargetKey::Exit`.
- **`GO ROUTE` has no last-resort fallback.** With nothing fresh and nothing on the way to the
objective it leaves the pad, instead of letting "nearest" pick the door underfoot. `GO OUT` and
`GO WARP` keep the fallback of section 9.2's third tier, because a room still has to be
leavable.
- **A front door nobody can name is not somewhere new.** A `LAST_MAP` warp whose town
`geography::outdoor_of` cannot name counts as *visited*: the only way onto an interior map is
through its own front door, so the map outside it is one the run has stood on.
- **A thing the fly is already facing is `TALK`'s, not `GO NPC`'s or `GO ITEM`'s.** "Nearest
untalked" excludes the tile ahead, which is the state `TALK`'s own precondition is.
- **A frontier tile whose new ground cannot be stood on is excluded.** The arrival press faced it,
which stays `Done`, and the tile enters the blocked ledger, because the exploration ledger only
ever records ground somebody stood on.
- **A `no route` refusal records what it could not reach.** The preconditions are deliberately the
cheap question and the route search is the real one, so a bound macro could refuse once per hold
for ever while its candidate list never changed.
- **Every playable scene deals a pad that can press something.** Dialog: `NEXT`, `YES`, `NO` — there
is no WRAM observable for "a choice is open" (`docs/design/macros-wram.md`), and A and B both
advance a plain box, so the approximation costs nothing and the fly can answer no. `Unknown`:
`NEXT`, `BACK`, because the Pokédex, the trainer card and OPTION land there and B is what leaves
them. Menu: `CLOSE`, `CONFIRM`, `BACK`. Shop: `BUY POTION`, `BUY BALL`, `LEAVE`. Battle own turn:
`ATTACK`, `SWITCH`, `ITEM`, `RUN`, then `NEXT`; forced switch: `SWITCH`, `NEXT` — the last entry of
each is what keeps a turn with nothing to attack, switch, heal or flee with from dealing an empty
pad, which is the battle-text deadlock of v0.2.4 in a different scene.
- **Six rows, eight buttons: the tail survives.** The overworld order is truncated in the middle, so
`GO FRONTIER` — the fallback that always has somewhere to go — is never the entry that is cut.
Still open, and named rather than worked around: the name-entry screen reads `Unknown` and needs
START, which the pad has no button for; `MENU` is on no pad at all, because the overworld row is
full at six slots and widening it is a feed and stage change; the mart cannot buy until
`MacroState::shop_stock` reads the counter's stock. The ratchet is not a backstop for any of this —
at three attempts on one rung it stops firing by design, and one new tile resets its stall window —
which is why the loop detector runs on the dev box before a release.
### 12.3 Walks that finish: a budget from the plan, a route it keeps, a resume (2026-09-17 08:10 UTC, eight hours on rung 8)
Section 12.2 left the frame cap as a fact about the *macro* and fixed what a `Timeout` means. This
amends what it measures. Live from 02:04 to 07:00 UTC after v0.3.2 the fly sat on rank 8 in
Viridian City and the last 40 KB of the event log held 116 `GO ITEM start` with 117 `GO ITEM
timeout`, 36 `GO ROUTE` starts and timeouts, 12 `GO OBJECTIVE` starts and timeouts, and nothing
else: **every walk spent its whole 600-frame cap and not one macro completed in five hours.**
`infra/docs/macros-traps.md` has the reproduction and the numbers; the mechanism was two tiles
wide.
- **The frame cap is not a fact about the ground.** A walk's cap is now its own plan's: 24 frames
per planned tile plus `FRAME_CAP` as the floor, capped at **60 s of brain time** (3,583 frames).
Viridian City is about twenty tiles by eighteen and 600 frames is twenty tiles of walking at
worst, so the walk from its south end to the Route 2 connection could not finish inside one
macro however well it went. Every other script keeps section 4's flat ten seconds.
- **A walk that is making progress does not time out.** The budget only ends a walk that has got
no nearer a goal for two tiles' worth of frames; the ceiling is what always ends one. "Ever got
closer" (12.2) is the question a *finished* walk is judged by; "closer lately" is the question a
running one is.
- **A walk the cap cut short resumes.** The remaining route is kept per target — map and
`TargetKey`, so `GO OBJECTIVE` and `GO ROUTE` resume each other's journey to one exit — and the
next start continues it instead of planning the same first tiles again. Only from the tile the
walk was suspended at: between two holds the fly may press a raw button, take a warp or be
rolled back, and a route is a list of directions from one tile. Session state in the executor
layer beside the talked, blocked and reached ledgers; it does not reach the checkpoint, and a
rollback drops it.
- **The planner commits to its route.** The walkable predicate's window is the ten-by-nine screen
buffer and it moves with the player, so an A* re-planned after every tile is an A* over a
different map after every tile: a tile priced as plausible ground at eight becomes a wall as the
window reaches it, the cheapest route flips to the far side of the obstacle, and the step back
makes the tile unknown again. The route is planned once and followed while the player is still
on it; it is re-planned only when the ground says something new — a step was refused, the plan
ran out, or the player is not where the plan left it. This is 12.2's row 23, which three strikes
bounded rather than fixed.
- **A refused step is a wall in the direction it refuses.** Ledges (`HandleLedges` matches a
facing, the tile stood on and the tile in front against `LedgeTiles`, and hops two tiles) and
tile-pair collisions (`TilePairCollisionsLand`/`...Water` refuse a step *between* two tiles that
are each passable) are invisible to the tileset collision list the predicate reads. The ledge
tile is absent from every passable list, so the search already treats a ledge as a wall in both
directions and declines the hop; a tile-pair refusal is measured instead — 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 it. Per walk, carried across a resume, never longer: a refusal is a
fact about the frame it happened in, and a person in a doorway is the same shape.
- **A goal the search cannot reach is excluded, not walked toward.** The search covers the whole
map and prices an off-screen tile as passable, so "no goal reachable" means walled off from where
the fly stands rather than too far to see. It refuses at `start`, presses nothing and writes every
goal key to the blocked ledger. The closest-approach route was what the two-tile oscillation
walked: a step toward the nearest reachable tile, a re-plan that answered with the tile just left,
and the pair of them trading the fly back and forth for the whole cap — while the frame cap read
as "closing", because it *had* been one tile nearer once.
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
### 12.4 The gate, and the rung under the rank (2026-09-17, audit row 28)
Section 12.3's walks worked and walked the fly into a wall. `infra/docs/macros-traps.md` has the
reproduction: at Viridian City's (18, 10) the fly stepped north into a still sprite at (18, 9), the
cartridge drew a box reading "This is private property!", took the joypad and walked it back one
tile — and every macro that hit it ended `Done`, because a scene change is "the world moved on
under it" and records nothing. So it was walked into once per hold, for eight hours.
**What shut the road was two rungs under the rank.** The save has `EVENT_GOT_OAKS_PARCEL` set and
`EVENT_OAK_GOT_PARCEL` clear: the parcel is carried and undelivered, so rungs 6 (the delivery) and
7 (the Pokédex) are unearned — while the rank reads **8**, because the rank is the *maximum* over
satisfied rungs and Viridian City had been stood on. The ladder's rungs are not a chain: rung 8 is
a map the fly can walk to, and the two under it are a script two maps south.
- **The objective is the lowest rung this run has not earned**, not `rank + 1`. Nothing in the
macros learns any plot: the catalog already held rung 6's place (Oak's lab, once the parcel is in
the bag) and the arithmetic was picking the wrong rung out of it. The rank itself is unchanged —
it is what the stream shows and what the ratchet measures.
- **A conversation counts as talked to only when the dialog closed without the game moving the
fly.** `TALK` is one A press and it finishes while the box is still open, so the ledger entry is
*pending* until the box closes: with the fly on the tile it pressed from and nothing driving it,
it is written; if the cartridge takes the joypad or the fly ends up somewhere else, it is not; and
a `NO` drops it, because what the fly said no to is still on offer. The gate was marked talked on
the first press, which took it off `TALK`'s pad and out of `GO ITEM`'s list — the one conversation
that might have changed something, had once.
- **A macro the cartridge answers by moving the fly excludes its target for the window.** A scene
change with `MacroState::scripted` — a non-zero `wSimulatedJoypadStatesIndex` or `wStatusFlags5`'s
scripted-movement bit — is the cartridge refusing the step, which is a fact about the target, so
it goes to the blocked ledger like any other refusal. An ordinary scene change (a wild battle
starting mid-walk) still records nothing. `scripted` is false during an ordinary text box, which
is what makes it the test for "the conversation ended by moving the fly" rather than for "a
conversation happened".
- **`YES` and `NO` both stay on the pad** in every text box, unchanged from 12.2's row 15: there is
no WRAM observable for "a choice is open", and the fly being able to answer no is what makes the
rule above meaningful.
- **Reached is a window, not a retirement.** 12.1 retired a `GO NPC`/`GO ITEM` target for the
session on arrival, which also retired every person the fly walked to and wandered away from
without pressing A, with nothing able to bring it back. Arriving is not talking: the entry now
expires with the same window the blocked ledger uses, so the loop stays bounded at one walk per
target per window and the conversation stays available.
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
### 12.5 An objective can name a target, not just a map (2026-09-17, audit row 29)
Row 29 went live: `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. Section 12.4 pointed the objective at the right
rung and the rung's place was still only a *map*, so `GO OBJECTIVE` arrived on the lab's own doormat
and called that arriving, and `GO OUT` walked straight back out.
- **A place can name what earns the rung.** `MapPlace::target` is a `PlaceKind` — `Person` or
`Object` — and the catalog uses it for every rung the early game earns by talking: rung 5 the
starter's Pokéball (`object`), rungs 6 and 7 the parcel and the Pokédex (`person`, both out of
Oak's one script), and the badges (`person`, the gym leader). Where a rung's place is only a map,
nothing changes.
- **Not a sprite slot.** A slot is an index into the map's `object_event` list, which is in a ROM
bank this crate cannot reach, and `docs/design/ladder.md`'s rule is that an unverified number does
not go in. A *kind* needs no number: the macro layer already reads the loaded map's sprite list
and already splits it at `FIRST_STILL_SPRITE`.
- **The arrival is `GO NPC`'s.** `GO OBJECTIVE` aims at the four tiles around the nearest untalked,
unexcluded target of that kind, with the press that turns to face it, and leaves out the tile
ahead exactly as `untalked` does — so a fly already facing the thing has nothing left for a walk
to do, `GO OBJECTIVE` leaves the pad, and `TALK`, whose precondition is precisely that state, is
what the next hold is offered.
- **The ways out are not on the pad while the objective's own thing is here.** `GO OUT`,
`GO ROUTE` and `GO WARP` have no candidates while the ladder's target is on this map, untalked
and unexcluded. Knowledge inside the macro, the way `GO EXIT` knows which exits the run has
taken: nothing ranks anything, the button simply has nothing to aim at. The earlier attempt at
this suppressed the way out with nothing leading to the target and stalled the fly in the room;
the escape hatch is the blocked ledger — a target no walk can reach is excluded for the window,
the objective's list empties, and the door is a candidate again.
- **The `reached` ledger does not apply to the objective's target.** That ledger is `GO NPC`'s
politeness; the ladder is not being polite. The rung *is* the conversation, so the thing stays
worth walking back to until it has been had.
- **The header names the rung the fly is going for.** `milestone.next` is `GameAdapter::next_rung`
rather than `rank + 1` (`docs/feed-protocol.md`, dated).
Measured from the live checkpoint with the rotating stub: Oak's lab in 1.4 brain minutes,
`EVENT_OAK_GOT_PARCEL` at 9.0 brain minutes on 504 macros, and `EVENT_GOT_POKEDEX` in the same
script — the first time any harness has earned a rung that is a conversation. The decoder, the
reward catalog, the adapter version and the compatibility string are untouched.
### 12.6 The battle's own sub-states (2026-09-17, live in Viridian Forest)
Rank 9, Viridian Forest, an hour and forty-one minutes on the rung: a wild Kakuna, Bulbasaur at
8/28, the FIGHT move list open with TACKLE showing 0 of 40 PP — and the event log was `NEXT start`,
`NEXT done`, every 268 brain milliseconds. `Battle::own_turn` was the **top-level menu alone**, so a
frame with the move list open read as *between turns*, whose pad is one `NEXT`: an A press on
whatever the cursor happens to be sitting on. The cursor sat on TACKLE, TACKLE had no PP, the game
said so, the box closed, the list came back.
- **A battle menu that is accepting input is the fly's turn.** The top-level menu, the move list,
and the party list outside a forced switch. The party list is the exception that proves it: opened
by `ChooseNextMon` after a faint it cannot be cancelled and is a forced switch, which has its own
pad; opened by choosing PKMN it is an ordinary part of the turn.
- **A list whose cursor cannot be placed is not accepting input.** `MoveSelectionMenu`'s
coordinates appear on a battle's opening frames, before the engine has copied the active Pokémon
into `wBattleMon*` and while `wCurrentMenuItem` is still 0 rather than the one-based slot the menu
keeps — measured on the cartridge as `Moves { cursor: None, count: 2 }` with no own Pokémon. That
frame is between turns, which is what it was before.
- **The pad is the sub-state's.** Main menu: `ATTACK`, `SWITCH`, `ITEM`, `RUN`, then `NEXT` (the
row-7 backstop for a turn where all four drop). Move list: `ATTACK`, `BACK`. Party list: `SWITCH`,
`BACK`. Forced switch: `SWITCH`, `NEXT`, unchanged. **`NEXT` is on no pad while a list is
accepting input**, because there are exactly two answers to a list — choose from it, or back out
of it — and an A press on whatever the cursor holds is neither.
- **`ATTACK` over an open list** moves the cursor to the best damaging move **with PP** and confirms
it; `best_move` has always skipped a move with no PP, and what was missing is the button being on
the pad at all and the script going straight to the move rather than through FIGHT. **If every
move is out of PP it confirms the cursor's own slot**, because Struggle is the cartridge's answer
there and the way to it is to choose a move anyway — refusing would take `ATTACK` off the pad on
the one turn the fly has nothing else to press, which is the shape the loop had.
- **The battle bag is the one sub-state with no observable, and it is named rather than guessed.**
`BattleMenu` has no item variant: the bag inside a battle reports no cursor through agent A's seam
(`listing()` answers `None` for it), so an open battle bag reads as `BattleMenu::None` and the
between-turns `NEXT` advances it — which is survivable, because `ITEM`'s own precondition needs a
potion and a hurt Pokémon, so the bag is rarely opened at all. Giving it a pad needs a verified
reading first, exactly as `MacroState::shop_stock` does.
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
### 12.7 A doormat is ground, and Route 2 is two maps (2026-09-17 17:32 UTC, four hours on rung 9)
Rank 9 (Viridian Forest, next Pewter City), four hours and eighteen minutes on the rung, 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**, and 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 of walking — and a new one every 800 ms. `GO OBJECTIVE`, `GO ROUTE` and `GO OUT` were absent.
`infra/docs/macros-traps.md` has the reproduction and the numbers; two things were wrong and both
are here.
- **A doormat is walkable, standable ground, and the reward ledger can never record it.**
`MacroState::tile_visited` was the adapter's `exploration` ledger alone, and that ledger belongs
to a *payout*: `sample` inserts a coordinate only on a frame its gate accepts, and the gate
rejects `wMovementFlags & 0xc7` — bits 0, 1 and 2 are `BIT_STANDING_ON_DOOR`, `BIT_EXITING_DOOR`
and `BIT_STANDING_ON_WARP`. So **every warp tile in the game was a permanent frontier**. In the
gate house the two southern doormats are the whole south wall, four tiles from everything, and
`GO FRONTIER` walked one tile at them for ever. The gate stays exactly as it is — it is the
reward rule's own and the catalog is not this section's business — and the macro layer keeps its
**own** answer to its own question: a session ledger in the executor layer beside `talked`,
`blocked` and `reached`, written once per frame from the tile the fly is standing on, never
checkpointed, and OR'd with the adapter's. It can only ever make the frontier smaller. A frame
the cartridge is driving records nothing, because the coordinates and the loaded map header come
from different frames then.
- **A tile a sprite is standing on is not ground.** The collision table knows nothing about people
or objects, so a villager standing in the open reads walkable underneath; the fly can never stand
there while the villager does, so the tile can never enter either ledger. Row 5 bounded that at
one walk per exclusion window; leaving the tile out of `path::frontier` is what lets the frontier
**exhaust**, which is what "the button leaves the pad" needs. The gate house had a guard on one.
- **`ROUTE_2` is one map id whose ground is in two halves the player cannot walk between.** Its
north half touches Pewter City and the forest's north gate; its south half touches Viridian City
and the forest's south gate; the belt of trees between them needs CUT. `geography` had one node
for it, so from inside the gate house the breadth-first answer to "which way is Pewter" was
`ROUTE_2` — two hops, south, through the door the fly had just walked in by. A road that does not
exist. `geography::Region` is a map *and a piece of it*, `next_hop` is asked from the piece the
fly is standing in, and the one split row in the table carries the two pieces' own neighbour
lists and the two doorway rows they are told apart by — 11 and 43, surveyed from the cartridge's
own warp table. Maps 46, 48 and 49 stay off the graph, which is the table's standing rule for a
building no rung place needs a route through: 49's two doors are both warps of `ROUTE_2` itself.
- **An exit into the objective's own map is toward the objective, even with no route in the
graph.** `ways`' second tier asked `next_hop` and gave up when the graph answered nothing, so a
way out whose every destination was visited had no candidate at any tier and the button left the
pad. The direct answer is the one `objective_goals` already had and `ways` did not.
What the fly does with all this is unchanged: the gate house now deals a pad with a way north on
it, and which button is pressed is the readout's. The decoder, the reward catalog, the adapter
version and the compatibility string are untouched.
### 12.8 FIGHT is always pressable (2026-09-17, audit row 34)
Section 12.6 put `ATTACK` on the pad over an open move list and made it confirm the cursor's own
slot when nothing has PP, "because Struggle is the cartridge's answer there and the way to it is to
choose a move anyway". The blind spot was one step earlier: **the move list is only ever reached
through FIGHT**, and `ATTACK`'s precondition over the *top-level* menu was `best_move(..).is_some()`
— a move with PP. So the one turn where the fly has nothing left to attack with took the button off
the pad, which is the state measured in Viridian Forest at 99.1 brain minutes: a wild battle, a
party of one, TACKLE, GROWL and LEECH SEED all at 0 PP, a `RUN` the cartridge refused, and a pad of
`ITEM` over a bag it could only close and `NEXT` on whatever the cursor held — which opens the party
list, whose only bound button with one Pokémon is `BACK`. Six hundred thousand frames of it.
- **The precondition is "is there a move list to open", not "is there a move with PP".** FIGHT
always opens; what is behind it is the cartridge's business. `can_fight` is the Pokémon that is
out having any move at all, and the one case that still answers no is a Pokémon with no move in
any slot, which nothing in the game reaches.
- **What FIGHT does with no PP is measured, not assumed.** Surveyed on the cartridge from the state
above (`examples/scene_probe.rs`, `FLY_PROBE_CATCH=nopp`): backing out to the top-level menu and
confirming FIGHT leaves the move list **closed on every frame** — `CheckPlayerHasUsableMoves`
prints "has no moves left!", sets Struggle, and the turn moves on. So the macro's script is
"confirm FIGHT and stop": a second cursor step would press A at text.
- **A move list that is open with nothing having PP keeps section 12.6's answer.** The same survey
found the list *can* be open in that state, and confirming a 0-PP slot from it earns "There's no
PP left for this move!" and the list again. `BACK` is on the pad beside `ATTACK` and leads to the
menu where FIGHT now works, so the cost is one wasted press rather than a loop — and taking
`ATTACK` off the list's pad would leave `BACK` alone on it, which is the shape this section is
about.
- **The fly's own turn never deals one button.** That is the invariant, and it is what the party
list with a one-Pokémon party breaks if it is reached at all: its one entry is the Pokémon that is
already out, so there is nothing to choose and `BACK` is the only press. Nothing is added there —
a second button would be a macro that completes without moving, which section 12.2 calls a trap —
and what changed is where `BACK` leads: a menu with `ATTACK` on it.
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
### 12.9 `BACK` belongs to a list, and a ball is not thrown at what the party already has (2026-09-22, rung 9, sixty-nine hours)
Rank 9 (VIRIDIAN FOREST, next PEWTER CITY), sixty-nine hours on the rung, the ratchet's three
attempts spent, the fly on Route 2 and in the forest and mostly in wild battles. Since the restart
the macro starts were `BACK` 135, `THROW BALL` 28, `MOVE 2` 10, `RUN` 5 and `GO ROUTE` 5, and the
event log repeated `RUN blocked, BACK start, BACK done`. `infra/docs/macros-traps.md` has the
reproduction and the numbers; two macros, and section 12.2's one rule between them.
- **`BACK` is a button only where there is a list to leave.** The top-level battle menu never had
it and does not now: FIGHT, PKMN, ITEM and RUN are the four answers to it and B is not a fifth.
What dealt it was the *between-turns* row, which section 13.1 gave `NEXT, BACK` for the sake of
the battle bag -- the bag reads as nobody's turn (12.6), so it lands there -- and which is also
every frame of battle text, every animation and every turn resolving. On those frames there is
nothing open, so the B press changes nothing the `NEXT` beside it does not, the macro completes
in a handful of frames on the tile it started on, and half the pad is a button that cannot move
the game on. That is exactly **a macro that completes without moving because its precondition is
already satisfied where the fly stands** (12.2). The row is now the sub-state's, like the own
turn's: `BACK` with the bag open, `NEXT` alone otherwise, and the move list and the party list
keep it as before.
- **`RUN blocked` was the script, not the cartridge.** Not "can't escape": `RUN` is one cursor
navigation, and a cursor macro whose list is not accepting input *waits* rather than pressing
blind (section 4), for `CURSOR_WAIT` = 180 frames before it reports `Blocked`. So a `RUN` that
won a hold as the menu closed spent three brain seconds pressing nothing and then gave up --
row 19 of the audit, bounded and left, and the reason it was visible at all is the `BACK` above
filling the frames on either side of it. `RUN`'s own precondition is unchanged (13.1: only a
wild battle the fly is losing).
- **A ball is not thrown at a species the party already holds.** `THROW BALL`'s precondition was
three facts -- a wild battle, a ball in the bag, room in the party -- and it now has a fourth:
the Pokémon on the other side is not one the party already has. Viridian Forest holds five
species and the fly had caught its own, so every throw spent a ball on a Caterpie or a Weedle it
was carrying, and a catch opens the nickname screen, which reads `Unknown`, needs the START the
pad has no button for (row 14), and is the one screen in the game neither `NEXT` nor `BACK`
leaves. The party is the caught set here and it is the honest one: it is the cartridge's own
lifetime record, it survives the restart the session ledgers do not, and it is in the same
numbering the enemy is read in -- the internal species index. **`wPokedexOwned` is not asked**,
because that bitset is by Pokédex *number* and the table converting an internal index into one
lives in a ROM bank this crate cannot read: `docs/design/ladder.md`'s rule is that an unverified
number does not go in. It costs nothing measurable -- the button is already off the pad while
the party is full and nothing in the vocabulary deposits into a box (row 17), so every species
this run has caught is in the party this reads. An enemy the seam cannot place leaves the button
where it was: a precondition this crate cannot observe is not a precondition (13.1).
- **The ratchet is not the backstop, and on this rung it cannot be.** `budget_spent` precedes both
triggers and three attempts on rank 9 were spent sixty-nine hours ago, so no recovery can fire
again until the rank *improves* -- row 22, contract, and the reason the trap hunt runs on the dev
box before a release. What has to carry the run is the road: rung 9's place stays
`VIRIDIAN_FOREST`, the objective is the lowest **unearned** rung (12.4), which is rung 10's
`PEWTER_CITY`, and `geography::next_hop` from the forest answers `VIRIDIAN_FOREST_NORTH_GATE`
(47), then Route 2's north piece, then Pewter (2) -- already asserted hop by hop in
`macros::geography`'s own tests since 12.7. `GO OBJECTIVE` is on the overworld pad there, which
the ROM-gated run from this checkpoint holds.
The decoder, the reward catalog, the adapter version and the compatibility string are untouched.
## 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
## for the Pokécenter. heal should be a macro.")
Knowledge inside macros, never in the choice; the pad still lists buttons and the fly picks.
- **Errands.** `GO OBJECTIVE` gains an errand list ahead of the rung's place: on a map whose
area (the town the map belongs to, per geography) has a mart or a Pokémon Center this run has
not yet entered, the objective target is that building's door first, then the rung's place.
One visit per area per run (session ledger `areaVisited(kind, area)`), so the errand is
paid once and never loops. Money below the cheapest purchase skips the mart errand.
- **`GO SHOP` / `GO HEAL`** are the errands as buttons of their own too (populations
`macro_go_shop`, `macro_go_heal`, from the same MBON/motor pool by the same rule): on the pad
when the area's mart / center is unvisited (GO SHOP also needs money), walk to its door and
in; inside, walk to the counter / nurse and face them.
- **Shop scene.** Buttons are purchases: `BUY POTION`, `BUY BALL`, `BUY ANTIDOTE`, `BUY REPEL`,
each bound only when the mart's stock (read from WRAM, `shop_stock` accessor, verified by
the survey method) lists it and money allows at least one; each macro buys ONE unit through
the real menus by reading the cursor; `LEAVE` closes the menu and walks out. A purchase
marks nothing visited; the errand was paid on entering.
- **Center scene.** `HEAL` is a macro: walk to the counter, face the nurse, talk, answer YES,
wait for the heal animation to end (read the party HP back to full), close the box. On the
pad only when at least one party member is not at full HP or has a status. `LEAVE` walks out.
- **Ladder.** Unchanged; no reward for shopping or healing (rewards are the adapter's,
untouched).
- **Screen.** Two new channel tags; the cells and the MACROS rate row take them as they come.
- **Proof.** ROM-gated from the Viridian checkpoint: `GO SHOP` enters the Viridian mart and
`BUY POTION` buys one; `GO HEAL` enters the center and `HEAL` restores the party; the errand is
not offered again in Viridian; trap hunt before/after.
## 14. The full pad and four move buttons (the operator, 2026-09-17: "scoot fly over. make it 2
## columns" … "four, one per move slot" … "2 cols.")
- **Screen.** The strip under the game shows EVERY macro type at once as a two-column grid
(rows as needed, 14 with the types below), the fly shrunk and scooted further left to make
room. A cell is lit when the button is on the pad, dim when it is not, bright while its
macro runs, with the outcome beat as before. Fixed order by type so a cell never moves.
- **Battle.** `ATTACK` is replaced by four buttons `MOVE 1` .. `MOVE 4`, one per move slot,
each with its own population (`macro_move_1..4`), bound only when that slot holds a move
with PP; the fly picks the move. No "best move" knowledge remains. `SWITCH`, `ITEM`, `RUN`
(section 13.1 precondition) unchanged. Over an open move list the four buttons select their
slot and confirm; with every move at 0 PP, `MOVE 1` confirms the cursor so Struggle happens.
- **Types (27):** GO OBJECTIVE, GO OUT, GO WARP, GO ROUTE, GO ITEM, GO NPC, GO FRONTIER,
GO SHOP, GO HEAL, TALK, MENU, NEXT, YES, NO, CLOSE, CONFIRM, BACK, MOVE 1, MOVE 2, MOVE 3,
MOVE 4, SWITCH, ITEM, RUN, BUY POTION, BUY BALL, HEAL, LEAVE (BUY ANTIDOTE / BUY REPEL fold
into the shop scene's BUY buttons only if the grid has room; otherwise they stay as
purchases reachable through BUY POTION's script? No: keep them as types; the grid takes
rows as needed).
**Section 14 layout, decided 2026-09-17 (the operator: "your rec is good").** The full
two-column grid under the game does not fit at the 24 px text floor (31 types need about 410 px;
the strip has 172), so: the strip under the game shows every button ON THE PAD NOW, up to 14 as
two columns of seven at the floor, tag and short name, the running one bright; a new rail tab
MACROS shows the whole keyboard, three columns of eleven, bound cells lit and unbound dim, the
running one bright. The game stays at 5x; the afterglow row stays. The population re-deal that
came with the nine new types is accepted.
### 13.1 The full complement, scene by scene (the operator, 2026-09-17: "check to make sure each
### scene has the full complement of options. we run away a lot." … "sometimes the macro buttons
### disappear and everything just hangs there.")
Every scene and sub-state audited against section 12's table and against the game's real options
there. "Read from WRAM" is the standing rule for a new button: a precondition this crate cannot
observe is not a precondition, it is a guess.
| scene / sub-state | pad | change |
| --- | --- | --- |
| Overworld, outdoors | GO OBJECTIVE, GO ROUTE, GO SHOP, GO HEAL, GO ITEM, GO NPC, GO FRONTIER, TALK, MENU | **MENU added** (row 18); errands added |
| Overworld, indoors | GO OBJECTIVE, GO OUT, GO WARP, GO SHOP, GO HEAL, GO ITEM, GO NPC, GO FRONTIER, TALK, MENU | **MENU added** (row 18); `GO NPC` was off section 3's fixed indoor row and on the plan's, and with one dealer it is on both |
| Overworld, inside a Pokémon Center | the indoor pad plus HEAL | new. A centre is a sub-state of the overworld, not a `Scene`: pokered has no "a Pokémon Center is open" byte, so the only honest observable is the map id, and a new `Scene` would be a new `game.scene` on the wire |
| Overworld, inside a mart | the indoor pad | the counter is a `Shop`; the mart's *floor* is an ordinary interior, with the one exception below |
| Overworld, inside a mart or a centre, counter unfaced | GO SHOP or GO HEAL, TALK when facing the counter, MENU | new, and it is the one place a pad is deliberately *narrow*. The errand is paid on entering and never offered again, so a walk that leaves the building spends the one visit the area gets — measured: the fly reached the mart in 1.7 brain minutes and `GO OBJECTIVE` walked it straight back out over the doormat. While the counter is unfaced nothing on the pad leaves (row 34b) |
| Overworld, inside a mart or a centre, counter faced | the indoor pad, plus HEAL in a centre | the suppression is released by facing the counter, by talking to it, or by a walk to it failing |
| Dialog | NEXT, YES, NO | unchanged. There is no "a choice is open" flag (`macros-wram.md`), and A and B both advance a plain box, so all three are dealt for every box — what it buys is the fly being able to answer *no* |
| Menu (the start menu) | CLOSE, CONFIRM, BACK | unchanged. The cursor is the fly's to move with the **raw** D-pad, which still reaches the cartridge in macros mode; a SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for |
| Menu (the bag, an elevator, the party list outside a battle) | CLOSE, CONFIRM, BACK | unchanged |
| Unknown (the Pokédex, the trainer card, OPTION, a naming screen, a mid-warp frame) | NEXT, **BACK** | **BACK added** (row 9): B is what leaves the first three, and A leaves none of them |
| Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN, NEXT | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu |
| Battle, own turn, move list | MOVE 1..4, BACK | as above |
| Battle, own turn, party list | SWITCH, BACK | unchanged |
| Battle, the bag | NEXT, **BACK** | **BACK added**, and the list now reports a *cursor* (`macros-wram.md` 7.1) — before this `ITEM` could open the bag and had nothing to read |
| Battle, forced switch | SWITCH, NEXT | unchanged (row 8) |
| Battle, between turns | NEXT | `BACK` was added here for the bag (the row above reads as nobody’s turn) and **taken back out by section 12.9**: on a frame of battle text there is no list to leave, and a `BACK` that changes nothing is the trap of section 12.2 |
| Shop | BUY POTION, BUY BALL, BUY ANTIDOTE, BUY REPEL, CONFIRM, LEAVE | two purchases to four; CONFIRM added |
| PC | **CONFIRM**, LEAVE | **CONFIRM added**: a list the fly opened is one it can answer rather than only close. Depositing and withdrawing are still not in the vocabulary (row 17) |
| Title | nothing | unchanged, by contract: the readout's boot variant applies |
**`RUN` is bound only in a wild battle the fly is losing.** Two readings, both from the
cartridge's own numbers: the Pokémon that is out is under a third of its HP with no healthier
reserve to send in (the same reserve `SWITCH` chooses by, so the two buttons cannot disagree about
whether one exists), or every move it has is out of PP with nothing to switch to, where Struggle
is a countdown. Anything else and the button is off the pad. Knowledge inside the macro, as a
precondition: nothing ranks `RUN` below `MOVE 1`, it simply is not there while the fight is worth
having — and a fled battle pays nothing and teaches nothing.
**`LEAVE` in a shop closes the menu and does not walk out**, and that is a deliberate limit rather
than the section's words. A scene-spanning walk would have to be planned while the screen buffer
holds the mart's menu, so every tile of it reads `Walkable::Unknown` and the route is a guess over
ground the search cannot see. `GO OUT` is on the mart's floor pad one press later and is the same
walk with the map on screen. Stated here so nobody has to rediscover it.
**Sections 3 and 12 had two dealers.** `Palette::for_scene` was section 3's fixed six-channel table
and `plan::plan_for` was section 9's ordering, and macros mode asked for the second because it was
the one that knew `GO OBJECTIVE`'s rung. They could disagree about which buttons a scene had, and
did: `GO OBJECTIVE` was on the plan's overworld and on no fixed row for two releases. Section 14
removes the last reason they differ — with a slot per type there is nothing to order and nothing to
truncate — so `palette::scene_set` is the table above and `plan_for` *is* `for_scene`.
#### Every way the pad can be empty
An empty pad in a playable scene is the one state the doctrine cannot recover from on its own:
nothing presses for the fly, so a scene with no button waits, and waiting is *correct behaviour
that looks exactly like a hang*. So it is enumerated, closed where a macro can close it, and
measured where it cannot.
| cause | closed by |
| --- | --- |
| a scene that binds nothing at all | the table above: every playable scene has at least one unconditional button (`MENU` on the overworld, `NEXT` in a dialog and in a battle, `CLOSE` in a menu, `LEAVE` in a shop and a PC) |
| an overworld where the talked, reached and blocked ledgers exclude every person, object and frontier tile, every route leads somewhere visited, and there is no objective hop | **fixed**: `ways`' last resort. With *nothing else on this map worth walking to* (`palette::stranded`), `GO ROUTE` offers the exit toward the objective and, failing that, all of them — **ignoring the blocked window**, because a target the ledger is resting is still the only place to go. It is a last resort and not a tier: with anything else on the pad it stays off, because "all of them, nearest" once per hold is row 2's own loop |
| the frontier is empty because `path::frontier` answers about the ten-by-nine walkable window, while most of the map is unstood | **fixed**: `frontier_aims` falls back to the nearest tile of the *whole* map the stood ledger has no entry for. A fallback and not the rule, because the windowed answer is the correct one whenever it has anything in it |
| a running macro aborts and the pad is not re-dealt until the next frame | not a cause: `observe` runs after every frame and the decoder is handed the bound channels again on the very next one |
| a restore, before the first `observe` | not a cause: the loop calls `observe` once at the end of boot and once after a ratchet recovery, so the first frame is decided on a real palette |
| the title screen, and raw mode | not an empty pad by contract: the readout's boot variant applies and no palette is dealt |
| a scene the detector cannot name | `Unknown` deals `NEXT` and `BACK`; a screen neither press leaves (the naming screen, row 14) is a genuine stall and still needs START, which is a contract change |
And because "closed where a macro can close it" is not "closed":
`game.padEmptyMs` publishes how long a **playable** scene has had nothing on the pad, in brain
milliseconds, 0 otherwise; a running macro counts as not empty, because it owns the pad, which is
the honest reading of what the audience is looking at. `fly-watchdog` exports it as
`fly_pad_empty_seconds`. Both are **report only**: nothing in the loop reads it back and nothing
presses a button because of it.
### 14.1 One slot per type, and what the four move buttons removed (2026-09-17)
Section 14's screen asks for every macro type at once, and the battle's own turn wants nine
buttons. Six slots was section 1's D-pad and A/B, and it stayed a cap on how many buttons a scene
could deal long after section 12 stopped a macro being a meaning laid over a button. **A slot is
now a type index**: thirty-one slots, the screen draws every cell in the fixed order and lights
the ones this scene binds, the wire carries the bound ones with their type index as `slot`, and
nothing truncates anything. The decoder never cared — it competes among the bound *channels*.
That retires two fixes: row 12 (`GO FRONTIER` rescued from the truncation) and row 18 (`MENU` on
no pad at all) are both about a cap that no longer exists.
- **Types (31)**, in channel order: GO OBJECTIVE, GO OUT, GO WARP, GO ROUTE, GO ITEM, GO NPC,
GO FRONTIER, GO SHOP, GO HEAL, TALK, MENU, NEXT, YES, NO, CLOSE, CONFIRM, BACK, MOVE 1, MOVE 2,
MOVE 3, MOVE 4, SWITCH, ITEM, THROW BALL, RUN, BUY POTION, BUY BALL, BUY ANTIDOTE, BUY REPEL,
HEAL, LEAVE. `GO STAIRS` is **`GO WARP`** (tag `MB·WARP`): stairs, ladders and the doors
between rooms are all the same move and the old name only described one of them.
- **`MOVE n`** is bound when slot `n` holds a move with PP. `MOVE 1` is also bound when *nothing*
anywhere has PP, because Struggle is what the cartridge does then and the only way to it is to
choose a move anyway (rows 30a and 34); its script confirms wherever the cursor stands rather
than moving it onto some other spent move. Over the main menu a move button presses FIGHT and
then its slot; over an open list it goes straight to the slot. **The `best_move` knowledge, the
ROM move table and the type chart are removed**, not narrowed: which move is used is the fly's
choice and the mushroom body's to learn, and that was the point of the four buttons.
- **`THROW BALL`** ("throw pokeball should be a macro") is on the own-turn pad in a **wild**
battle when the bag holds a ball of any kind and the party has room. The script opens ITEM and
moves the cursor to the ball's own bag index by reading the list; the throw, the shakes, the
result text and a caught Pokémon's nickname prompt are the between-turns `NEXT`'s and the
dialog's `NO`. No catch rate and no HP: when to throw is the fly's. **A full party takes the
button off the pad** even where a PC box would have taken the catch, because this crate has no
reviewed symbol for the box count — the ordinary narrowing, said out loud.
- **Populations.** Thirty-one `macro_<type>` roles, cut from the same 96 MBONs plus 110 brain
motor neurons by the same round-robin: seven or six per type. 206 neurons dealt thirty-one ways
is not twenty-two ways plus nine, so **every population was re-dealt**, exactly as it was when
section 11's amendment went from six slot channels to twenty-two type channels. The neuron ids,
the edges and the kernel are untouched and rates are restored by role name, so
`--print-compatibility` is byte-identical (648 bytes) and the live checkpoint carries over: the
new and renamed roles start at zero, and what a run loses is the mushroom body's learned
preference for particular macros. `circuit-roles.json` regenerated with
`tools/build_flywire.py --macro-roles`, checksums updated.
- **Tags.** `MB·MV1`..`MB·MV4`, `MB·BALL` for `THROW BALL`, `MB·WARP`, `MB·SHOP`, `MB·HEAL`,
`MB·NURSE` for the conversation at the counter, `MB·ANTI`, `MB·REPEL`, and **`BUY BALL` becomes
`MB·PBALL`**: the ball the fly throws takes `MB·BALL`, and two channels cannot share a tag on a
screen that identifies a cell by it.
### 14.2 The grid was reverted, then the layout was decided
**Built, measured, and taken out again.** The two-column grid of all thirty-one types needed
**11 px** type — the widest name and tag are `BUY ANTIDOTE` and `MB·NURSE`, and at 24 px they want
two columns of 346 px in the strip's 364 and 272 px of height in its 208 — and this page's floor
is 24 px at the 0.31 phone downscale. A floor with an exemption in it is not a floor, so the strip
is section 12's again: **six rows, the scene's bound macros in type order**, 24 px, and
`apps/stage/src/lib/geometry.ts` is byte-identical to what it was.
What section 14 *keeps* is the part that is not about pixels: thirty-one types, a slot that is a
type index on the wire, and a pad that binds as many as the scene has. The strip is now narrower
than the pad, and the cost is real and asserted rather than hidden:
- **a centre binds nine macros and `HEAL` is type 29, so the strip cannot show it.** The audience
sees the fly heal in the ticker and not the button it pressed
(`apps/stage/tests/e2e/macros.spec.ts`, "the mart deals purchases, and the centre deals HEAL").
- the battle's own turn binds nine too, so two of `MOVE 1`..`MOVE 4`, `THROW BALL`, `RUN` or `NEXT`
are off the strip whenever all nine hold.
**And then it was decided** (the operator, 2026-09-17: "your rec is good"), which is the paragraph
at the end of section 14 above: the strip under the game shows every button *on the pad now*, up to
fourteen as two columns of seven at the floor, tag and short name, the running one bright; and a new
rail tab, MACROS, shows the whole keyboard as three columns of eleven with the bound cells lit and
the unbound dim. The game stays at 5x and the afterglow row stays. That is what is built; this
section stays because the arithmetic behind the decision is the measurement above, and because the
six-row strip is what the branch shipped for an afternoon in between.
### 14.3 What the grid cost while it existed, for the record
Two consequences of drawing all thirty-one cells at 1920x1080, both measured in the built page
rather than argued:
- **The 24 px type floor was broken inside the grid, and only there — which is why it is gone.** The widest name and tag are
`BUY ANTIDOTE` and `MB·NURSE`; at 24 px they want two columns of 346 px in the grid's 364 and
272 px of height in its 208. **11 px** is the largest that fits. `apps/stage/tests/e2e/text-size.spec.ts`
now measures the grid against `MACRO_CELL_FONT_SIZE` and holds 24 px everywhere else — a
documented exemption for one region with the arithmetic behind it. On-screen copy is the
operator's, and an exemption is not this branch's to grant, so it was reverted.
- **The fly was not scooted over.** Section 14 asks for it and the grid cannot move left of
x = 480 without putting text into the bottom-left no-content zone (0,900 → 480,1080), where
Twitch overlays chat: taking 80 px off the fly would widen a column 179 → 219 and the type
11 → 14 px at the price of nine of the sixteen rows' glyph chips sitting under the overlay. What
the grid was short of was *height*, and the button row's 36 px band is where that came from
(the button row now spans the fly's 416 px column rather than the strip's 792). Two constants
reverse it if the type matters more than the overlay.
`LAYOUT.macroPalette` was `{x: 480, y: 816, w: 364, h: 212}` while the grid existed — one box
moved, the strip's top edge from 852 to 816 — and it is back to `{x: 480, y: 852, w: 364, h: 176}`.
Nothing in the layout differs from main.
## 15. Map-aware walks: one plan over the whole map (the operator, 2026-09-22: "the frontier and
## warp macros need to be map aware: A* over walkable tiles.")
Section 4 has said "A* over the current map's walkable tiles" since the first draft, and it was
never that. The walkable predicate answers for the ten tiles by nine of the screen buffer and
`Walkable::Unknown` for everything else (`docs/design/macros-wram.md`), so every walk in the game
planned through guesses at eight times the price of a known tile, re-planned at every window edge,
and `GO FRONTIER` aimed at whatever unstood ground was on screen -- never the far side of a town,
which nothing could see.
What changes is where the tile ids come from. The rule does not change at all: a tile is walkable
when the current tileset's collision list holds its id, which is `CheckTilePassable`.
- **The whole map is decoded** from the tables the cartridge has already loaded: the block ids out
of `wOverworldMap`, the block-to-tiles blockset out of the tileset header, the collision list as
before, and the `TilePairCollisionsLand` pairs as **directed walls**. `MapGrid` is every tile of
the loaded map with those walls, and `docs/design/macros-wram.md` section 9 is the byte-level
evidence, the new addresses and the verification.
- **One read of a ROM bank was needed, so `MemoryReader` gained one method.** The blockset does
not live in bank 0, and the only way to reach another bank through the CPU bus would be to
*write* the mapper's bank register. `MemoryReader::read_rom(bank, address)` reads the cartridge image the process
already holds instead: the same bytes, addressed the way the disassembly addresses them, and no
write into a running game. The joypad register is still the only write (section 12).
- **`path::route` and `path::frontier` plan over the grid**, and the plan is the same A*: one step
per tile, the multi-goal heuristic, the committed route of section 12.3, re-planned only on a
refusal or a displacement. `GO WARP`, `GO OUT` and `GO ROUTE` route to their warp or connection
tile across the whole map; `GO OBJECTIVE` takes the exit that is the first hop; the frontier is
the nearest unstood walkable tile *anywhere on the map*, by the stood ledger of section 12.7.
- **The window stays as the fallback, and it says when.** A frame with no grid falls back to the
ten-by-nine reading exactly as before, and `GridRefusal` names which of the five reasons it is:
no map header, no player, no collision list, no blockset (which is what a reader with no
cartridge behind it answers), the map not on screen, or the decode disagreeing with the screen.
Nothing is guessed and nothing silently degrades.
- **The grid is checked against the screen before it is trusted, and again whenever it is served.**
The decode is compared with the window predicate over the tile the fly is standing on and its
four neighbours, and a frame where the window can answer for none of them is refused — the block
data shares its bytes with the picture buffer, so a battle is exactly when it belongs to somebody
else. Serving a cached grid re-checks the fly's own tile, because a warp writes the map id before
the header and the blocks: for a frame or two on a doormat, `wCurMap` is the map the fly is
arriving on and the blocks are still the map it is leaving.
- **Cached per map, decoded once on arrival**, dropped when the map or its size changes. Session
state beside the talked, blocked, reached, stood and errand ledgers; never checkpointed, so a
restored run decodes the map again on its first overworld frame.
- **The probes report it.** `examples/scene_probe.rs` prints the grid's size, its walkable count,
the count reachable from where the fly stands and the count never stood on, draws the ground with
the reading it used, and names the refusal when there is none; `examples/trap_hunt.rs` carries the
same line into every trace line and into the summary table. Three numbers read a stalled walk at
a glance: a fly with forty walkable tiles and four reachable ones is fenced in, and no amount of
re-planning will help it.
**Nothing about the choice moves.** The scene deals the same buttons, the readout presses them, and
what changed is what a chosen walk knows about the ground — which is where section 12 puts
knowledge. The decoder, the reward catalog, the adapter version and the compatibility string are
untouched: 648 bytes, `0d9bfde7…707fa`, byte-identical across the change.
Two things the cartridge settled rather than the design, both measured and both written up with
their bytes in `docs/design/macros-wram.md` section 9: the collision id of a map tile is the
**lower left** of its four screen tiles and not the upper left, and **a warp writes the map id
before the map**, which is what the per-serve check above is for.
### 15.1 The proof
- **Unit tests, no cartridge.** The decoder against a made-up tileset: a block's four quadrants,
a collision list over a map wider than the window, a tile-pair collision as a wall in both
directions and not in another tileset, a block id past the end of the blockset staying `Unknown`,
and the reachable count over a fenced region. The reader against synthetic WRAM with a synthetic
blockset in a synthetic bank: the whole map decoded, a screen that disagrees refused, a reader
with no cartridge refused, a battle frame refused, and the cache holding one map. The search over
a grid: one plan across a map larger than the window where the window's own plan walks into a
wall it cannot see, a frontier beyond the window where the window's frontier is empty, a
tile-pair wall planned around, and a connection whose walls are not goals.
- **ROM-gated, from the release container's own checkpoints** (`tests/rom_map_grid.rs`, `FLY_ROM`
plus a checkpoint, skipped cleanly without either). On **Pallet Town** — 20x18, 221 walkable
tiles, 207 of them reachable, none unknown — and on **Viridian Forest** — 34x48, 719 walkable,
all reachable, none unknown: the decode agrees with the window predicate on all ninety tiles the
window can answer for, and it agrees with a **survey of real presses** on every one of the first
120 tiles the walk can stand on (58 refused presses on the town, 74 in the forest, every one of
them a wall, a directed wall or a tile a sprite was standing on; 10 presses in the forest that
the cartridge answered with a battle, which say nothing about the ground either way).
`GO FRONTIER` in the forest planned to ground outside the window and walked there in 215 frames
(615 of its frontier tiles are outside the window); every way out of the forest is one plan away,
27 to 149 steps, with no guessed tile in any of them.
- **The trap hunt**, twenty brain minutes from the rung-9 checkpoint, against `main` at v0.4.2:
**286 distinct (map, tile) become 489**, the median flagged window holds 55 tiles instead of 16,
`GO FRONTIER` runs 64 walks worth up to twelve net tiles instead of four worth one, and one walk
spends its cap where six did. It costs battle: flagged windows go 61 to 70 and windows under four
tiles 5 to 14, every one of them a window spent inside a battle, because a fly that covers more
ground walks into more grass. `infra/docs/macros-traps.md` has both runs whole and says so at
length; this is the one measurement in this file where more ground and fewer flags do not both
hold.