diff --git a/docs/design/macros-wram.md b/docs/design/macros-wram.md index 1798322..328e08a 100644 --- a/docs/design/macros-wram.md +++ b/docs/design/macros-wram.md @@ -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. 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..c7fbf3a 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 @@ -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. diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs index 2bf4751..316cbb0 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs @@ -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); diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index 0bd3e43..007b87a 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -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 { // 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 { // 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). /// diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs index 463b488..61e5981 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs @@ -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(); diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index 7282754..dd7fb5c 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -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, + 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, Vec)> { + let mut out: Vec<(u16, Vec, Vec)> = 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 { + 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::>() + .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::() + }) + .collect::>() + .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::>().join(" "), + honoured.iter().map(|v| format!("{v:02x}")).collect::>().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 = 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; diff --git a/services/flysim/crates/flysim/tests/rom_macros_mode.rs b/services/flysim/crates/flysim/tests/rom_macros_mode.rs index 4cfacff..a8022d5 100644 --- a/services/flysim/crates/flysim/tests/rom_macros_mode.rs +++ b/services/flysim/crates/flysim/tests/rom_macros_mode.rs @@ -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, 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