From 6588897ab3312b9e972cd40cd61b59aa29632b98 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 18:43:05 +0000 Subject: [PATCH] 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. --- docs/design/macros-wram.md | 71 +++++++++++++++++++++++++++++++++++++- docs/design/macros.md | 49 ++++++++++++++++++++++++-- infra/docs/macros-traps.md | 2 +- 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/docs/design/macros-wram.md b/docs/design/macros-wram.md index 3b55030..5cf5efb 100644 --- a/docs/design/macros-wram.md +++ b/docs/design/macros-wram.md @@ -128,7 +128,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 | @@ -714,3 +714,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. diff --git a/docs/design/macros.md b/docs/design/macros.md index 81535b7..3c2c559 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -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 | diff --git a/infra/docs/macros-traps.md b/infra/docs/macros-traps.md index 8043bf7..90a2835 100644 --- a/infra/docs/macros-traps.md +++ b/infra/docs/macros-traps.md @@ -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