Merge fix/loop-row50: a battle turn is the fly's only while the move list is drawn
This commit is contained in:
commit
349a1fd28c
8 changed files with 819 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue