Compare commits

..

8 commits

Author SHA1 Message Date
acamilo
351338038a docs: v0.5.1 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 22:13:09 +00:00
acamilo
349a1fd28c Merge fix/loop-row50: a battle turn is the fly's only while the move list is drawn 2026-09-22 22:13:08 +00:00
dev
e307b7dde4 docs: row 50's survey, its two trap hunts and its ROM run
The press survey, twenty brain minutes before and after on both checkpoints,
and the ROM-gated forest run.

The ethos check's 'fewer flagged windows, more distinct tiles' holds on the
tiles on both arms (296 to 430, 163 to 184) and does NOT hold on the windows
(33/73 to 70/73, 68/73 to 73/73). After the fix the fly spends three quarters
of each run inside battles it is actually fighting, and the hunt's tile rule
flags a fighting fly exactly as hard as a stuck one. Reported rather than
smoothed.
2026-09-22 22:11:01 +00:00
dev
9501a5a17c macros: the survey numbers in the accessor are the corrected pulse's
The first pass of the press survey counted 187 refusals that were its own held
button: JoypadLowSensitivity acts on a key's edge, so a direction the fly was
already holding produced no press. With the pulse releasing first, the reading
is 264 honoured of 3,102 by the cursor bytes alone and 231 of 231 by the bytes
and the box.
2026-09-22 19:09:42 +00:00
dev
c25c28efe3 test: hold the rung-nine forest run to a MOVE n that finishes, and a median battle
Row 50's two numbers, ROM-gated from the forest checkpoint. Before, on main:
MOVE n 940 starts and 838 blocked, 11 battles entered and 10 ended, worst 503
macros, median 48. After: 51 starts and 0 blocked, 13 entered and 13 ended,
worst 283, median 43, and the fly leaves the forest north through the gate.

The blocked share is the assertion the row is about; the median is what it
buys, and the worst battle is a tail rather than the run.
2026-09-22 18:57:18 +00:00
dev
6588897ab3 docs: a menu is up while its box is on screen (section 12.18, row 50)
macros.md gains 12.18 and macros-wram.md a section 10 for the accessor and the
survey that found it: press at every battle frame with a rollback pulse, and
ask every byte of WRAM and HRAM which of them separates a honoured press from
a refused one.

The half of row 50 that was wrong is where the fix is. The move list was not
drawn: MoveSelectionMenu's cursor bytes are never cleared and SelectMenuItem
decrements wCurrentMenuItem back into the one-based range on its way out, so a
turn's whole text and animation read as an open list with a placeable cursor.

The battle bag is the same trap on wListMenuID and is named rather than fixed,
because the figure that tells its list from the frame after it closes has not
been surveyed yet.
2026-09-22 18:43:05 +00:00
dev
e2bf4c0305 macros: the move list is the box on screen, not the cursor bytes it left behind
Row 50: MOVE n reported blocked 890 times in 1,431 macros on the cartridge,
every one of them on a frame the seam read as an open move list with a
placeable cursor.

MoveSelectionMenu writes wTopMenuItemY 12 and wTopMenuItemX 5 and nothing in
the game clears them, exactly as the two-option box's geometry outlives its
box. SelectMenuItem then decrements wCurrentMenuItem back to the 0-based slot
on its way out, which lands straight back inside the one-based range the
accessor reads. So the whole of a turn -- the text, the animation, the
enemy's reply -- read as the fly's own turn on an open list, the pad dealt
the four move buttons on it, and the cursor step pressed at a list nobody was
reading until its budget ran out.

The reading is the figure the menu draws, the same construction text_box's
waiting and yes_no_prompt already make: a box at (4, 12) fourteen wide with a
horizontal run over its top-left corner and the junction tile at (10, 12).
Surveyed with one rollback pulse per battle frame over 3,102 frames at the
rung-9 forest checkpoint: by the cursor bytes alone a real directional press
was honoured on 264 of them, and by the cursor bytes and the box on 231 of
231.

A frame whose list is not on screen is between turns, whose pad is the one
NEXT that advances text.
2026-09-22 18:37:36 +00:00
dev
2987b243f4 probe: press at every battle menu to see which reading says it is accepting input
Row 50 asks which WRAM reading tells a battle menu that is accepting input
from one that is only drawn, and the honest way to answer it is to press.
FLY_PROBE_CATCH=accept drives real battles, and on every battle frame it
exports the emulator state, issues one directional pulse, reads
wCurrentMenuItem and puts the state straight back -- so the run is not
perturbed by the measurement and every frame gets a ground truth.

Beside that it asks every byte of WRAM and HRAM whether its values on
accepting frames are disjoint from its values on refusing ones, so a
reading is found rather than nominated.

The pulse releases the buttons before it presses: JoypadLowSensitivity acts
on a key's edge, so a direction the fly is already holding reads as refused
for the measurement's reason and not the cartridge's.
2026-09-22 18:37:26 +00:00
9 changed files with 829 additions and 8 deletions

View file

@ -142,7 +142,7 @@ parked its cursor. All five bytes are contiguous: `wTopMenuItemY` `$cc24`, `wTop
| menu | signature | cursor | verified |
| --- | --- | --- | --- |
| the top-level battle menu | `wTextBoxID` = `$0b`, `wTopMenuItemY` = 14, `wTopMenuItemX` = 9 with watched keys `PAD_RIGHT\|PAD_A` (left column) or 15 with `PAD_LEFT\|PAD_A` (right), `wMaxMenuItem` = 1 (`DisplayBattleMenu`, `engine/battle/core.asm:2081` and `:2114`) | reported 0 FIGHT, 1 PKMN, 2 ITEM, 3 RUN: the game keeps the index *within* the column and `.rightColumn` adds two on selection | ROM (a fresh menu is FIGHT; RIGHT is ITEM; DOWN from there is RUN), trace |
| the move list | `wTopMenuItemY` = 12, `wTopMenuItemX` = 5 (`MoveSelectionMenu`'s regular menu, `:2492`) | the game's list is **one-based** — `wCurrentMenuItem` is `wPlayerMoveListIndex + 1` and `wMaxMenuItem` is the move count plus one — so the accessor reports the 0-based slot, and `None` for an index that names no move | trace |
| the move list | `wTopMenuItemY` = 12, `wTopMenuItemX` = 5 (`MoveSelectionMenu`'s regular menu, `:2492`) **and the box it draws** — section 10, because nothing clears the cursor bytes and `SelectMenuItem` decrements `wCurrentMenuItem` back into range on its way out | the game's list is **one-based** — `wCurrentMenuItem` is `wPlayerMoveListIndex + 1` and `wMaxMenuItem` is the move count plus one — so the accessor reports the 0-based slot, and `None` for an index that names no move | trace, and the press survey of section 10 |
| the party list | `wTopMenuItemY` = 1, `wTopMenuItemX` = 0, `wMaxMenuItem` = `wPartyCount - 1`, watched keys `PAD_A\|PAD_B` or `PAD_A` alone (`PartyMenuInit`, `home/pokemon.asm:201`) | 0-based party slot | trace |
| **a forced switch** | the party list, in a battle, with `wPartyMenuTypeOrMessageID` = `BATTLE_PARTY_MENU` (`$02`) at `$d07d`. `ChooseNextMon` is the battle path that sets it (`engine/battle/core.asm:1088`, and `:1389` for the "use next mon?" branch); choosing PKMN from the menu sets `NORMAL_PARTY_MENU` (`$00`, `:2316`), which is why the two are distinguishable. `wForcePlayerToChooseMon` (`$d11f`) is the byte `PartyMenuInit` turns into "A only, no way out". | — | trace |
@ -728,3 +728,72 @@ answers that, and the executor's per-step moved check covers the rest), a warp t
step onto it, and a script that pushes the fly off a tile (a session ledger answers that). The
water half of the tile-pair lists is deliberately absent: it is the list
`CheckForJumpingAndTilePairCollisions` uses while surfing, and the palette cannot surf.
## 10. A menu that is accepting input, against one that is only remembered (2026-09-22, row 50)
`HandleMenuInput` is shared by every menu in the game (section 2) and so are the five bytes it
parks a cursor in. Section 2's table reads those bytes to say *which* menu is up; it does not say
whether anybody is reading them. The difference is the whole of row 50: `MOVE n` reported `blocked`
**890 times in 1,431 macros** on the cartridge, every one of them on a frame the seam called an
open move list with a placeable cursor.
**Nothing in the game clears the cursor bytes.** `MoveSelectionMenu` writes `wTopMenuItemY` 12 and
`wTopMenuItemX` 5 once, and the whole of the turn that follows — the text, the animation, the
damage, the enemy's reply — reads them back unchanged. It is the same fact section 7's YES/NO box
rests on ("the cursor bytes survive the box closing"), and the reason the battle's *top-level* menu
never had this problem is that it carries `wTextBoxID` = `$0b` beside its geometry.
`SelectMenuItem` makes it worse rather than better: on its way out of `HandleMenuInput` it does
`ld a, [wCurrentMenuItem] / dec a / ld [wCurrentMenuItem], a`, turning the menu's one-based index
back into a 0-based move slot. That lands straight back inside the range the accessor reads as a
valid one-based slot, so a turn spent on move 2, 3 or 4 leaves a *placeable* cursor behind it.
### The accessor
| state | how | verified |
| --- | --- | --- |
| the move list is **accepting input** | the cursor at `wTopMenuItemY` 12 / `wTopMenuItemX` 5 **and** the figure `MoveSelectionMenu` draws: a `TextBoxBorder` at (4, 12) fourteen wide and four tall, with a horizontal run written over its top-left corner and the `┘` junction written over (10, 12) (`engine/battle/core.asm`, `.regularmenu`). Read whole — both verticals, both horizontal runs, all four corners — because a single frame tile id is an ordinary character. The mimic and relearn menus draw at row 7 and never reach a battle's own turn. | survey (below) |
`Scene::Battle { own_turn }` follows it: a frame whose move list is not on screen reads
`BattleMenu::None`, which is nobody's turn, which is the between-turns row and its one `NEXT`
(`docs/design/macros.md` 12.10). Nothing else moves — the top-level menu, the party list and the
bag keep the readings they had.
### The survey
`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`, from the rung-9 forest checkpoint. The
question "is this menu accepting input" is answered by **pressing at it**, not by nominating a
flag: on every battle frame the emulator exports its state, one directional pulse is issued,
`wCurrentMenuItem` is read, and the state goes straight back — `HandleMenuInput` moves the cursor
on UP and DOWN before it even looks at `wMenuWatchedKeys`, so a cursor that moves is a menu running
its input loop. The pulse *releases* the buttons first, because `JoypadLowSensitivity` acts on a
key's edge and a direction the fly is already holding would read as refused for the measurement's
reason rather than the cartridge's.
| the reading | press refused | press honoured |
| --- | ---: | ---: |
| the cursor bytes alone (what the seam read before row 50) | 2,838 | 264 |
| the cursor bytes **and** the box on screen | **0** | **231** |
| the cursor bytes with no box drawn | 2,838 | 33 |
So 91.5% of the frames the old reading called an open move list were frames no press reached, and
the reading that survives is exact on the 231 it keeps. (The 33 are frames where the pulse's own
thirty frames were long enough for the cartridge to open something by itself; the pulse is a
measurement and not a claim about one frame.)
Beside the press, the probe asks **every byte of WRAM and HRAM** whether its values on accepting
frames are disjoint from its values on refusing ones, so a reading is found rather than guessed.
Over the move list, once the box is in the reading, no byte separates the two classes at all —
there is nothing left to separate. Over the whole class before the fix, the only separators were
the HRAM joypad bytes, which is the measurement seeing its own held button.
### What the same survey found and this section did not fix
- **The top-level battle menu is already exact**: 413 frames, 0 refused. `wTextBoxID` is why.
- **The bag is the same trap, unfixed and named.** `wListMenuID` = `ITEMLISTMENU` outlives the bag
exactly as the cursor bytes outlive the move list: 449 refused against 36 honoured over the
frames the seam calls an open battle bag. The bag list is drawn in the top half of the screen and
the survey has not yet found the figure that tells it from the frame after it closes, so it is
reported rather than guessed — `docs/design/ladder.md`'s rule. `ITEM` and `THROW BALL` are the
two macros it costs.
- **The party list, likewise**: `PartyMenuInit`'s geometry outlives its list.

View file

@ -1217,6 +1217,51 @@ walk. Both were measured from the same rung-10 checkpoint, with
Nothing here changes which button the fly presses. The decoder, the reward catalog, the adapter
version and the compatibility string are untouched.
### 12.18 A menu is up while its box is on screen, not while its cursor bytes say so (2026-09-22, row 50)
The largest thing left inside a battle after 12.17: `MOVE n` reported `blocked` **890 times in
1,431 macros** from the rung-9 forest checkpoint, `MOVE 4` **222 of 224**, and 82% of a fixed run's
frames were battle time with one battle running 30,809 of them. Row 50 called it "the move list
drawn and its cursor placeable but not accepting input". Half of that turned out to be wrong, and
finding out which half is the whole fix.
- **The cursor bytes outlive the list, so the list was not drawn at all.** `MoveSelectionMenu`
writes `wTopMenuItemY` 12 and `wTopMenuItemX` 5 and **nothing in the game clears them**. That is
the same fact 12.12 rested the YES/NO box on — "the cursor bytes survive the box closing" — and
the reason the *top-level* battle menu never had it is that `wTextBoxID` = `$0b` sits beside its
geometry and is written by somebody else. `SelectMenuItem` then decrements `wCurrentMenuItem` back
into a 0-based move slot on its way out, which lands inside the one-based range the accessor reads
as valid, so a turn spent on move 2, 3 or 4 leaves a *placeable* cursor behind it. Every frame of
the text, the animation, the damage and the enemy's reply read as the fly's own turn on an open
move list. The pad dealt `MOVE 1..4` and `BACK` on all of them, the roll landed on one, and the
cursor step pressed at a list nobody was reading until its budget ran out. That is section 12.2's
trap wearing 12.6's clothes: a macro whose precondition is satisfied where the fly stands.
- **So a menu is up while its box is on screen.** The reading is the figure `MoveSelectionMenu`
draws — a box at (4, 12) fourteen wide, with a horizontal run over its top-left corner and the
`┘` junction over (10, 12) — read whole, exactly as `text_box`'s `waiting` and `yes_no_prompt`
are. `docs/design/macros-wram.md` section 10 has the accessor.
- **It was surveyed by pressing, not by nominating a flag.** `examples/scene_probe.rs`,
`FLY_PROBE_CATCH=accept`: on every battle frame the emulator exports its state, one directional
pulse is issued, `wCurrentMenuItem` is read and the state goes straight back, so every frame has a
ground truth and the run is not perturbed by the measurement. By the cursor bytes alone a press
was honoured on **264 frames of 3,102**; by the cursor bytes and the box on **231 of 231**. Beside
it every byte of WRAM and HRAM was asked whether it separates the two classes, so a reading was
found rather than guessed — nothing separates once the box is in it, which is what "exact" means
here.
- **A frame whose list is not on screen is between turns**, whose pad is the one `NEXT` that
advances text (12.10). No pad gains or loses a button anywhere else: the top-level menu, the party
list, the bag and the forced switch keep exactly the rows 13.1 gives them, and which move the fly
uses is still the fly's.
- **The bag is the same trap and it is named rather than fixed.** `wListMenuID` = `ITEMLISTMENU`
outlives the bag as surely as the cursor bytes outlive the move list: over the frames the seam
calls an open battle bag, the same survey refused **449** presses against 36 honoured. The bag's
list is drawn in the top half of the screen and the survey has not yet found the figure that tells
it from the frame after it closes, so `ITEM` and `THROW BALL` still pay for it and that is
reported. A reading this crate cannot verify does not go in (`docs/design/ladder.md`).
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched.
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
## 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.")
@ -1298,11 +1343,11 @@ observe is not a precondition, it is a guess.
| 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 (whose cursor indices are FIGHT 0, **ITEM 1, PKMN 2**, RUN 3 -- two columns, 12.11) | 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. **`NEXT` removed by 12.10** — an A press here confirms FIGHT and reopens the list the move list's `BACK` just closed, and `MOVE 1` is the backstop instead, bound here whatever the battler reads as |
| Battle, own turn, move list | MOVE 1..4, BACK -- or **MOVE 1 alone** | as above, plus **12.11**: `BACK` is dealt here only while `wBattleMon*` reads, because a list that binds no `MOVE n` has a pad whose one button closes the list `MOVE 1` underneath had just opened. With nothing readable the pad is `MOVE 1` and its script confirms where the cursor stands |
| Battle, own turn, move list (**the box on screen**, 12.18) | MOVE 1..4, BACK -- or **MOVE 1 alone** | as above, plus **12.11**: `BACK` is dealt here only while `wBattleMon*` reads, because a list that binds no `MOVE n` has a pad whose one button closes the list `MOVE 1` underneath had just opened. With nothing readable the pad is `MOVE 1` and its script confirms where the cursor stands |
| Battle, own turn, party list | SWITCH, BACK | unchanged |
| Battle, own turn, the bag | ITEM, THROW BALL, **BACK** | the bag reports a *cursor* (`macros-wram.md` 7.1), and since **12.10** it is the own turn, because a cursor accepting input is one. Its pad is the list's own answers; `NEXT` and `CONFIRM` are both off it, being the same blind A press that *uses* whatever the cursor holds |
| Battle, forced switch | SWITCH, NEXT | unchanged (row 8). The one arm that keeps `NEXT` with a cursor up, because it cannot be cancelled and has no `BACK` to undo it |
| Battle, between turns | NEXT | `BACK` was added here for the bag and **taken back out by 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. Since **12.10** the bag is not on this row at all, so `NEXT` here is only ever the A that advances text |
| Battle, between turns | NEXT | since **12.18** this row is most of a battle, and correctly so: a frame whose move list is remembered rather than drawn lands here. `BACK` was added here for the bag and **taken back out by 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. Since **12.10** the bag is not on this row at all, so `NEXT` here is only ever the A that advances text |
| 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 |

View file

@ -774,3 +774,13 @@ decisions for the operator, so the swarm stopped here. DOLPHIN-01 was never in s
Measured on the development box, not capacity claims: bus RPC near one millisecond at the
median; a two-fly transition near 10 to 12 ms at the median in every execution mode; about
5.7 MiB per participant process when split.
- 2026-09-22 (v0.5.1, loop review, auto): row 50. The cartridge never clears the move-list cursor
bytes after a turn, so every frame of a turn's text, animation and reply read as the fly's own
turn on an open list; the pad dealt MOVE 1-4 and BACK on all of them and the cursor step pressed
at a list nobody was reading. Surveyed by pressing: a press was honoured on 231 of 231 frames
where the menu box is drawn and on none where it is not. Fix: one gate on the drawn box in the
battle seam; a frame with no box is between turns, NEXT only. From the forest checkpoint MOVE n
blocked starts 838 -> 0, every battle entered is ended, worst battle 503 -> 283 macros; hunt
tiles up on both arms (296 -> 430 forest, 163 -> 184 Route 3), flagged windows again rise
because the fly is inside battles it is fighting (same judgement as v0.4.6). Next row: the
battle bag's list id outlives the bag the same way.

View file

@ -1863,7 +1863,7 @@ same question, and that was the second half of the trap.
| 41 | the nurse's conversation is a ring of forty-six A presses that ends where it began, and the dialog pad deals two names for the A press that walks it | standing at a Pokémon Center's counter with a party that is already full -- which is every visit after a heal, and the state a `GO HEAL` errand leaves the fly in | `talk_is_off_the_pad_at_a_nurse_the_party_has_no_use_for`, `the_nurses_prompt_offers_only_the_answer_that_changes_something`, `a_completed_heal_writes_the_nurse_into_the_talked_ledger`, `a_declined_heal_writes_the_nurse_into_the_talked_ledger`, `the_fly_leaves_the_pokemon_center_from_the_rung_ten_checkpoint` (ROM-gated) | **fixed**: `TALK` is off the pad at a nurse the party has no use for; her prompt deals only the answer that changes something; `NEXT` is off any readable YES/NO pad, because an A press there *is* `YES`; and a completed heal or a declined prompt retires her |
| 48 | `TALK` is bound by a reach that goes over a counter and recorded by one that does not, so a counter person is never retired | any mart clerk or centre nurse, since the counter reach was added | `a_completed_heal_writes_the_nurse_into_the_talked_ledger` (the ledger entry is the assertion) | **fixed**: the ledger entry comes from `palette::facing_target`, which is `TALK`'s own precondition |
| 49 | a YES/NO answer that brings the same prompt straight back | any readable two-option box the answer does not settle | `a_yes_no_box_that_reopens_unchanged_takes_that_answer_off_the_pad`, `a_prompt_that_does_not_come_back_excludes_nothing` | **fixed**: `TargetKey::Answer { at, yes }` in the blocked ledger, same ten-minute window as a walk's target, armed for one hold after the answer. The exclusion narrows a pad and never empties one |
| 50 | `MOVE n` reports `blocked` with the move list drawn and its cursor placeable but not accepting input | every battle | -- | **unchanged from v0.4.3 and v0.4.4, named again**: 222 of 224 `MOVE 4` and 162 of 171 `MOVE 2` in the ROM run below. Row 30b's unplaceable cursor inverted; the honest fix is a WRAM reading of "this list is accepting input" rather than a pad change, and it is the next brief |
| 50 | `MOVE n` reports `blocked` with the move list drawn and its cursor placeable but not accepting input | every battle | `a_move_list_is_the_box_on_screen_and_not_the_cursor_bytes_it_left_behind`, and the `MOVE n` blocked share in `the_battles_turns_advance_from_the_rung_nine_forest_checkpoint` (ROM) | **fixed** (2026-09-22, `docs/design/macros.md` 12.18), and the half of the row that was wrong is where the fix is: the list was **not** drawn. `MoveSelectionMenu`'s cursor bytes are never cleared and `SelectMenuItem` decrements `wCurrentMenuItem` back into the one-based range on its way out, so every frame of a turn's text and animation read as an open list with a placeable cursor. A menu is up while its **box** is on screen -- surveyed by pressing at every battle frame with a rollback pulse, honoured on 264 frames of 3,102 by the cursor bytes alone and on **231 of 231** by the bytes and the box |
### The ROM-gated run, from the live checkpoint
@ -2156,3 +2156,126 @@ skipped cleanly without `FLY_ROM` and the checkpoint):
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1
through v0.4.5. Decoder, reward catalog, adapter version and roles untouched.
## Row 50: the move list was never drawn (2026-09-22, v0.4.7)
`MOVE n` has reported `blocked` on most of its starts since v0.4.3 and every review since has named
it and left it: "the move list drawn and its cursor placeable but not accepting input". Half of
that is wrong, and finding out which half is the fix.
### The survey
`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`, from the rung-9 forest checkpoint. The
question "is this menu accepting input" is answered by **pressing at it**: every battle frame
exports the emulator state, takes one directional pulse, reads `wCurrentMenuItem` and puts the
state straight back, so every frame has a ground truth and the run is not perturbed by the
measurement. `HandleMenuInput` moves the cursor on UP and DOWN before it looks at
`wMenuWatchedKeys`, so a cursor that moves is a menu running its input loop. The pulse releases the
buttons first: `JoypadLowSensitivity` acts on a key's edge, and the first pass of this survey
counted 187 refusals that were its own held button.
3,102 battle frames whose cursor bytes say "the move list":
| the reading | press refused | press honoured |
| --- | ---: | ---: |
| the cursor bytes alone (the seam before this row) | 2,838 | 264 |
| the cursor bytes **and** the box on screen | **0** | **231** |
| the cursor bytes with no box drawn | 2,838 | 33 |
So 91.5% of the frames the old reading called an open move list were frames no press reached. The
33 are frames where the pulse's own thirty were long enough for the cartridge to open something by
itself; the pulse is a measurement and not a claim about one frame.
Beside the press, the probe asks **every byte of WRAM and HRAM** whether its values on accepting
frames are disjoint from its values on refusing ones, so the reading is found rather than
nominated. Once the box is in the reading nothing separates the two classes, because there is
nothing left to separate. The top-level battle menu was already exact: 413 frames, 0 refused, and
`wTextBoxID` is why.
### The mechanism
`MoveSelectionMenu` writes `wTopMenuItemY` 12 and `wTopMenuItemX` 5 and nothing in the game clears
them -- row 41's fact about the YES/NO box, one menu over. `SelectMenuItem` then decrements
`wCurrentMenuItem` back into a 0-based move slot on its way out, which lands inside the one-based
range the accessor reads as valid, so a turn spent on move 2, 3 or 4 leaves a *placeable* cursor
behind it. That is why `MOVE 4` was 222 of 224.
### The trap hunt, twenty brain minutes on each checkpoint
`main` at `e76b3d1` against this branch, same seed, same ground.
**The rung-9 forest checkpoint.**
| measure | before | after |
| --- | ---: | ---: |
| `MOVE n` starts / `blocked` | 109 / **29** (26.6%) | 83 / **0** |
| macro starts that were `BACK` on the move list | **233** | 17 |
| frames the seam called an open move list | **20,318** | 3,742 |
| frames it called a move list with no cursor | 6,751 | 10 |
| frames it called between-turns | 1,670 | **44,270** |
| distinct (map, tile) | 296 | **430** |
| windows flagged | **33 / 73** | 70 / 73 |
| macros started / blocked | 620 / 31 | 1,160 / **1** |
| frames in `battle` | 31,751 | 52,311 |
| wall clock | 3,350 s | 3,038 s |
**The rung-11 Route 3 checkpoint.**
| measure | before | after |
| --- | ---: | ---: |
| `MOVE n` starts / `blocked` | 198 / **72** (36.4%) | 97 / **2** (2.1%) |
| macro starts that were `BACK` on the move list | **361** | 22 |
| frames the seam called an open move list | **34,637** | 4,141 |
| frames it called a move list with no cursor | 16,162 | 5 |
| frames it called between-turns | 5,372 | **47,660** |
| distinct (map, tile) | 163 | **184** |
| windows flagged | **68 / 73** | 73 / 73 |
| macros started / blocked | 1,097 / 87 | 1,300 / **19** |
| `GO ROUTE` completed | 5 | 21 |
| wall clock | 2,392 s | 2,127 s |
**More ground on both arms, and more flagged windows on both.** That is row 54's arm again and it
is reported rather than smoothed: after the fix the fly spends 73% and 78% of the two runs inside
battles it is actually fighting, and the hunt's rule -- fewer than four distinct tiles in two brain
minutes -- flags a fly that is fighting exactly as hard as a fly that is stuck. The ethos check's
"fewer flagged windows, more distinct tiles" holds on the tiles and **not** on the windows, on both
arms. The merge is Fable's call.
### ROM-gated, from the forest checkpoint
`the_battles_turns_advance_from_the_rung_nine_forest_checkpoint`, with the two claims row 50 turns
on added to it:
| measure | before (`main` at `e76b3d1`) | after |
| --- | ---: | ---: |
| `MOVE n` starts / `blocked` | 940 / **838** | 51 / **0** |
| battles entered / ended | 11 / **10** | 13 / **13** |
| worst battle, in macros | 503 | 283 |
| median battle, in macros | 48 | 43 |
| where `blocked` was earned | seven of ten were `MOVE n` in a battle | no `MOVE n` at all |
The blocked share is the assertion; the median is what it buys and the worst battle is a tail.
### Residuals, named rather than worked around
- **The battle bag is the same trap on `wListMenuID`.** `ITEMLISTMENU` outlives the bag exactly as
the cursor bytes outlive the move list: over the frames the seam calls an open battle bag the
same survey refused **449** presses against 36 honoured. Its list is drawn in the top half of the
screen and the survey has not found the figure that tells it from the frame after it closes, so
`ITEM` and `THROW BALL` still pay for it. It is the next trap.
- **The party list, likewise**: `PartyMenuInit`'s geometry outlives its list.
- **The hunt's tile rule still cannot tell a long battle from a stall**, which is section 15's own
measurement in `docs/design/macros.md` and now the third branch to run into it.
### Gates
- `cargo test --workspace` with `FLY_ROM` set: green except
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
the known debug-build boot failure on this box. On the first pass
`flybus::integration::unix_socket::session_over_one_router` also failed -- the slow-consumer
coalescing flake that `fix/flybus-coalescing-flake` is open on -- and passed on a re-run; three
other agents were building on the box at the time.
- `cargo clippy --all-targets`: clean.
- `infra/tests/lint.sh`: all checks passed, de-PII guard included.
- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to this
branch's base. Decoder, reward catalog, adapter version and roles untouched.

View file

@ -261,17 +261,42 @@ impl Wram {
self.set(ram::wTextBoxID, poke::BATTLE_MENU_TEMPLATE).cursor(14, x, current, 1, keys)
}
/// The move list, `MoveSelectionMenu`'s regular menu. `slot` is the 0-based move.
/// The move list, `MoveSelectionMenu`'s regular menu, open and accepting input. `slot` is the
/// 0-based move.
///
/// Both halves, because since row 50 the seam reads both: the cursor bytes *and* the box the
/// menu draws. Use [`Self::move_menu_stale`] for the state a turn spends its text and animation
/// in, which is these bytes with no box on screen.
pub fn move_menu(&mut self, slot: u8, moves: u8) -> &mut Self {
self.move_menu_stale(slot, moves).draw_move_list()
}
/// The bytes `MoveSelectionMenu` wrote, with its box no longer on screen.
///
/// Nothing in the game clears `wTopMenuItemY` / `wTopMenuItemX` / `wCurrentMenuItem`, so this
/// is what every frame of a turn's text, animation and reply reads back once a move has been
/// chosen (`infra/docs/macros-traps.md` row 50). Note that `SelectMenuItem` decrements
/// `wCurrentMenuItem` back to the 0-based slot on its way out, so `slot` here is one lower than
/// the slot the fly chose.
pub fn move_menu_stale(&mut self, slot: u8, moves: u8) -> &mut Self {
self.set(ram::wNumMovesMinusOne, moves.saturating_sub(1)).cursor(
12,
5,
poke::MOVE_LIST_CURSOR_Y,
poke::MOVE_LIST_CURSOR_X,
slot + 1,
moves + 1,
poke::pad::UP | poke::pad::DOWN | poke::pad::A,
)
}
/// The figure `MoveSelectionMenu` draws: a box at (4, 12) with a horizontal run over its
/// top-left corner and the `┘` junction at (10, 12).
pub fn draw_move_list(&mut self) -> &mut Self {
let (left, top, right, bottom) = poke::MOVE_LIST_BOX;
self.draw_box(left, top, right, bottom)
.screen_tile(left, top, poke::frame::HORIZONTAL)
.screen_tile(poke::MOVE_LIST_JOIN, top, poke::frame::BOTTOM_RIGHT)
}
/// The party list. `forced` is the state `ChooseNextMon` leaves: A only, no way out.
pub fn party_list(&mut self, current: u8, forced: bool) -> &mut Self {
let count = self.peek(ram::wPartyCount).max(1);

View file

@ -87,6 +87,18 @@ pub mod poke {
pub const YES_NO_CURSOR_Y: u8 = 8;
pub const YES_NO_CURSOR_X: u8 = 12;
/// The move list's own box, and the junction tile in its top edge
/// (`infra/docs/macros-traps.md`, row 50).
///
/// `MoveSelectionMenu`'s regular menu draws a `TextBoxBorder` at (4, 12) fourteen wide and
/// four tall, then writes a horizontal run over its top-left corner and a `┘` over (10, 12).
/// Values rather than symbols, like `YES_NO_BOX`: this is a figure on screen, not a byte.
pub const MOVE_LIST_BOX: (u16, u16, u16, u16) = (4, 12, 19, 17);
pub const MOVE_LIST_JOIN: u16 = 10;
/// Where `MoveSelectionMenu` parks the shared cursor: row 12, column 5.
pub const MOVE_LIST_CURSOR_Y: u8 = 12;
pub const MOVE_LIST_CURSOR_X: u8 = 5;
/// `constants/ram_constants.asm`: `wMiscFlags` bit 3.
pub const BIT_USING_GENERIC_PC: u8 = 1 << 3;
/// `wFontLoaded` bit 0.
@ -428,9 +440,22 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option<Battle> {
// round, so `ITEM` and `THROW BALL` opened the party list and `SWITCH` opened the bag.
let column = if right { 2 } else { 0 };
BattleMenu::Main { cursor: column + cursor.current.min(1) }
} else if cursor.top_y == 12 && cursor.top_x == 5 {
} else if cursor.top_y == poke::MOVE_LIST_CURSOR_Y
&& cursor.top_x == poke::MOVE_LIST_CURSOR_X
&& move_list_drawn(memory)
{
// MoveSelectionMenu's regular menu. Its list is one-based: `wCurrentMenuItem` is
// `wPlayerMoveListIndex + 1` and `wMaxMenuItem` is the move count plus one.
//
// **Both halves are load-bearing** (row 50, 2026-09-22). The cursor bytes are written once
// and never cleared, so the geometry alone is true for the whole turn -- the text, the
// animation, the enemy's reply -- and `SelectMenuItem` decrements `wCurrentMenuItem` back
// to a 0-based slot as it leaves, which lands right back inside this accessor's one-based
// range. So a frame of battle text read as an open move list with a placeable cursor, the
// pad dealt `MOVE 1..4` on it, and the cursor step pressed at a list nobody was reading:
// `MOVE n` reported `blocked` 890 times in 1,431 macros. The box on screen is what says the
// list is up, and it is the same construction `text_box`'s `waiting` and `yes_no_prompt`
// already make.
let count = read(memory, ram::wNumMovesMinusOne).saturating_add(1).min(4);
let slot = cursor.current.checked_sub(1).filter(|slot| *slot < count);
BattleMenu::Moves { cursor: slot, count }
@ -476,6 +501,10 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option<Battle> {
// still 0 rather than the one-based slot the menu keeps. Measured on the cartridge: a wild
// Weedle's opening frame reads `Moves { cursor: None, count: 2 }` with `own: None`. That
// frame is between turns, which is what it was before this change.
//
// A move list that is *not on screen* never reaches this arm at all since row 50: the menu
// above reads `None` for it, so the frame is between turns and its pad is the one `NEXT`
// that advances text (section 12.10).
BattleMenu::Moves { cursor, .. } => cursor.is_some(),
BattleMenu::Party { .. } => !forced_switch,
// The bag is a list the fly opened *during* its turn, and it is a menu cursor accepting
@ -576,6 +605,50 @@ pub fn yes_no_prompt(memory: &mut dyn MemoryReader) -> bool {
border_drawn(memory, left, top, right, bottom)
}
/// Whether `MoveSelectionMenu`'s own box is the figure on screen (`infra/docs/macros-traps.md`,
/// row 50).
///
/// The cursor bytes alone are not the move list. `wTopMenuItemY` 12 and `wTopMenuItemX` 5 are
/// written by `MoveSelectionMenu` and **nothing clears them**, exactly as the two-option box's
/// geometry outlives its box (`yes_no_prompt` above): the whole rest of the turn -- the text, the
/// animation, the damage, the enemy's reply -- reads back the same five bytes. Surveyed on the
/// cartridge over 3,102 battle frames at the rung-9 forest checkpoint, one rollback pulse per frame
/// (`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`): by the cursor geometry alone a real
/// directional press moved `wCurrentMenuItem` on **264** of them, and by the geometry **and** this
/// box on **231 of 231**. With the box not drawn, 33 of 2,871 -- and those thirty-three are frames
/// where the pulse's own thirty were long enough for the cartridge to open something by itself.
///
/// The figure is `MoveSelectionMenu`'s regular menu and only it: a `TextBoxBorder` at (4, 12)
/// fourteen wide and four tall, with two tiles written over it afterwards -- the top-left corner
/// becomes a horizontal run and (10, 12) becomes the `┘` junction with the PP box above. The
/// mimic and relearn menus draw at row 7 and never reach a battle's own turn. Read whole, like
/// every other box in this module, because a single tile id is an ordinary character.
fn move_list_drawn(memory: &mut dyn MemoryReader) -> bool {
let (left, top, right, bottom) = poke::MOVE_LIST_BOX;
if screen_tile(memory, left, top) != poke::frame::HORIZONTAL
|| screen_tile(memory, poke::MOVE_LIST_JOIN, top) != poke::frame::BOTTOM_RIGHT
|| screen_tile(memory, right, top) != poke::frame::TOP_RIGHT
|| screen_tile(memory, left, bottom) != poke::frame::BOTTOM_LEFT
|| screen_tile(memory, right, bottom) != poke::frame::BOTTOM_RIGHT
{
return false;
}
for x in (poke::MOVE_LIST_JOIN + 1)..right {
if screen_tile(memory, x, top) != poke::frame::HORIZONTAL {
return false;
}
}
for x in (left + 1)..right {
if screen_tile(memory, x, bottom) != poke::frame::HORIZONTAL {
return false;
}
}
(top + 1..bottom).all(|y| {
screen_tile(memory, left, y) == poke::frame::VERTICAL
&& screen_tile(memory, right, y) == poke::frame::VERTICAL
})
}
/// The four screen tiles the dialogue box's `waiting` test reads, in the order `box_drawn` reads
/// them: top-left, top-right, bottom-left, bottom-right of a box at (0, 12)-(19, 17).
///

View file

@ -227,6 +227,95 @@ fn the_move_list_is_reported_zero_based() {
assert_eq!(battle(&mut wram).unwrap().menu, BattleMenu::Moves { cursor: None, count: 3 });
}
/// Row 50: the move list is the box on screen, not the cursor bytes it left behind.
///
/// `MoveSelectionMenu` writes `wTopMenuItemY` 12 and `wTopMenuItemX` 5 and **nothing in the game
/// clears them**, exactly as the two-option box's geometry outlives its box (`yes_no_prompt`).
/// `SelectMenuItem` then decrements `wCurrentMenuItem` back to the 0-based slot on its way out,
/// which lands straight back inside the one-based range this accessor reads -- so a frame of battle
/// text read as an open move list with a placeable cursor, the pad dealt `MOVE 1..4` on it, and the
/// cursor step pressed at a list nobody was reading until its budget ran out: `MOVE n` reported
/// `blocked` 890 times in 1,431 macros on the cartridge.
///
/// Surveyed with one rollback pulse per battle frame (`examples/scene_probe.rs`,
/// `FLY_PROBE_CATCH=accept`, 3,102 frames at the rung-9 forest checkpoint): by the cursor bytes
/// alone a real directional press moved the cursor on 264 frames, and by the cursor bytes **and**
/// the box on screen on 231 of 231.
#[test]
fn a_move_list_is_the_box_on_screen_and_not_the_cursor_bytes_it_left_behind() {
let battler = |wram: &mut Wram| {
wram.party_mon(0, 4, 7, 14, 22, 0, &[(10, 35)]);
wram.battle_mon(0, 4, 7, 14, 22, 0, &[(10, 35), (45, 40), (33, 30)])
.enemy_mon(19, 3, 5, 11)
.battle(1);
};
// The list drawn: the fly's own turn, on the slot the cursor is on.
let mut wram = Wram::overworld();
battler(&mut wram);
wram.move_menu(2, 3);
let fight = battle(&mut wram).unwrap();
assert_eq!(fight.menu, BattleMenu::Moves { cursor: Some(2), count: 3 });
assert!(fight.own_turn, "a move list with its box on screen is the fly's turn");
// The same bytes with the box gone -- every frame of the turn's text, animation and reply.
// Not a move list at all, so not the own turn, so the pad is the between-turns `NEXT`.
let mut wram = Wram::overworld();
battler(&mut wram);
wram.move_menu_stale(2, 3);
let fight = battle(&mut wram).unwrap();
assert_eq!(fight.menu, BattleMenu::None, "the cursor bytes alone are not a move list");
assert!(!fight.own_turn, "cursor bytes with no box are between turns");
// The exact shape row 50 was measured in: `SelectMenuItem` decrements on its way out, so the
// slot the fly chose reads back one lower and stays inside the one-based range for ever.
let mut wram = Wram::overworld();
battler(&mut wram);
wram.move_menu(3, 3).set(ram::wCurrentMenuItem, 3);
assert!(battle(&mut wram).unwrap().own_turn, "with the box drawn this is a real slot");
let mut wram = Wram::overworld();
battler(&mut wram);
wram.move_menu_stale(3, 3).set(ram::wCurrentMenuItem, 3);
assert!(!battle(&mut wram).unwrap().own_turn, "the same byte, no box, no turn");
// Half a figure is not a box. The junction tile at (10, 12) is the one `MoveSelectionMenu`
// writes over its own border, and a run of horizontals there is an ordinary text box.
let mut wram = Wram::overworld();
battler(&mut wram);
wram.move_menu(1, 3).screen_tile(poke::MOVE_LIST_JOIN, 12, poke::frame::HORIZONTAL);
assert_eq!(battle(&mut wram).unwrap().menu, BattleMenu::None, "the junction tile is read");
// And the pads the two frames are dealt, which is what row 50 costs: the four move buttons and
// `BACK` where a list is open (section 13.1), and the one `NEXT` that advances text where the
// turn is resolving (section 12.10).
use crate::pokemon_red::macros::palette::{MacroKind, scene_set};
let pad = |wram: &mut Wram| {
let scene = crate::pokemon_red::scene::detect(wram);
let mut poke = PokeState::new(wram);
scene_set(scene, &mut poke)
};
let mut wram = Wram::overworld();
battler(&mut wram);
wram.move_menu(1, 3);
assert_eq!(
pad(&mut wram),
vec![
MacroKind::Move1,
MacroKind::Move2,
MacroKind::Move3,
MacroKind::Move4,
MacroKind::Back
],
"an open move list"
);
let mut wram = Wram::overworld();
battler(&mut wram);
wram.move_menu_stale(1, 3);
assert_eq!(pad(&mut wram), vec![MacroKind::Next], "the same bytes with no box on screen");
}
#[test]
fn a_forced_switch_is_the_party_list_that_cannot_be_cancelled() {
let mut wram = Wram::overworld();

View file

@ -610,6 +610,327 @@ fn step_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
println!("```");
}
/// Ground truth for "this menu is accepting input", measured rather than read off a flag.
///
/// The emulator exports its own state, one directional pulse is issued into it, and
/// `wCurrentMenuItem` is read: `HandleMenuInput` moves the cursor on UP and DOWN before it even
/// looks at `wMenuWatchedKeys`, so a cursor that moves is a menu that is running its input loop and
/// a cursor that does not is a menu nobody is reading. The state goes straight back afterwards, so
/// the run this is measured inside is not perturbed by the measurement.
fn press_honoured(gb: &mut Emulator) -> bool {
let save = gb.export_state().expect("the emulator should export its own state");
let before = gb.read8(ram::wCurrentMenuItem);
let mask = if before < gb.read8(ram::wMaxMenuItem) {
flybrain_gb::buttons::DOWN
} else {
flybrain_gb::buttons::UP
};
// Released first, and that is not cosmetic. `JoypadLowSensitivity` acts on a key's *edge*, so a
// direction the fly is already holding when this pulse begins produces no press at all and the
// frame reads as refused for a reason that is the measurement's and not the cartridge's. The
// first survey of row 50 measured 187 such frames before this line existed.
for phase in 0..ACCEPT_PULSE {
gb.set_buttons(if (8..22).contains(&phase) { mask } else { 0 });
gb.run_frame().expect("a frame should complete");
}
let moved = gb.read8(ram::wCurrentMenuItem) != before;
gb.import_state(&save).expect("the emulator should take its own state back");
moved
}
/// Frames of the rollback pulse [`press_honoured`] issues: released, held, released.
const ACCEPT_PULSE: usize = 30;
/// Whether the cursor bytes say "the move list", which is all the seam read before row 50.
fn move_cursor_geometry(gb: &mut Emulator) -> bool {
gb.read8(ram::wTopMenuItemY) == 12 && gb.read8(ram::wTopMenuItemX) == 5
}
/// Every byte of WRAM and HRAM, as two sets of values per address.
///
/// The question row 50 asks is "which byte flips exactly when a press is honoured", and the honest
/// way to answer it is not to nominate candidates but to let every address answer: an address whose
/// values on accepting frames never once overlap its values on refusing frames *is* the reading,
/// and one that overlaps is not, however plausible its name.
struct Separator {
seen: BTreeMap<u16, [[u64; 4]; 2]>,
counts: [usize; 2],
}
impl Separator {
fn new() -> Self {
Self { seen: BTreeMap::new(), counts: [0, 0] }
}
fn observe(&mut self, gb: &mut Emulator, honoured: bool) {
let class = usize::from(honoured);
self.counts[class] += 1;
for address in (0xc000u16..0xe000).chain(0xff80u16..0xffff) {
let value = gb.read8(address);
let bits = self.seen.entry(address).or_insert([[0; 4]; 2]);
bits[class][usize::from(value) / 64] |= 1u64 << (u32::from(value) % 64);
}
}
/// The addresses whose two value sets never overlap, smallest sets first.
fn disjoint(&self) -> Vec<(u16, Vec<u8>, Vec<u8>)> {
let mut out: Vec<(u16, Vec<u8>, Vec<u8>)> = self
.seen
.iter()
.filter(|(_, bits)| {
(0..4).all(|word| bits[0][word] & bits[1][word] == 0)
&& bits[0].iter().any(|word| *word != 0)
&& bits[1].iter().any(|word| *word != 0)
})
.map(|(address, bits)| (*address, values(&bits[0]), values(&bits[1])))
.collect();
out.sort_by_key(|(address, refused, honoured)| {
(refused.len() + honoured.len(), *address)
});
out
}
}
/// A 256-bit set back as the byte values in it, capped so a report line stays a line.
fn values(bits: &[u64; 4]) -> Vec<u8> {
let mut out = Vec::new();
for value in 0..=255u16 {
if bits[usize::from(value) / 64] & (1u64 << (u32::from(value) % 64)) != 0 {
out.push(value as u8);
}
if out.len() >= 9 {
break;
}
}
out
}
/// What the seam makes of this battle frame, in the shape the pad is dealt from.
fn battle_reading(gb: &mut Emulator, adapter: &PokemonRedReward) -> Option<(String, bool, bool)> {
use flybrain_gb::pokemon_red::macros::state::BattleMenu;
let ledger = AdapterLedger(adapter);
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
let battle = flybrain_gb::pokemon_red::macros::state::GameState::battle(&mut poke)?;
let name = match battle.menu {
BattleMenu::None => "none".to_string(),
BattleMenu::Main { cursor } => format!("main[{cursor}]"),
BattleMenu::Moves { cursor: Some(slot), count } => format!("moves[{slot}/{count}]"),
BattleMenu::Moves { cursor: None, count } => format!("moves[?/{count}]"),
BattleMenu::Party { cursor } => format!("party[{cursor}]"),
BattleMenu::Bag { cursor, count } => format!("bag[{cursor}/{count}]"),
};
Some((name, battle.own_turn, battle.forced_switch))
}
/// Whether the move list's own box is on screen, by the two tiles only it draws.
///
/// `MoveSelectionMenu`'s regular menu is a `TextBoxBorder` at (4, 12) fourteen wide, with the
/// junction tile written over (10, 12) afterwards. A plain battle text box is the full width of the
/// screen, so (10, 12) is a horizontal run and (4, 13) is inside it; the top-level battle menu's
/// own box starts at column 8. Either mark alone is ambiguous; together they are the move list.
fn move_box_drawn(gb: &mut Emulator) -> bool {
let corner = gb.read8(ram::wTileMap + 12 * 20 + 10);
let wall = gb.read8(ram::wTileMap + 13 * 20 + 4);
matches!(corner, 0x79 | 0x7b | 0x7d | 0x7e) && wall == 0x7c
}
/// The whole screen as border tiles, the menu cursor and "some text", one row per line.
fn screen_rows(gb: &mut Emulator) -> String {
(0..18u16)
.map(|y| {
let row: String = (0..20u16)
.map(|x| match gb.read8(ram::wTileMap + y * 20 + x) {
0x7f => '.',
0x79 | 0x7b | 0x7d | 0x7e => '+',
0x7a => '-',
0x7c => '|',
0xed => '>',
_ => 'x',
})
.collect();
format!(" {y:>2} {row}")
})
.collect::<Vec<_>>()
.join("\n")
}
/// The tiles of the six rows a battle's bottom boxes are drawn in, as one line.
fn box_rows(gb: &mut Emulator) -> String {
(12..18u16)
.map(|y| {
(0..20u16)
.map(|x| match gb.read8(ram::wTileMap + y * 20 + x) {
0x7f => '.',
0x79 | 0x7b | 0x7d | 0x7e => '+',
0x7a => '-',
0x7c => '|',
0xed => '>',
_ => 'x',
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("/")
}
/// Row 50's survey: which reading says a battle menu is accepting input, and which only says it is
/// drawn.
///
/// `infra/docs/macros-traps.md` row 50: `MOVE n` reports `blocked` 890 times in 1,431 macros, every
/// one of them on a move list the seam could place a cursor in. Two readings fit that -- a list
/// that is up and busy, or cursor bytes that outlive the list they were written for -- and they are
/// told apart by pressing at it, so this presses at it: every battle frame is classified by whether
/// a real directional press moves the cursor, and every byte of WRAM and HRAM is asked whether it
/// separates the two classes.
fn accept_survey(
gb: &mut Emulator,
adapter: &mut PokemonRedReward,
ms: &mut f64,
layer: &mut flysim::macros::MacroLayer,
decoder: &mut PopulationDecoder,
channels: &[String],
hold_ms: f64,
) {
let budget = env_usize("FLY_PROBE_FRAMES", 200_000);
let samples = env_usize("FLY_PROBE_SAMPLES", 3_000);
let trace = env_usize("FLY_PROBE_TRACE", 160);
let mut next_burst = *ms;
let mut burst = 0usize;
let mut separators: BTreeMap<&'static str, Separator> = BTreeMap::new();
let mut tally: BTreeMap<(String, bool), [usize; 2]> = BTreeMap::new();
let mut shown: BTreeMap<(String, bool), String> = BTreeMap::new();
let mut traced = 0usize;
let mut tested = 0usize;
// [box not drawn, box drawn] x [press refused, press honoured], over every frame whose cursor
// bytes say "the move list" -- which is the whole of what the seam read before row 50.
let mut readings = [[0usize; 2]; 2];
println!("\n## Row 50: every battle frame, pressed at\n");
println!("```");
println!(
"frame seam turn honoured ccyx/cur/max/keys d125 cf94 cd6c cfc4 boxes"
);
for _ in 0..budget {
let bursting = *ms < next_burst + BURST_MS;
let hot = bursting.then(|| channels[(burst / HOLDS_PER_SLOT) % channels.len()].as_str());
if *ms >= next_burst + hold_ms {
next_burst = *ms;
burst += 1;
}
let bound = layer.bound_channels();
let active = decoder.decode_bound(&rates(hot), *ms, false, None, Some(&bound));
let mask = {
let ledger = AdapterLedger(adapter);
layer.decide(&active, 0, *ms, gb, &ledger).mask
};
gb.set_buttons(mask as u8);
gb.run_frame().expect("a frame should complete");
*ms += MS_PER_FRAME;
adapter.sample(gb, *ms);
{
let ledger = AdapterLedger(adapter);
let _ = layer.observe(gb, &ledger, *ms);
}
let Some((name, own_turn, forced)) = battle_reading(gb, adapter) else { continue };
let geom = move_cursor_geometry(gb);
if (name == "none" && !geom) || forced {
continue;
}
if tested >= samples {
break;
}
tested += 1;
let honoured = press_honoured(gb);
let drawn = move_box_drawn(gb);
if geom {
readings[usize::from(drawn)][usize::from(honoured)] += 1;
}
let key = (format!("{name} drawn={drawn}"), own_turn);
tally.entry(key.clone()).or_insert([0, 0])[usize::from(honoured)] += 1;
let kind = if name.starts_with("moves") {
"the move list"
} else if name.starts_with("main") {
"the top-level menu"
} else if name.starts_with("bag") {
"the bag"
} else {
"the party list"
};
separators.entry(kind).or_insert_with(Separator::new).observe(gb, honoured);
let boxes = box_rows(gb);
shown.entry((name.clone(), honoured)).or_insert_with(|| screen_rows(gb));
if traced < trace {
traced += 1;
println!(
"{tested:>5} {name:<18} {:<4} {:<8} {:>2},{:>2},{:>2},{:>2},{:#04x} \
{:02x} {:02x} {:02x} {:02x} {boxes}",
own_turn,
honoured,
gb.read8(ram::wTopMenuItemY),
gb.read8(ram::wTopMenuItemX),
gb.read8(ram::wCurrentMenuItem),
gb.read8(ram::wMaxMenuItem),
gb.read8(ram::wMenuWatchedKeys),
gb.read8(ram::wTextBoxID),
gb.read8(ram::wListMenuID),
gb.read8(ram::wNumMovesMinusOne),
gb.read8(ram::wFontLoaded),
);
}
}
println!("```");
let (stale, live) = (readings[0], readings[1]);
println!("\n## The move list, by which reading says it is up\n");
println!("| the reading | press refused | press honoured |");
println!("| --- | ---: | ---: |");
println!(
"| the cursor bytes alone (what the seam read before row 50) | {} | {} |",
stale[0] + live[0],
stale[1] + live[1],
);
println!("| the cursor bytes **and** the box on screen | {} | {} |", live[0], live[1]);
println!("| the cursor bytes with no box drawn | {} | {} |", stale[0], stale[1]);
println!("\n## What the seam reads against what the cartridge honours\n");
println!("| the seam's menu | `own_turn` | press refused | press honoured |");
println!("| --- | --- | ---: | ---: |");
for ((name, own_turn), counts) in &tally {
println!("| `{name}` | {own_turn} | {} | {} |", counts[0], counts[1]);
}
for (kind, separator) in &separators {
println!(
"\n## The bytes that separate a honoured press from a refused one, on {kind}\n\n\
{} refusing frames, {} accepting.\n",
separator.counts[0], separator.counts[1]
);
let disjoint = separator.disjoint();
if disjoint.is_empty() {
println!("No single byte of WRAM or HRAM separates the two classes here.");
continue;
}
println!("| address | when refused | when honoured |");
println!("| ---: | --- | --- |");
for (address, refused, honoured) in disjoint.iter().take(40) {
println!(
"| `{address:#06x}` | {} | {} |",
refused.iter().map(|v| format!("{v:02x}")).collect::<Vec<_>>().join(" "),
honoured.iter().map(|v| format!("{v:02x}")).collect::<Vec<_>>().join(" "),
);
}
println!("\n{} addresses separate in all.", disjoint.len());
}
println!("\n## One screen of each class\n\n```");
for ((name, honoured), boxes) in &shown {
println!("{name} honoured={honoured}\n{boxes}");
}
println!("```");
}
fn main() {
let Some(path) = std::env::var_os("FLY_ROM") else {
println!("FLY_ROM is not set, so there is nothing to probe.");
@ -662,6 +983,22 @@ fn main() {
return;
}
// Row 50's survey: drive real battles and press at every battle menu the seam reads, to tell a
// list that is accepting input from cursor bytes that outlived their list.
if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "accept") {
let channels: Vec<String> = channels.iter().map(|name| (*name).to_string()).collect();
accept_survey(
&mut gb,
&mut adapter,
&mut ms,
&mut layer,
&mut decoder,
&channels,
hold_ms,
);
return;
}
let budget = env_usize("FLY_PROBE_FRAMES", 200_000);
let stuck_after = env_usize("FLY_PROBE_STUCK", 600);
let mut next_burst = ms;

View file

@ -219,6 +219,9 @@ struct Run {
/// number of macros rather than on however many holds the 2-cycle takes to fall out of.
macros_this_battle: u32,
worst_battle_macros: u32,
/// What every battle that *ended* cost in macros, so the run can be asked for a median rather
/// than only for its worst (row 50).
battle_costs: Vec<u32>,
battles_entered: u32,
battles_ended: u32,
was_in_battle: bool,
@ -396,6 +399,7 @@ impl Run {
last_battle_start: None,
macros_this_battle: 0,
worst_battle_macros: 0,
battle_costs: Vec::new(),
battles_entered: 0,
battles_ended: 0,
was_in_battle: false,
@ -507,6 +511,7 @@ impl Run {
last_battle_start: None,
macros_this_battle: 0,
worst_battle_macros: 0,
battle_costs: Vec::new(),
battles_entered: 0,
battles_ended: 0,
was_in_battle: false,
@ -904,6 +909,7 @@ impl Run {
self.battles_ended += 1;
self.worst_battle_macros =
self.worst_battle_macros.max(self.macros_this_battle);
self.battle_costs.push(self.macros_this_battle);
self.macros_this_battle = 0;
}
_ => {}
@ -1337,6 +1343,50 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() {
run.worst_battle_macros,
run.longest_next_back_alternation
);
// **Row 50**: a `MOVE n` that starts on an accepting move list finishes its cursor walk.
//
// What was measured on v0.4.6 from this checkpoint: `MOVE n` reported `blocked` **890 times in
// 1,431 macros**, and `MOVE 4` 222 of 224 -- every one of them on a frame whose cursor bytes
// said "the move list" while no list was on screen. `MoveSelectionMenu` writes
// `wTopMenuItemY` 12 and `wTopMenuItemX` 5 and nothing ever clears them, so the whole of a
// turn's text, animation and reply read back an open list with a placeable cursor; the pad
// dealt the four move buttons on it and the cursor step pressed at nothing until its budget
// ran out. Surveyed with one rollback pulse per battle frame
// (`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`): by the cursor bytes alone a real
// directional press was honoured on 264 frames of 3,102, and by the cursor bytes *and* the box
// on screen on 231 of 231.
let move_starts: u32 = run
.started
.iter()
.filter(|(name, _)| name.starts_with("MOVE "))
.map(|(_, n)| *n)
.sum();
let move_blocked: u32 = run
.blocked
.iter()
.filter(|(name, _)| name.starts_with("MOVE "))
.map(|(_, n)| *n)
.sum();
eprintln!("`MOVE n`: {move_starts} starts, {move_blocked} blocked");
assert!(move_starts > 0, "no move button ever started: {:?}", run.started);
assert!(
move_blocked * 20 < move_starts,
"`MOVE n` reported blocked on {move_blocked} of {move_starts} starts, which is row 50"
);
// And what that buys, which is the thing the audience sees: a battle that is over in a
// sensible number of presses rather than one that spends its turns pressing at text. The
// worst battle is a tail; the median is the run.
let mut costs = run.battle_costs.clone();
costs.sort_unstable();
let median = costs.get(costs.len() / 2).copied().unwrap_or(0);
eprintln!("battle cost in macros: {costs:?}, median {median}");
assert!(
median > 0 && median < 300,
"the median battle cost {median} macros over {} that ended",
run.battles_ended
);
// `BACK` is still pressed, and that is the contract rather than a residual: over the move list
// and over a one-Pokemon party list it is one of the two answers a list has, and where it
// leads is a menu with the move buttons on it (row 34). Its share is *reported* -- under this