diff --git a/docs/design/macros-wram.md b/docs/design/macros-wram.md index 5d5918f..0c721a3 100644 --- a/docs/design/macros-wram.md +++ b/docs/design/macros-wram.md @@ -145,6 +145,7 @@ forces. A battle that is neither — text, an animation, the turn resolving — | a submenu | the weakest rule here, and the reason `Unknown` exists: `wListMenuID` is the bag or an elevator list, or the party list geometry outside a battle. A submenu none of those catch reads as `Unknown`, never as `Overworld`. | trace | | mart | `wTextBoxID` = `BUY_SELL_QUIT_MENU` (`$15`) for the BUY / SELL / QUIT choice, and `engine/events/pokemart.asm:17` is its only user in the game; the buy list is `wListMenuID` = `$02` and the sell list is the bag's own `$03`, recognised only while the mart's template is still the last one drawn. | trace | | PC | `wMiscFlags` bit 3, above. | trace | +| a two-option YES/NO box | **new 2026-09-22** (`docs/design/macros.md` section 12.12). `wFontLoaded` bit 0, plus the border `DisplayTwoOptionMenu` draws at (11, 6)-(19, 11), plus the shared cursor parked at `wTopMenuItemY` 8, `wTopMenuItemX` 12 with `wMaxMenuItem` 1 and `wMenuWatchedKeys` = A\|B. **Both halves are load-bearing**: the cursor bytes survive the box closing, so all forty-six frames of a Pokémon Center nurse's conversation carry that geometry while the box is drawn on exactly one of them. It does **not** answer "is a choice open" in general — Red places a two-option menu where the script asking for it says, and a prompt drawn elsewhere reads `false`. | ROM (the rung-10 Pokémon Center checkpoint, surveyed one raw A pulse at a time: `examples/scene_probe.rs`, `FLY_PROBE_CATCH=nurse`) | ### Money and bag @@ -474,6 +475,13 @@ Two facts about the emulator came out of building these and are worth keeping: which is two battles away from anything a fixed-seed walk reaches quickly. The trace is built from `ChooseNextMon` and `PartyMenuInit`, and the distinguishing byte (`wPartyMenuTypeOrMessageID` = `BATTLE_PARTY_MENU`) is asserted both ways. +- **`yes_no_prompt` is "*this* two-option box is drawn", not "a choice is open".** The claim in + earlier revisions of this file — that pokered has no observable for a choice — stands for the + general question and is now narrowed rather than withdrawn: the box the Pokémon Center nurse's + offer is drawn in has been surveyed on the cartridge and is readable, and every other + two-option menu in the game is not, because `DisplayTwoOptionMenu` takes its coordinates from + the script that calls it. A prompt this reading misses is a plain dialog, which is the pad it + had before the reading existed. - **`text_box().waiting` is "the dialogue box is drawn", not "the game wants a button".** Pokered has no flag for the second thing; A is the right press either way, so the distinction has no consequence for the palette, but it is not what the field's name might suggest. diff --git a/docs/design/macros.md b/docs/design/macros.md index a7d5765..f830c78 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -1003,6 +1003,83 @@ cursor inverted and it needs a WRAM reading rather than a pad change. The decoder, the reward catalog, the adapter version, the roles and the compatibility string are untouched. +### 12.12 The nurse's box is a ring, and `YES` and `NEXT` are one press (2026-09-22, rung 10, row 41) + +Rank 10 (PEWTER CITY), the fly in the Pewter Pokémon Center, and since the v0.4.4 restart the +macro starts were `YES` **2,142**, `TALK` 107, `GO FRONTIER` 26, `BACK` 24, with the event log's +tail `YES start/done` for ever. This is **row 41**, first measured in the rung-9 trap hunt (`YES` +1,278 of 1,295 starts on one tile of map `0x3a`) and named by 12.11 as the next trap. Reproduced +from the live checkpoint with the real cartridge and surveyed press by press +(`examples/scene_probe.rs`, `FLY_PROBE_CATCH=nurse`); `infra/docs/macros-traps.md` has the survey +whole. + +- **The conversation is a ring of forty-six A presses, and the box is a *choice* on one of them.** + Welcome, "We heal your POKéMON back to perfect health!", the **YES/NO box**, "OK. We'll need your + POKéMON.", the machine, "Your POKéMON are fighting fit!", "We hope to see you again!", the box + closes for a single frame, and the next A press at a nurse two tiles away over her counter opens + the whole thing again. The party read **70/70 and healthy** on every frame of it, so not one of + those presses changed anything. That is section 12.2's rule at conversation scale: a macro whose + precondition is already satisfied where the fly stands is a trap. +- **The brief allowed three readings and the survey settles it as none of them.** The box open at + the checkpoint is not the prompt, it is the closing line, and `YES` there is an A press on plain + text -- forty-five of the forty-six frames are like that. `HEAL` is not in the loop at all: its + precondition already reads the live party, `party_needs_rest` answers `false`, and the button was + off the pad the whole time. And `HEAL`'s own wait is a *read* of the party, not a loop waiting + for a timer, so it cannot spin on a party that is already full. What was in the loop was the + **dialog pad**, dealt unconditionally, and `TALK` to get back into it. +- **On a box that is a choice, `NEXT` is `YES` under another name.** An A press at a two-option menu + confirms the option the cursor is on, and the cursor opens on YES, so `NEXT` and `YES` are one + press with two channels -- 12.10's rule about a pair of buttons, in a dialog rather than a + battle. `NEXT` is off any pad dealt for a readable YES/NO prompt; the pad is the box's own two + answers. +- **At the nurse's prompt the answer that changes something is the only one bound.** Hurt or + statused, `YES`; full and healthy, `NO`. Section 13 has read that byte for `HEAL` since the + errands existed; this is the same byte read for the box `HEAL` opens. Knowledge inside the macro + as a precondition, and nothing ranks the two: one of them simply is not there. +- **`TALK` is not offered at a nurse the party has no use for.** The nurse is an object with a + purpose rather than a person to chat with, and she is the one person in Red whose conversation + has a precondition the cartridge publishes. This is the door into the ring, and closing it is + what makes the rest of the section a backstop rather than the fix. Nobody else is narrowed: an + ordinary villager is `TALK`'s whatever the party reads. +- **The nurse enters the *talked* ledger on a completed heal or a declined prompt.** A completed + `HEAL` has had the conversation with its own presses, so `TALK` has nothing left to open, and the + reached window would otherwise expire in ten brain minutes and offer the ring again. A declined + prompt is 12.4's rule deliberately inverted, for one person: "the thing it said no to is still on + offer" is true of a villager with something to say and false of a service the party does not + need -- and the pad only ever offers `NO` at her prompt when the party is already full. +- **`TALK`'s precondition and `TALK`'s ledger entry were two different questions, which is why she + was never retired.** The precondition reaches over a counter, because + `IsSpriteOrSignInFrontOfPlayer` does; the entry was read one tile ahead. So `TALK` was *bound* at + the counter and *recorded* nothing at all, 107 times. A precondition and the ledger that answers + it have to be the same reading, and they are now. +- **The general rule: an answer that brings the same prompt straight back is excluded for the + blocked window.** Same map, same tile, same readable prompt, within one hold of the answer + finishing -- nothing moved and nothing was settled, so the press did nothing the next press will + not undo. It is 12.1's ledger doing for an answer what it does for a walk, with the same ten + brain minutes, keyed `TargetKey::Answer { at, yes }`. The exclusion *narrows* a pad and never + empties one: with both answers excluded both come back, because a box nothing can answer is a + screen nothing can leave. +- **What the reading rests on, and what it does not claim.** `docs/design/macros-wram.md` said + outright that there is no "a choice is open" flag, and there is not -- so `yes_no_prompt` is the + construction `text_box`'s `waiting` already makes: `wFontLoaded` plus the figure the game draws, + a border at (11, 6)-(19, 11) with the shared cursor parked at row 8, column 12, one item below + the first, watching A and B. **Both halves are needed**: the cursor bytes are not cleared when + the box closes, so all forty-six of the nurse's frames carry that geometry while the box itself + is drawn on exactly one. Red places a two-option menu where the script asking for it says, so a + prompt drawn somewhere else reads `false` and its dialog keeps the pad it has always had. That is + a named limit, not a gap being papered over. + +**What the harness holds.** Unit: `HEAL` is off a full party's pad, including the rung-10 party's +own numbers; `TALK` is off a rested nurse's pad and on a hurt one's; the nurse's prompt deals one +answer and a plain box still deals three; a readable prompt that is not hers deals both answers and +no `NEXT`; an answer whose prompt reopens is excluded and an answer that settled the box is not; a +completed heal and a declined prompt both write the nurse into the talked ledger. ROM-gated from +the live checkpoint: the fly leaves map `0x3a`, `YES` starts stay under five, `NEXT` is on no pad +while a prompt is readable, and `TALK` is on no pad at a rested nurse. + +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.") @@ -1025,7 +1102,9 @@ Knowledge inside macros, never in the choice; the pad still lists buttons and th marks nothing visited; the errand was paid on entering. - **Center scene.** `HEAL` is a macro: walk to the counter, face the nurse, talk, answer YES, wait for the heal animation to end (read the party HP back to full), close the box. On the - pad only when at least one party member is not at full HP or has a status. `LEAVE` walks out. + pad only when at least one party member is not at full HP or has a status — verified against the + live party on the cartridge (**12.12**), which is also what takes `TALK` at the nurse off the pad + and what picks the one answer her YES/NO box is dealt. `LEAVE` walks out. - **Ladder.** Unchanged; no reward for shopping or healing (rewards are the adapter's, untouched). - **Screen.** Two new channel tags; the cells and the MACROS rate row take them as they come. @@ -1075,8 +1154,9 @@ observe is not a precondition, it is a guess. | Overworld, inside a Pokémon Center | the indoor pad plus HEAL | new. A centre is a sub-state of the overworld, not a `Scene`: pokered has no "a Pokémon Center is open" byte, so the only honest observable is the map id, and a new `Scene` would be a new `game.scene` on the wire | | Overworld, inside a mart | the indoor pad | the counter is a `Shop`; the mart's *floor* is an ordinary interior, with the one exception below | | Overworld, inside a mart or a centre, counter unfaced | GO SHOP or GO HEAL, TALK when facing the counter | new, and it is the one place a pad is deliberately *narrow*. The errand is paid on entering and never offered again, so a walk that leaves the building spends the one visit the area gets — measured: the fly reached the mart in 1.7 brain minutes and `GO OBJECTIVE` walked it straight back out over the doormat. While the counter is unfaced nothing on the pad leaves (row 34b) | -| Overworld, inside a mart or a centre, counter faced | the indoor pad, plus HEAL in a centre | the suppression is released by facing the counter, by talking to it, or by a walk to it failing | -| Dialog | NEXT, YES, NO | unchanged. There is no "a choice is open" flag (`macros-wram.md`), and A and B both advance a plain box, so all three are dealt for every box — what it buys is the fly being able to answer *no* | +| Overworld, inside a mart or a centre, counter faced | the indoor pad, plus HEAL in a centre | the suppression is released by facing the counter, by talking to it, or by a walk to it failing. Since **12.12** `TALK` is not on it at a *nurse* the party has no use for: her conversation is a service whose need the cartridge publishes, and a ring of text that ends where it began is section 12.2's trap | +| Dialog, a plain text box | NEXT, YES, NO | A and B both advance a plain box, so all three are dealt for one — what it buys is the fly being able to answer *no*. Forty-five of the nurse's forty-six frames are this row (**12.12**) | +| Dialog, a readable YES/NO box | YES, NO — or **one of them** at a Pokémon Center's nurse | **new, 12.12.** `NEXT` is off it: an A press at a two-option menu confirms the option the cursor is on, which is what `YES` is, so the two are one press under two names (12.10). At the nurse's own prompt the bound answer is the one that changes something — `YES` with a hurt or statused party, `NO` with a full one. An answer whose prompt comes straight back is excluded for the blocked window, and the exclusion never empties the pad | | Menu (the start menu) | CLOSE, CONFIRM, BACK | unchanged as a *scene*, and since **12.11** nothing on any other pad opens it: the fly reaches it with the **raw** START button, which still reaches the cartridge in macros mode, and moves its cursor with the raw D-pad. A SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for -- which is precisely why `MENU` had nothing behind it | | 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 | @@ -1120,6 +1200,7 @@ measured where it cannot. | cause | closed by | | --- | --- | +| a dialog whose one answer the reopen ledger excludes | **not a cause** (12.12): the exclusion narrows a pad and never empties one, so a box with both answers excluded is dealt both again. A box nothing can answer is a screen nothing can leave | | a scene that binds nothing at all | the table above: every playable scene but the overworld has at least one unconditional button (`NEXT` in a dialog and in a battle, `CLOSE` in a menu, `LEAVE` in a shop and a PC). The overworld's was `MENU`, and **12.11** took it off rather than keep a button whose only effect is a screen its own scene closes again; what stands in its place is the row below | | an overworld map with no way out at all | **not closed, and named**: with `MENU` gone this is a genuinely empty pad. No map in Red is that -- an interior has its front door or its staircase, an outdoor map has its connections -- so it is asserted as a residual in the pad-empty sweep rather than covered, and `game.padEmptyMs` reports it | | an *indoors* overworld where every ledger excludes everything and the blocked window is resting the one door | **fixed** (12.11): `ways`' last resort is a room's too, not only `GO ROUTE`'s. With nothing else on this map worth walking to, the exits of that kind come back ignoring the blocked window, the one toward the objective preferred. This is the rung-10 museum: its only way out is a *passage*, so tier 3 was built out of the excluded list and emptied with it | diff --git a/infra/docs/macros-traps.md b/infra/docs/macros-traps.md index b6969de..95bc117 100644 --- a/infra/docs/macros-traps.md +++ b/infra/docs/macros-traps.md @@ -1767,3 +1767,177 @@ and it now has a checkpoint of its own. - `infra/tests/lint.sh`: all checks passed, de-PII guard included. - `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1, v0.4.2 and v0.4.3. Decoder, reward catalog, adapter version and roles untouched. + +## 2026-09-22, row 41 worked: the nurse's box is a ring, and `YES` and `NEXT` are one press + +Named in the rung-9 review and again by 12.11 as the next trap, and flagged live by the watchdog +(v0.4.4, macros mode) within the hour: rank 10 (PEWTER CITY), the fly in the **Pewter Pokémon +Center**, and since the 09:39 restart the macro starts were `YES` **2,142**, `TALK` 107, +`GO FRONTIER` 26, `BACK` 24, with the event log's tail + +``` +YES start, YES done, YES start, YES done, ... +``` + +for ever. `docs/design/macros.md` section 12.12 is the design; this is the reproduction, the +survey, and the before/after. + +### Where the fly was standing + +`examples/scene_probe.rs` from the live checkpoint: + +- map `0x3a`, **14x8**, the player at **(3, 3) facing up**, two warps at (3, 7) and (4, 7) out to + Pewter City (map 2); +- two sprites: picture `0x29` (`SPRITE_NURSE`) at **(3, 1)** and picture `0x38` at (1, 3) -- so the + nurse is **two tiles away**, behind her counter, which is the reach + `IsSpriteOrSignInFrontOfPlayer` doubles over a counter tile; +- the scene reads `Dialog` (`font=0x01`, the full-width border drawn, `textbox=0x01`) and the pad is + **`NEXT`, `YES`, `NO`**; +- the objective is map `0x36` -- the Pewter gym, rung 11's BOULDER BADGE -- and `next_hop` from here + answers map 2, so the road out is known; +- every overworld candidate list is empty (`ways(Exit)` `[]` because both warps are `exit_visited`, + `objective_goals` `[]`, `untalked_people` `[]`, `frontier_aims` `[]`), which is why `TALK` and the + dialog were the pad. + +### The survey: the conversation, one raw A pulse at a time + +`FLY_PROBE_CATCH=nurse` leaves the box with B and then pulses A, printing `scene`, the text box's +two halves, the two-option menu's cursor bytes, the top-right corner's frame tiles and both lines of +decoded text for every state the conversation passes through. **The party first**, because the +loop's premise is that it is already full: + +``` +- slot 0 species 0x09 level 24 hp 70/70 status Healthy +- `party_needs_rest` = false, `party_rested` = true +``` + +Then the ring, elided to the states that matter (`A#n` is the pulse): + +``` +at the checkpoint Dialog cursor=(8,12,0,1,0x03) corner=empty | POKeMON back to | perfect health! v +A#0 Dialog cursor=(8,12,1,1,0x03) corner=empty | | +A#4 Dialog cursor=(8,12,1,1,0x03) corner=empty | Welcome to our | POKeMON CENTER! v +A#9 Dialog cursor=(8,12,1,1,0x03) corner=empty | We heal your | POKeMON back to v +A#12 Dialog cursor=(8,12,1,1,0x03) corner=empty | POKeMON back to | perfect health! v +A#13 Dialog cursor=(8,12,0,1,0x03) corner=BOX | POKeMON back to | perfect health! +A#15 Dialog cursor=(8,12,0,1,0x03) corner=empty | OK. We'll need | your POKeMON. +A#33 Dialog cursor=(8,12,0,1,0x03) corner=empty | Thank you! | Your POKeMON are v +A#38 Dialog cursor=(8,12,0,1,0x03) corner=empty | Your POKeMON are | fighting fit! v +A#42 Dialog cursor=(8,12,0,1,0x03) corner=empty | We hope to see | you again! +A#45 Overworld open=false waiting=false +A#46 Dialog ... the whole thing again, and again, and again +``` + +Five things that settles: + +1. **The cycle is forty-six A presses and the box is a *choice* on exactly one of them** (A#13, + then A#59, A#105, A#151...). The other forty-five are plain text, where `NEXT` and `YES` are the + same A press with two channel names and `NO`'s B advances a plain box too. +2. **The box open at the checkpoint is the closing line, not the prompt.** So the first of the + brief's three candidate readings is out: the fly was not sitting on a YES/NO box re-offering + itself; it was walking a ring of text. +3. **`HEAL` is not in the loop at all.** Its precondition reads the live party and + `party_needs_rest` answers `false`, so the button was off the pad throughout -- and `HEAL`'s + middle step is a *read* of the party (`Step::Rested`), not a timer, so it cannot spin on a party + that is already full either. The third candidate reading is out too. +4. **The box closes for a single frame and the next A press reopens it**, because the fly is still + standing at (3, 3) facing the nurse over the counter. That is the ring's own door, and `TALK` is + what opens it from the overworld side. +5. **The two-option menu's cursor bytes are stale on all forty-six frames** + (`wTopMenuItemY` 8, `wTopMenuItemX` 12, `wMaxMenuItem` 1, `wMenuWatchedKeys` `$03`) while the + box itself is drawn on one. So "is a choice open" needs the **drawn border** beside them, at + (11, 6)-(19, 11) -- which is the same construction `text_box().waiting` already makes for the + dialogue box. `docs/design/macros-wram.md`'s table carries the reading and its limit. + +### Why `TALK` fired 107 times and retired nothing + +`TALK`'s precondition is `palette::facing_untalked`, which reaches **over a counter** because the +cartridge does. Its talked-ledger entry came from `executor::talk_target`, which looked **one tile +ahead** -- at the counter tile, which holds nothing. So the macro was bound by one reading and +recorded by a shorter one, and the ledger never learned that the nurse had been talked to: the +button came back every hold for ever. A precondition and the ledger that answers it have to be the +same question, and that was the second half of the trap. + +| # | trap | trigger | test | fix, or why it is left | +| ---: | --- | --- | --- | --- | +| 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 | + +### The ROM-gated run, from the live checkpoint + +`FLY_CENTER_CHECKPOINT`, macros mode, the stub rotation, 120,000 frames (33.5 brain minutes): + +| measure | value | +| --- | --- | +| the checkpoint's map | `0x3a`, party 70/70 and healthy | +| left map `0x3a` on | **frame 326** | +| macros spent in the centre | **6**: `GO OBJECTIVE` 1, `NEXT` 2, `NO` 1, `YES` 2 | +| route | `0x3a` -> Pewter City (2) -> **the Pewter gym (0x36)**, which is the objective | +| `YES` starts in the centre | **2**, against 1,423 in the before hunt from the same room | +| `NEXT` on a pad with a readable prompt open | **0** | +| `TALK` on a pad at a rested nurse | **0** | +| dialog frames / of those, a readable prompt | 19,409 / **63** | + +The two `YES` presses are the checkpoint's own half-finished conversation: it resumes inside a plain +text box, whose pad is `NEXT`, `YES`, `NO` by contract, and two A presses closed the boxes that were +left. Then the prompt came up, the party was full, the pad was `NO` alone, and the fly was out. The +`MOVE n` blocked counts in the table above are the gym's battles and are row 50, unchanged. + +### The trap hunt, before and after + +Twenty brain minutes, seed 20260917, 4 sweep threads, the same connectome and the same cartridge, +from the release container's own rung-10 Pokémon Center checkpoint, **driven by the brain**. + +| measure | before (v0.4.4) | after | +| --- | ---: | ---: | +| distinct (map, tile) | **1** | **437** | +| windows flagged | 73/73 | **54/73** | +| macros started | 1494 | 1165 | +| `YES` starts | **1423** | 108 | +| `YES` presses in a text box on map `0x3a` | **1424** | **4** | +| `TALK` starts | 68 | 11 | +| `GO HEAL` starts | 2 | 0 | +| frames in `dialog` | **69,469** | 5,492 | +| frames in `overworld` | 2,204 | **44,817** | +| frames in `battle` | 0 | 10,518 | +| worst single text box | 69,469 frames, map `0x3a` (3, 3) | 2,318 frames, map `0x02` (16, 17) | +| the map at the end | none -- a text box over it | Pewter City, `40x36`, 879 walkable | + +**The before arm is row 41 whole**: one tile for twenty brain minutes, every window flagged, and +`YES x21` in every one of them. The after arm leaves the centre in the first window, walks Pewter +City, finds the gym and fights in it -- `YES` on map `0x3a` goes **1424 → 4**, and the four are the +checkpoint's own half-finished conversation plus the one `NO` that answered the prompt. + +### Residuals, named rather than worked around + +- **54 of 73 windows still flag, and they are a different trap on ground the before arm never + reached.** In Pewter City and the gym the sequences are `GO FRONTIER` runs of up to 112, and + `GO OBJECTIVE, BACK, GO FRONTIER, GO FRONTIER` and `GO OUT, BACK, GO FRONTIER, GO FRONTIER` at + x37, with `BACK` pressed 189 times in a text box on map `0x02`. A window with 150 distinct tiles + in it flags on the *repeat* rule and not the tile rule, which is the detector working: the fly is + covering ground and still cycling four macros. That is the next brief, and it is a loop behind + the loop in front of it exactly as rows 1, 2b, 23, 24 and 41 were. +- **`MOVE n` still reports `blocked` with the move list drawn and its cursor placeable but not + accepting input** (row 50): 24 of 27 `MOVE 2..4` in the hunt, 222 of 224 `MOVE 4` in the ROM run, + mean 223-238 frames, which is the cursor step spending its whole wait. Unchanged from v0.4.3 and + v0.4.4 and needing a WRAM reading rather than a pad change. +- **`yes_no_prompt` reads one box and says so.** Red places a two-option menu where the script + asking for it says; the nurse's is at (11, 6)-(19, 11) and surveyed, and a prompt drawn elsewhere + reads `false` and keeps the pad it had. The reopen exclusion therefore only fires on a prompt this + crate can read, which is the honest half of a general rule. +- **The declined-prompt talked entry is 12.4 inverted for one person.** It is justified by the pad: + `NO` is only ever offered at her prompt when the party is already full. A future macro that + declined a heal for some other reason would want that revisited. + +### Gates + +- `cargo test --workspace` with `FLY_ROM` set: green except + `flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`, + which fails identically on v0.4.4 on this box (a debug build of the service does not finish + booting inside the test's window here). Pre-existing and unrelated to the macro layer. +- `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 v0.4.1 + through v0.4.4. Decoder, reward catalog, adapter version and roles untouched. diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs index aedded3..01d556e 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs @@ -286,6 +286,15 @@ pub enum TargetKey { Thing(TalkTarget), /// A tile of the map to stand on. Tile(Tile), + /// One answer to the YES/NO box at a tile: the key of the reopened-prompt exclusion + /// (`docs/design/macros.md` section 12.12). + /// + /// Not a walk's target -- nothing is aimed at it -- but the same ledger and the same window, + /// because it is the same fact: an answer that changed nothing is an answer not worth making + /// again from this tile for a while. Keyed by the tile rather than by the person, because what + /// the box belongs to is whatever the fly is standing in front of, and the box is the only + /// thing on screen while it is open. + Answer { at: Tile, yes: bool }, } /// Which list the shared cursor belongs to right now. @@ -456,6 +465,20 @@ pub trait MacroState: GameState { false } + /// Whether the box on screen is the two-option YES/NO prompt rather than a plain text box. + /// + /// `pokemon_red::state::yes_no_prompt`: the border `DisplayTwoOptionMenu` draws plus the + /// cursor it parks inside it, surveyed on the cartridge (`docs/design/macros.md` section + /// 12.12). It is what tells the *one* frame of the nurse's conversation that is a choice from + /// the forty-five that are text, and the pad is dealt differently for it -- on a choice, an A + /// press *is* `YES`, so `NEXT` is the same press under another name (12.10). + /// + /// The default is `false`: a state that cannot answer has no choice open, which leaves the + /// dialog pad exactly what it has always been. + fn yes_no_prompt(&mut self) -> bool { + false + } + /// Whether `target` is inside its blocked-target exclusion window on the map that is loaded. /// /// Written when `GO ITEM`, `GO NPC`, `GO OBJECTIVE`, `GO OUT`, `GO WARP`, `GO ROUTE` or diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs index 975e67b..4407ace 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/executor.rs @@ -31,7 +31,9 @@ use super::cartridge::{ use super::geography::Amenity; use super::palette::{ MacroId, MacroKind, Palette, amenity_goals, frontier_aims, heal_goals, healthiest_other, - listing, move_index, move_list, objective_goals, party_rested, potion_slot, precondition, + facing_target, listing, move_index, move_list, nurse_prompt, objective_goals, party_rested, + potion_slot, + precondition, shop_screen, throw_slot, untalked_objects, untalked_people, ways, }; use super::path::{self, Route, Way}; @@ -128,6 +130,15 @@ const BACKOUT_PRESSES: u8 = 8; /// Presses a `MENU` may spend opening the start menu before it reports `Blocked`. const OPEN_PRESSES: u8 = 3; +/// Frames after an answer inside which the same YES/NO prompt coming back is that answer's doing. +/// +/// One hold of the Game Boy preset's macro group (268 brain milliseconds, sixteen frames at +/// 59.7275 fps) and half of one again, which is the fly's own next decision plus the frames the +/// cartridge spends redrawing: the nurse's prompt is back **two frames** after a `YES` at the +/// rung-10 checkpoint (`infra/docs/macros-traps.md`, row 41). Later than this and the prompt came +/// back because something else happened, which is not the answer's fault. +const ANSWER_REOPEN_FRAMES: u32 = 24; + /// Frames a `HEAL` waits, pressing nothing, for the healing machine to finish. /// /// `docs/design/macros.md` section 13: "wait for the heal animation to end (read the party HP @@ -481,6 +492,9 @@ struct Active { /// whether a conversation ended with the game walking it away, and whether a push-back /// happened under a macro that was not walking. from: Option, + /// The box a `YES` or `NO` is answering, read at `start` because by the time the press has + /// landed the box has moved on (section 12.12). `None` for every other macro. + answered: Option, /// What this macro is walking to, and the map it was chosen on: the target ledgers' entry. /// /// Chosen at `start`, from the state the fly chose in, for the same reason `facing` is: by the @@ -489,6 +503,35 @@ struct Active { target: Option<(u8, TargetKey)>, } +/// The box a `YES` or `NO` was answering, as it read when the answer was chosen. +/// +/// `docs/design/macros.md` section 12.12. Read at `start`: the whole point of the reading is that +/// the *same* prompt comes back, and by the time the press has landed the box on screen is +/// whatever the answer led to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Answered { + map: u8, + /// The tile the fly answered from, which is what the box belongs to. + at: Tile, + yes: bool, + /// Whether the box was a prompt this crate can read at all. An answer to a plain text box + /// cannot be judged by "the same prompt came back", because no prompt was there to come back. + prompt: bool, + /// The nurse this prompt belongs to, when it is hers: the talked-ledger entry a *declined* + /// heal earns (section 12.12). + nurse: Option, +} + +/// An answer that has been made and whose box may yet come straight back +/// ([`MacroMachine::pending_answer`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PendingAnswer { + answered: Answered, + /// Frames since the answer finished. The window is one hold: a prompt that comes back later + /// than that came back because something else happened. + frames: u32, +} + /// A conversation that has been started and has not ended yet ([`MacroMachine::pending_talk`]). #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct PendingTalk { @@ -574,6 +617,14 @@ pub struct MacroMachine { /// `GO ITEM`'s list, so the one conversation that might change something could not be had /// again. pending_talk: Option, + /// A finished `YES` or `NO` whose prompt may yet come straight back. + /// + /// `docs/design/macros.md` section 12.12: **a YES/NO box that reopens after an answer with + /// nothing changed is section 12.2's trap** -- the answer completed, the fly is on the tile it + /// answered from, and the same question is being asked again, so the press did nothing that + /// the next press will not undo. One hold of frames is the window, because that is how long + /// the fly has to choose again; anything later and something else happened in between. + pending_answer: Option, /// A finished `TALK`'s target, waiting to be taken into the session's talked ledger. /// /// The machine records rather than keeps: the ledger is the driver's @@ -604,6 +655,7 @@ impl MacroMachine { timed_out: None, resume: VecDeque::new(), pending_talk: None, + pending_answer: None, talked: None, rng: if seed == 0 { 1 } else { seed }, } @@ -666,6 +718,21 @@ impl MacroMachine { let facing = (spec.kind == MacroKind::Talk) .then(|| talk_target(state)) .flatten(); + // And which box a `YES` or `NO` is answering, read here for the same reason: the answer + // is about the box that was on screen when the fly chose it (section 12.12). + let answered = matches!(spec.kind, MacroKind::Yes | MacroKind::No) + .then(|| { + let prompt = state.yes_no_prompt(); + let nurse = nurse_prompt(state).then(|| talk_target(state)).flatten(); + state.player().map(|player| Answered { + map: player.map, + at: Tile::new(player.x, player.y), + yes: spec.kind == MacroKind::Yes, + prompt, + nurse: nurse.map(|(_, target)| target), + }) + }) + .flatten(); self.outcome = None; let from = state.player().map(|player| Tile::new(player.x, player.y)); self.active = Some(Active { @@ -677,6 +744,7 @@ impl MacroMachine { cap, plan, facing, + answered, from, target, }); @@ -824,6 +892,8 @@ impl MacroMachine { self.resume.clear(); // A rollback is not the end of a conversation; it is the end of the frames it happened in. self.pending_talk = None; + // Nor is it a prompt reopening: the frames the answer was made in are being thrown away. + self.pending_answer = None; } /// Whether the fly is standing somewhere other than where the running macro began. @@ -851,6 +921,7 @@ impl MacroMachine { /// - the cartridge took the joypad, or the fly is somewhere else — not talked; /// - the fly answered `NO` — not talked, and that one is decided in [`MacroMachine::finish`]. pub fn observe_frame(&mut self, state: &mut dyn MacroState) { + self.observe_answer(state); let Some(pending) = self.pending_talk else { return }; if state.scripted() { self.pending_talk = None; @@ -869,6 +940,38 @@ impl MacroMachine { } } + /// One frame after a `YES` or `NO`: decide whether the box it answered has come straight back. + /// + /// `docs/design/macros.md` section 12.12. The evidence is all in one frame: the fly is on the + /// tile it answered from, and the prompt it answered is up again. Nothing moved and nothing + /// was settled, so the answer goes into the blocked ledger for its window and the dialog pad + /// offers the *other* one -- which at the nurse's counter is the `NO` that ends the ring. + /// + /// Only for an answer to a prompt this crate can **read**. An answer to a plain text box is + /// not judged here, because "the same prompt came back" is not a question that has a meaning + /// there: a conversation is many boxes and advancing one is exactly what `YES` should do. + fn observe_answer(&mut self, state: &mut dyn MacroState) { + let Some(pending) = self.pending_answer.as_mut() else { return }; + pending.frames += 1; + let answered = pending.answered; + if pending.frames > ANSWER_REOPEN_FRAMES || !answered.prompt { + self.pending_answer = None; + return; + } + let Some(player) = state.player() else { return }; + if player.map != answered.map || Tile::new(player.x, player.y) != answered.at { + self.pending_answer = None; + return; + } + if state.yes_no_prompt() { + self.blocked.push(( + answered.map, + TargetKey::Answer { at: answered.at, yes: answered.yes }, + )); + self.pending_answer = None; + } + } + /// Keep `walk`'s remaining route, so the next walk toward the same target carries it on. /// /// Only the route and what the walk learned about the ground: the goals are chosen again from @@ -978,6 +1081,19 @@ impl MacroMachine { if active.kind == MacroKind::No { self.pending_talk = None; } + // An answer, and the box it answered: armed so that the same prompt coming + // straight back is recorded (section 12.12), and the nurse written into the + // talked ledger when what was declined was *her* offer. That is 12.4's rule + // inverted, deliberately and for one person: "the thing it said no to is + // still on offer" is true of a villager with something to say and false of a + // service the party does not need -- the pad only ever offers `NO` at her + // prompt when the party is already full, and declining is the errand's end. + if let Some(answered) = active.answered { + if let Some(nurse) = answered.nurse.filter(|_| !answered.yes) { + self.talked = Some((answered.map, nurse)); + } + self.pending_answer = Some(PendingAnswer { answered, frames: 0 }); + } // The cartridge answered the macro by walking the fly away: that is the // target's own fact, not the world's, so it is excluded for the window like // any other refusal. Without it the gate was walked into once per hold for @@ -1010,6 +1126,16 @@ impl MacroMachine { && let Some(entry) = active.target { self.reached = Some(entry); + // And a completed `HEAL` has *had* the conversation: the box was opened, + // answered and closed by this macro's own presses, so the nurse is talked + // to and `TALK` has nothing left to open (section 12.12). Without it the + // reached window expires after ten brain minutes and the fly is offered + // the same forty-six text frames again with a party that is already full. + if active.kind == MacroKind::Heal + && let (map, TargetKey::Thing(target)) = entry + { + self.talked = Some((map, target)); + } } } // Section 12's blocked-target ledger: three failed steps, or the frame cap on a @@ -1981,9 +2107,17 @@ fn shop_plan(state: &mut dyn MacroState, want: u8) -> Option> { /// /// `None` when the tile ahead is off the map or has nothing on it, which is also when `TALK`'s /// precondition refuses, so a started `TALK` normally has one. +/// +/// **`palette::facing_target`, which is `TALK`'s own precondition, and not a second reading of +/// it.** This looked one tile ahead, and a mart clerk and a Pokemon Center nurse stand two tiles +/// away behind a counter -- so `TALK` was *bound* at a counter by the reach +/// `IsSpriteOrSignInFrontOfPlayer` really has and *recorded* by a reach one tile shorter, which is +/// no entry at all: the ledger never learned that the counter had been talked to, and the pad +/// offered the same conversation once per hold for ever. Measured at the rung-10 Pokemon Center +/// (`infra/docs/macros-traps.md` row 41): `TALK` 107 starts on one tile, none of them retiring the +/// nurse. A precondition and the ledger that answers it have to be the same question. fn talk_target(state: &mut dyn MacroState) -> Option<(u8, TalkTarget)> { + let target = facing_target(state)?; let player = state.player()?; - let ahead = Tile::new(player.x, player.y).step(player.facing)?; - let target = path::target_at(state, ahead)?; Some((player.map, target)) } diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs index d133701..b5fd2de 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/palette.rs @@ -477,10 +477,10 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec { // of advancing, because `Unknown` is also where the Pokédex, the trainer card and OPTION // land (`docs/design/macros-wram.md`) and B is what leaves all three. Scene::Unknown => vec![Next, Back], - // There is no WRAM observable for "a choice is open" (`macros-wram.md` says so outright), - // and A and B both advance a plain box, so the three are dealt for every text box. What it - // buys is the fly being *able* to answer no, which a pad of one A press never could. - Scene::Dialog => vec![Next, Yes, No], + // `NEXT`, `YES`, `NO` for a plain box -- A and B both advance one, and what the three + // buy is the fly being *able* to answer no. On the one box that **is** a choice, the pad + // is the choice's own answers: section 12.12. + Scene::Dialog => dialog_set(state), Scene::Menu => vec![Close, Confirm, Back], // Section 9.1's split, plus section 13's errands and centre. Indoors the ways out of a // room are the building's door and its passages; outdoors there is no building to leave. @@ -601,7 +601,11 @@ pub fn precondition(kind: MacroKind, state: &mut dyn MacroState) -> bool { // that this run has not already talked to (`docs/design/macros.md` section 12: "TALK // (only when facing something untalked)"). A tile ahead with nothing on it is not a // reason to press A, and a shelf that has been read is not a reason to read it again. - MacroKind::Talk => facing_untalked(state), + // ...and only where there is something to say. The nurse of a Pokemon Center is an + // object with a purpose, not a person to chat with: with the party already full her whole + // conversation is forty-six text frames that end where they began, which is section + // 12.2's trap at conversation scale (section 12.12). + MacroKind::Talk => facing_untalked(state) && !rested_nurse(state), // The start menu opens from anywhere, and that is exactly why `MENU` is on no pad: // "the precondition is satisfied wherever the fly stands" is section 12.2's trap, and a // macro whose whole effect is a screen its own scene's `BACK` closes again is 12.10's @@ -660,6 +664,104 @@ pub fn precondition(kind: MacroKind, state: &mut dyn MacroState) -> bool { } } +/// The dialog pad: the answers to a box that is a choice, the three presses for one that is not. +/// +/// **Section 12.12, rung 10, the Pewter Pokemon Center.** Since the 09:39 restart the macro starts +/// were `YES` **2,142**, `TALK` 107, `GO FRONTIER` 26, `BACK` 24, the log ending `YES start/done` +/// for ever, on one tile of map `0x3a`. Surveyed on the cartridge from the live checkpoint: the +/// nurse's conversation is a **ring of forty-six A presses** -- welcome, "We heal your POKeMON +/// back to perfect health!", the YES/NO box on **one** frame of the forty-six, "OK. We'll need +/// your POKeMON.", the machine, "Your POKeMON are fighting fit!", "We hope to see you again!", +/// the box closes for a single frame, and the next A press at a nurse two tiles away over the +/// counter opens the whole thing again. The party was **70/70 and healthy** throughout, so every +/// press of it changed nothing. +/// +/// Two readings the survey settles, because the brief allowed three: +/// +/// - the box open at the checkpoint is **not** the prompt, it is the closing line, and `YES` there +/// is an A press on plain text. Forty-five of the forty-six frames are like that, and on every +/// one of them `NEXT` and `YES` are the identical press with two names; +/// - `HEAL` is **not** in the loop at all: its precondition already reads the live party and +/// `party_needs_rest` answers `false`, so the button was off the pad the whole time. What was on +/// the pad was the *dialog*, unconditionally, and `TALK` to get back into it. +/// +/// So: on a box that is a readable choice the pad is that choice's answers and `NEXT` is off it, +/// which is 12.10's rule about two buttons that are one press; at the nurse's own prompt the +/// answer that changes something is the only one bound, which is 12.2's rule about a macro whose +/// precondition is already satisfied; and an answer that brings the same prompt straight back is +/// excluded for the blocked window, which is 12.1's ledger doing what it does for a walk. +fn dialog_set(state: &mut dyn MacroState) -> Vec { + use MacroKind::*; + if !state.yes_no_prompt() { + return vec![Next, Yes, No]; + } + // A choice is open. An A press here confirms whichever option the cursor is on, which is what + // `YES` is, so `NEXT` is off this pad for exactly 12.10's reason: two buttons that are one + // press cannot both be answers to the box. + let answers = if nurse_prompt(state) { + // The nurse's box is an offer about the party, and the party is a byte. Hurt or statused, + // the answer worth making is `YES`; full and healthy, the offer is for nothing and the + // only answer that changes anything is `NO`. Knowledge inside the macro as a + // precondition, section 13's rule for `HEAL` applied to the box `HEAL` opens. + if party_needs_rest(state) { vec![Yes] } else { vec![No] } + } else { + vec![Yes, No] + }; + let kept: Vec = + answers.iter().copied().filter(|kind| !answer_excluded(state, *kind)).collect(); + // A box must stay answerable: the exclusion narrows a pad, it never empties one. With both + // answers excluded the fly is offered both again, because a dialog with nothing on its pad is + // a screen nothing can leave. + if kept.is_empty() { answers } else { kept } +} + +/// Whether this answer to the box at this tile is inside its reopened-prompt exclusion window. +fn answer_excluded(state: &mut dyn MacroState, kind: MacroKind) -> bool { + let yes = match kind { + MacroKind::Yes => true, + MacroKind::No => false, + _ => return false, + }; + match answer_key(state, yes) { + Some(key) => state.blocked(key), + None => false, + } +} + +/// The blocked-ledger key for answering the box at the tile the fly is standing on. +pub fn answer_key(state: &mut dyn MacroState, yes: bool) -> Option { + let player = state.player()?; + Some(TargetKey::Answer { at: Tile::new(player.x, player.y), yes }) +} + +/// Whether the box on screen is the Pokemon Center nurse's own YES/NO prompt. +/// +/// Three readings, each a byte: the two-option box is drawn ([`MacroState::yes_no_prompt`]), the +/// map is a centre ([`inside_center`]), and the thing the fly is facing is the nurse. The map id +/// is in there because Red draws a two-option box for a dozen scripts and only this one is an +/// offer about the party. +pub fn nurse_prompt(state: &mut dyn MacroState) -> bool { + state.yes_no_prompt() && inside_center(state) && facing_nurse(state) +} + +/// Whether the thing the fly is facing -- over a counter, which is how a nurse is ever faced -- is +/// a Pokemon Center's nurse. +pub fn facing_nurse(state: &mut dyn MacroState) -> bool { + let Some(TalkTarget::Sprite(slot)) = facing_target(state) else { return false }; + state.npcs().iter().any(|npc| npc.slot == slot && npc.picture == poke_sprite::NURSE) +} + +/// Whether the fly is facing a nurse with nothing to ask her for: what takes `TALK` off the pad. +/// +/// The nurse is the one person in Red whose conversation has a *precondition*, because her +/// conversation is a service and the cartridge publishes whether the service is needed. `HEAL` has +/// read that byte since section 13; this is the same byte read for the press that opens the same +/// box. Section 12.2's rule, at conversation scale: a `TALK` whose whole effect is a ring of text +/// that ends where it began is a trap, so it is not on the pad. +pub fn rested_nurse(state: &mut dyn MacroState) -> bool { + inside_center(state) && !party_needs_rest(state) && facing_nurse(state) +} + /// Whether at least one party member is below full HP or carries a status: `HEAL`'s precondition. /// /// The party rather than the battler, because this is asked in the overworld where the battle diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs index 28d9fe6..67a7b67 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs @@ -27,7 +27,8 @@ use super::executor::{ WALK_FRAME_CEILING, walk_budget, }; use super::palette::{ - MacroId, MacroKind, Palette, SLOTS, amenity_goals, errand, healthiest_other, heal_goals, + MacroId, MacroKind, Palette, SLOTS, amenity_goals, answer_key, errand, facing_nurse, + healthiest_other, heal_goals, nurse_prompt, rested_nurse, listing, losing, move_slot_bound, objective_goals, party_needs_rest, party_rested, poke_sprite, precondition, throw_slot, untalked_objects, untalked_people, ways, }; @@ -163,6 +164,12 @@ struct World { /// A frame at which the cartridge heals the party, which is what a Pokémon Center does while /// its text box is open (`docs/design/macros.md` section 13). heal_at: Option, + /// Whether the two-option YES/NO box is the thing on screen ([`MacroState::yes_no_prompt`]). + /// + /// A field rather than a shape of the `list`, because on the cartridge it is a *drawn box* + /// beside a cursor the game never clears, and what the palette asks is only "is a choice + /// open" (section 12.12). + prompt: bool, /// Whether the cartridge is driving the player right now ([`MacroState::scripted`]). scripted: bool, /// A frame at which the cartridge takes the joypad, which is what the Viridian gate does. @@ -236,6 +243,7 @@ impl World { pending: None, switch: None, heal_at: None, + prompt: false, scripted: false, scripted_at: None, pulses: Vec::new(), @@ -339,6 +347,18 @@ impl World { world } + /// [`World::center`] with the fly at the counter facing the nurse, mid-conversation. + /// + /// The rung-10 state (`infra/docs/macros-traps.md` row 41): map `0x3a` at (3, 3) facing up, + /// a text box open, the nurse two tiles away over the counter at (3, 1). + fn at_the_nurse() -> Self { + let mut world = Self::center(); + world.player = Tile::new(3, 3); + world.facing = Facing::Up; + world.scene = Scene::Dialog; + world + } + fn at(mut self, x: u8, y: u8) -> Self { self.player = Tile::new(x, y); self @@ -638,6 +658,12 @@ impl MacroState for World { self.scripted } + /// A drawn box is what the reading rests on, so a prompt cannot be open with no box open: + /// `pokemon_red::state::yes_no_prompt` gates on `wFontLoaded` before it looks at the tiles. + fn yes_no_prompt(&mut self) -> bool { + self.prompt && self.scene == Scene::Dialog + } + fn shop_stock(&mut self) -> Vec { self.stock.clone() } @@ -3892,6 +3918,16 @@ fn heal_is_on_the_centres_pad_only_while_the_party_needs_it() { assert!(!precondition(MacroKind::Heal, &mut center)); assert!(!on_the_pad(&mut center, MacroKind::Heal)); + // The rung-10 party, as the live checkpoint of 2026-09-22 reads it: one Pokemon, 70 of 70, + // healthy (`infra/docs/macros-traps.md` row 41). `HEAL` was **not** what looped there -- its + // precondition reads the live party and answers no, and the survey confirmed it on the + // cartridge. So this is the assertion that the loop was never the heal's. + let mut rung10 = World::center(); + rung10.mons = vec![Mon { hp: 70, max_hp: 70, ..mon(0, 70, 70, &[(33, 30)]) }]; + assert!(!party_needs_rest(&mut rung10)); + assert!(!precondition(MacroKind::Heal, &mut rung10)); + assert!(!on_the_pad(&mut rung10, MacroKind::Heal)); + // Hurt. center.mons[0].hp = 9; assert!(precondition(MacroKind::Heal, &mut center)); @@ -4565,3 +4601,149 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() { mod map_aware; mod shop_purchase; + +// --------------------------------------------------------------------------------------------- +// Section 12.12: the nurse's box (row 41) +// --------------------------------------------------------------------------------------------- + +#[test] +fn talk_is_off_the_pad_at_a_nurse_the_party_has_no_use_for() { + // The overworld frame the ring starts from: at the counter, facing the nurse, party full. + let mut center = World::center().at(3, 3); + center.facing = Facing::Up; + assert!(facing_nurse(&mut center), "the nurse is the thing ahead, over the counter"); + assert!(rested_nurse(&mut center)); + assert!(!precondition(MacroKind::Talk, &mut center)); + assert!(!on_the_pad(&mut center, MacroKind::Talk)); + + // Hurt, and she is worth talking to again -- the conversation now does something. + center.mons[0].hp = 4; + assert!(!rested_nurse(&mut center)); + assert!(precondition(MacroKind::Talk, &mut center)); + assert!(on_the_pad(&mut center, MacroKind::Talk)); + + // Statused at full HP counts as needing her, exactly as `HEAL`'s own precondition does. + center.mons[0].hp = center.mons[0].max_hp; + center.mons[0].status = Status::Poison; + assert!(precondition(MacroKind::Talk, &mut center)); + + // And nobody else in the game is narrowed by this: an ordinary person on the same map is + // still `TALK`'s whatever the party reads. + let mut villager = World::center().at(3, 3); + villager.facing = Facing::Up; + villager.npcs = vec![Npc { slot: 1, picture: 1, x: 3, y: 2, facing: Facing::Down }]; + villager.counters.clear(); + villager.walls.clear(); + assert!(!facing_nurse(&mut villager)); + assert!(precondition(MacroKind::Talk, &mut villager), "a full party is not a reason to ignore a person"); +} + +#[test] +fn the_nurses_prompt_offers_only_the_answer_that_changes_something() { + let mut center = World::at_the_nurse(); + center.prompt = true; + assert!(nurse_prompt(&mut center)); + + // Full and healthy: the offer is for nothing, so `NO` is the answer and `YES` is not on the + // pad. `NEXT` is off it too -- an A press at a two-option box *is* `YES` (12.10). + assert_eq!(names(&plan::plan_for(Scene::Dialog, &mut center)), ["NO"]); + + // Hurt: `YES` is the answer, and `NO` is the one that changes nothing. + center.mons[0].hp = 4; + assert_eq!(names(&plan::plan_for(Scene::Dialog, &mut center)), ["YES"]); + + // A plain text box, which is forty-five of the nurse's forty-six frames, keeps all three: A + // and B both advance one and there is no choice for `NEXT` to be the wrong name for. + center.prompt = false; + assert_eq!(names(&plan::plan_for(Scene::Dialog, &mut center)), ["NEXT", "YES", "NO"]); +} + +#[test] +fn a_readable_prompt_that_is_not_the_nurses_keeps_both_answers_and_loses_next() { + // Red draws a two-option box for a dozen scripts and only the nurse's is an offer about the + // party, so nothing else is narrowed by the party: both answers, and no `NEXT`. + let mut world = World::room(); + world.scene = Scene::Dialog; + world.prompt = true; + assert!(!nurse_prompt(&mut world)); + assert_eq!(names(&plan::plan_for(Scene::Dialog, &mut world)), ["YES", "NO"]); +} + +#[test] +fn a_yes_no_box_that_reopens_unchanged_takes_that_answer_off_the_pad() { + // Section 12.12's general rule, away from the nurse: the answer completed, the fly is on the + // tile it answered from, and the same prompt is up again -- so the press did nothing, which + // is section 12.2's trap, and the answer joins the blocked ledger for its window. + let mut world = World::room(); + world.scene = Scene::Dialog; + world.prompt = true; + assert_eq!(names(&plan::plan_for(Scene::Dialog, &mut world)), ["YES", "NO"]); + + assert_eq!(run(&mut world, MacroKind::Yes).unwrap(), MacroAbort::Done); + let key = answer_key(&mut world, true).expect("a loaded map has a tile"); + assert!(world.targets.blocked(world.map, key), "the answer that changed nothing"); + assert_eq!( + names(&plan::plan_for(Scene::Dialog, &mut world)), + ["NO"], + "the other answer is still there, which is what ends the ring" + ); + + // And `NO` is not excluded by `YES`'s entry: one answer, one key. + let no = answer_key(&mut world, false).expect("a loaded map has a tile"); + assert!(!world.targets.blocked(world.map, no)); +} + +#[test] +fn a_prompt_that_does_not_come_back_excludes_nothing() { + // The other half of the same rule: an answer that settled the box is an answer worth making + // again. Nothing is excluded, because nothing looped. + let mut world = World::room(); + world.scene = Scene::Dialog; + world.prompt = true; + // The box closes on the frame after the press, which is what answering it does. + world.switch = Some((2, Scene::Overworld)); + assert_eq!(run(&mut world, MacroKind::Yes).unwrap(), MacroAbort::Done); + let key = answer_key(&mut world, true).expect("a loaded map has a tile"); + assert!(!world.targets.blocked(world.map, key)); +} + +#[test] +fn a_declined_heal_writes_the_nurse_into_the_talked_ledger() { + // 12.4's rule is "the fly said no, so the thing is still on offer", and for one person in Red + // that is wrong: the pad only ever offers `NO` at her prompt when the party is already full, + // so declining is the errand's end rather than a conversation postponed. + let mut center = World::at_the_nurse(); + center.prompt = true; + assert!(party_rested(&mut center)); + + assert_eq!(run(&mut center, MacroKind::No).unwrap(), MacroAbort::Done); + assert!(center.talked.contains(&TalkTarget::Sprite(1)), "the nurse: {:?}", center.talked); + + // So `TALK` is off the pad there even if the party is hurt later: the ledger is the record + // that this run has had her conversation. + center.scene = Scene::Overworld; + center.mons[0].hp = 4; + assert!(!precondition(MacroKind::Talk, &mut center)); +} + +#[test] +fn a_completed_heal_writes_the_nurse_into_the_talked_ledger() { + let mut center = World::center(); + center.mons[0].hp = 3; + center.switch = Some((200, Scene::Dialog)); + center.heal_at = Some(240); + + assert_eq!(run(&mut center, MacroKind::Heal).unwrap(), MacroAbort::Done); + assert!(party_rested(&mut center)); + assert!( + center.talked.contains(&TalkTarget::Sprite(1)), + "a completed heal has had the conversation: {:?}", + center.talked + ); + + // And `TALK` cannot reopen it. The reached window would expire in ten brain minutes and offer + // the fly the same forty-six text frames again; the talked entry is for the session. + center.scene = Scene::Overworld; + assert_eq!(center.player, Tile::new(3, 3)); + assert!(!precondition(MacroKind::Talk, &mut center)); +} diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/scene.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/scene.rs index d720867..e09347a 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/scene.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/scene.rs @@ -104,7 +104,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String { let box_corners = state::dialog_corners(memory); format!( "started={} map={:?} party={} battle={} type={} font={:#04x} textbox={:#04x} \ - list={:#04x} cursor=({},{},{},{},{:#04x}) joy={} sim={} flags5={:#04x} \ + list={:#04x} cursor=({},{},{},{},{:#04x}) prompt={} joy={} sim={} flags5={:#04x} \ flags6={:#04x} move={:#04x} \ corners=({:#04x},{:#04x},{:#04x},{:#04x})", state::started(memory), @@ -120,6 +120,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String { memory.read8(ram::wCurrentMenuItem), memory.read8(ram::wMaxMenuItem), memory.read8(ram::wMenuWatchedKeys), + state::yes_no_prompt(memory), memory.read8(ram::wJoyIgnore), memory.read8(ram::wSimulatedJoypadStatesIndex), memory.read8(ram::wStatusFlags5), 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 da6167e..f2913c3 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -75,6 +75,17 @@ pub mod poke { /// `constants/menu_constants.asm` party menu types. pub const BATTLE_PARTY_MENU: u8 = 0x02; + /// The two-option YES/NO box, as surveyed on the cartridge (`infra/docs/macros-traps.md`, + /// row 41): the border `DisplayTwoOptionMenu` draws, and where it parks the cursor. + /// + /// Values rather than a symbol because `wTwoOptionMenuID` is not in the reviewed address list + /// and the box's geometry is what is on screen. Read from the rung-10 Pokemon Center + /// checkpoint, one raw A pulse at a time: a box at (11, 6)-(19, 11) with the cursor at + /// row 8, column 12, one item below the first, watching A and B. + pub const YES_NO_BOX: (u16, u16, u16, u16) = (11, 6, 19, 11); + pub const YES_NO_CURSOR_Y: u8 = 8; + pub const YES_NO_CURSOR_X: u8 = 12; + /// `constants/ram_constants.asm`: `wMiscFlags` bit 3. pub const BIT_USING_GENERIC_PC: u8 = 1 << 3; /// `wFontLoaded` bit 0. @@ -534,6 +545,36 @@ pub fn text_box(memory: &mut dyn MemoryReader) -> TextBox { TextBox { open, waiting: open && border_drawn(memory, 0, 12, 19, 17) } } +/// Whether the two-option YES/NO box is the thing on screen: a *choice*, not a plain text box. +/// +/// `docs/design/macros-wram.md` says there is no "a choice is open" flag, and there is not -- so +/// this is the same construction [`text_box`] makes for `waiting`: a WRAM flag plus the figure the +/// game draws. `DisplayTwoOptionMenu` draws its own little box in the top right and parks the +/// shared cursor inside it, and **both halves are needed**: the cursor bytes are not cleared when +/// the box closes, so at the rung-10 checkpoint every one of the nurse's forty-six text frames +/// reads `wTopMenuItemY` 8, `wTopMenuItemX` 12, `wMaxMenuItem` 1 and `wMenuWatchedKeys` `$03` +/// while the box itself is drawn on exactly one of them (`infra/docs/macros-traps.md`, row 41). +/// +/// **What it does not claim.** Red places a two-option menu where the script that asks for it +/// says, so a prompt drawn somewhere else reads `false` here and its dialog keeps the pad it has +/// always had. This is the box the nurse's "heal your POKeMON?" is drawn in, surveyed; it is not a +/// general answer to "is a choice open", and nothing in the palette treats it as one. +pub fn yes_no_prompt(memory: &mut dyn MemoryReader) -> bool { + if read(memory, ram::wFontLoaded) & poke::BIT_FONT_LOADED == 0 { + return false; + } + let cursor = cursor(memory); + if cursor.top_y != poke::YES_NO_CURSOR_Y + || cursor.top_x != poke::YES_NO_CURSOR_X + || cursor.max != 1 + || cursor.watched_keys != poke::pad::A | poke::pad::B + { + return false; + } + let (left, top, right, bottom) = poke::YES_NO_BOX; + border_drawn(memory, left, top, right, bottom) +} + /// 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). /// @@ -1269,6 +1310,10 @@ impl MacroState for PokeState<'_> { !controllable(self.memory) } + fn yes_no_prompt(&mut self) -> bool { + yes_no_prompt(self.memory) + } + /// The whole loaded map's walkability, from the cache when it is for this map /// (`docs/design/macros.md` section 15). /// diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index 09253ee..37c447b 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -329,6 +329,131 @@ fn battle_dump(gb: &mut Emulator, adapter: &PokemonRedReward, pad: &[String], la println!("- money: {}", state.money()); } +/// One line of the dialogue box, decoded through `constants/charmap.asm`. +/// +/// The survey below is about *which box* is open, and the only thing on screen that says so is the +/// text in it: `wTextBoxID` is `$01` for every ordinary `TX_FAR` box the nurse draws, so the id +/// cannot tell the welcome from the prompt from the closing line. The tiles can. +fn box_line(gb: &mut Emulator, y: u16) -> String { + (1..19u16) + .map(|x| match gb.read8(ram::wTileMap + y * 20 + x) { + 0x7f => ' ', + byte @ 0x80..=0x99 => (b'A' + (byte - 0x80)) as char, + byte @ 0xa0..=0xb9 => (b'a' + (byte - 0xa0)) as char, + 0xba => 'e', + 0xe3 => '-', + 0xe6 => '?', + 0xe7 => '!', + 0xe8 => '.', + 0xef => 'M', + 0xee => '\u{25bc}', + byte @ 0xf6..=0xff => (b'0' + (byte - 0xf6)) as char, + _ => '.', + }) + .collect::() + .trim_end() + .to_string() +} + +/// The top-right corner of the screen, where a two-option menu's own little box is drawn: the +/// tiles at (11..20, 6..11) reduced to which of them hold a text-box frame tile. +fn corner_box(gb: &mut Emulator) -> String { + let mut out = String::new(); + for y in 6..12u16 { + for x in 11..20u16 { + let byte = gb.read8(ram::wTileMap + y * 20 + x); + out.push(match byte { + 0x79 | 0x7b | 0x7d | 0x7e => '+', + 0x7a | 0x7c => '|', + 0x7f => '_', + _ => '.', + }); + } + out.push('/'); + } + out +} + +/// Everything that tells one of the nurse's boxes from another, on one line. +fn nurse_frame(gb: &mut Emulator) -> String { + let text = state::text_box(gb); + format!( + "{:?} open={} waiting={} cursor=({},{},{},{},{:#04x}) yesno={} | {} | {}", + scene::detect(gb), + text.open, + text.waiting, + gb.read8(ram::wTopMenuItemY), + gb.read8(ram::wTopMenuItemX), + gb.read8(ram::wCurrentMenuItem), + gb.read8(ram::wMaxMenuItem), + gb.read8(ram::wMenuWatchedKeys), + corner_box(gb), + box_line(gb, 14), + box_line(gb, 16), + ) +} + +/// The Pokémon Center nurse's whole conversation, box by box, with raw presses (row 41). +/// +/// The rung-10 loop of 2026-09-22 was `YES` 2,142 macro starts on one tile of map `0x3a`, so the +/// question the fix turns on is **which box each A press answers**. `wTextBoxID` cannot say -- +/// every box the nurse draws is `$01` -- and `docs/design/macros-wram.md` says outright that there +/// is no "a choice is open" flag, so the reading has to be surveyed: leave the box with B, then +/// pulse A and print every state the conversation passes through, with the two-option menu's own +/// geometry beside it. The party is printed first because `HEAL`'s precondition is the party and +/// the loop's premise is that it is already full. +fn nurse_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) { + use flybrain_gb::pokemon_red::macros::cartridge::MacroState; + + println!("\n## The party at the checkpoint\n"); + { + let ledger = AdapterLedger(adapter); + let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger); + let state: &mut dyn MacroState = &mut poke; + for mon in &state.party().mons { + println!( + "- slot {} species {:#04x} level {} hp {}/{} status {:?}", + mon.slot, mon.species, mon.level, mon.hp, mon.max_hp, mon.status + ); + } + println!( + "- `party_needs_rest` = {}, `party_rested` = {}", + flybrain_gb::pokemon_red::macros::palette::party_needs_rest(state), + flybrain_gb::pokemon_red::macros::palette::party_rested(state), + ); + } + + let pulse = |gb: &mut Emulator, adapter: &mut PokemonRedReward, mask: u8, ms: &mut f64| { + for phase in 0..16 { + gb.set_buttons(if phase < 8 { mask } else { 0 }); + gb.run_frame().expect("a frame should complete"); + *ms += MS_PER_FRAME; + adapter.sample(gb, *ms); + } + }; + + println!("\n## The nurse's conversation, one raw A pulse at a time\n"); + println!("- at the checkpoint: {}", nurse_frame(gb)); + for _ in 0..20 { + if scene::detect(gb) == scene::Scene::Overworld { + break; + } + pulse(gb, adapter, flybrain_gb::buttons::B, ms); + } + println!("- after B until the box closes: {}", nurse_frame(gb)); + println!("\n```"); + let mut last = String::new(); + for index in 0..env_usize("FLY_PROBE_PULSES", 120) { + pulse(gb, adapter, flybrain_gb::buttons::A, ms); + let now = nurse_frame(gb); + if now != last { + println!("A#{index:<3} {now}"); + last = now; + } + } + 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."); @@ -368,6 +493,12 @@ fn main() { dump(&mut gb, "At the checkpoint"); pad(&mut gb, &adapter, "The pad at the checkpoint"); + let catch_nurse = std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "nurse"); + if catch_nurse { + nurse_survey(&mut gb, &mut adapter, &mut 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 8ba9b5c..633d0b3 100644 --- a/services/flysim/crates/flysim/tests/rom_macros_mode.rs +++ b/services/flysim/crates/flysim/tests/rom_macros_mode.rs @@ -59,6 +59,8 @@ const VIRIDIAN_MART: u32 = 0x2a; /// Route 2's southern forest gate and the forest north of it, which is rung 9's own road. const VIRIDIAN_FOREST_SOUTH_GATE: u32 = 0x32; const VIRIDIAN_FOREST: u32 = 0x33; +/// Pewter City's Pokemon Center, which is the room the rung-10 nurse loop was inside. +const PEWTER_POKECENTER: u32 = 0x3a; /// The upper floor of the Pewter museum, which is the building the rung-10 stall was inside. const MUSEUM_2F: u32 = 0x35; /// The forest's *northern* gate, which is the first hop from the forest toward Pewter @@ -237,10 +239,33 @@ struct Run { blocked_where: std::collections::BTreeSet, /// Macros started while the fly was still on the map it resumed on. macros_on_the_first_map: u32, + /// How many times each macro started while the fly was still on that map, by name. + /// + /// The total is the wrong measure for a room the fly is meant to *leave*: a run that leaves it + /// and then fights a gym answers `YES` in the gym's own boxes, which is the fly playing the + /// game. What row 41 is about is the presses spent in the room. + started_on_the_first_map: std::collections::BTreeMap<&'static str, u32>, /// The longest chain of macro starts that alternated `MENU`, `BACK`, `MENU`, `BACK`. longest_menu_back_alternation: u32, menu_alternation: u32, last_start: Option<&'static str>, + /// Frames the run spent in a `Scene::Dialog`, and of those, frames a readable YES/NO prompt + /// was open (section 12.12). + /// + /// The two numbers that name row 41: 62,804 of the hunt's 71,673 frames were one text box, and + /// the survey found the **prompt** on one frame of every forty-six. A `NEXT` and a `YES` that + /// are the same press live in the difference. + dialog_frames: u32, + prompt_frames: u32, + /// Whether `NEXT` was ever on the pad while a readable YES/NO prompt was open. + /// + /// 12.10's rule in a dialog: an A press at a two-option box confirms the option the cursor is + /// on, which is what `YES` is, so the two are one press under two names. `false` is the claim. + next_on_a_prompt: bool, + /// Whether `TALK` was ever on the pad while the fly faced a nurse the party had no use for. + /// + /// The door into the ring (section 12.12). `false` is the claim. + talk_at_a_rested_nurse: bool, } impl Run { @@ -331,7 +356,12 @@ impl Run { blocked: std::collections::BTreeMap::new(), blocked_where: std::collections::BTreeSet::new(), macros_on_the_first_map: 0, + started_on_the_first_map: std::collections::BTreeMap::new(), longest_menu_back_alternation: 0, + dialog_frames: 0, + prompt_frames: 0, + next_on_a_prompt: false, + talk_at_a_rested_nurse: false, menu_alternation: 0, last_start: None, } @@ -427,7 +457,12 @@ impl Run { blocked: std::collections::BTreeMap::new(), blocked_where: std::collections::BTreeSet::new(), macros_on_the_first_map: 0, + started_on_the_first_map: std::collections::BTreeMap::new(), longest_menu_back_alternation: 0, + dialog_frames: 0, + prompt_frames: 0, + next_on_a_prompt: false, + talk_at_a_rested_nurse: false, menu_alternation: 0, last_start: None, } @@ -574,6 +609,20 @@ impl Run { flybrain_gb::pokemon_red::macros::MacroState::shop_stock(&mut state) } + /// Whether the two-option YES/NO box is drawn, through the accessor the palette reads + /// (section 12.12). + fn yes_no_prompt(&mut self) -> bool { + flybrain_gb::pokemon_red::state::yes_no_prompt(&mut self.gb) + } + + /// Whether the fly faces a Pokemon Center nurse with a party that does not need her. + fn rested_nurse(&mut self) -> bool { + let ledger = AdapterLedger(&self.adapter); + let mut state = + flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut self.gb, &ledger); + flybrain_gb::pokemon_red::macros::palette::rested_nurse(&mut state) + } + /// Whether every party member reads full HP with no status: the end of a `HEAL`. fn party_rested(&mut self) -> bool { let party = flybrain_gb::pokemon_red::state::party(&mut self.gb); @@ -714,6 +763,7 @@ impl Run { self.last_start = Some(name); if self.route.len() == 1 { self.macros_on_the_first_map += 1; + *self.started_on_the_first_map.entry(name).or_insert(0) += 1; } *self.started.entry(name).or_insert(0) += 1; } @@ -748,6 +798,24 @@ impl Run { if self.route.last() != Some(&map) && map != u32::MAX { self.route.push(map); } + // Section 12.12's two frame counters and its two pad rules, asked of the frame the pad + // was dealt for -- the dialog's, exactly as 12.10 asks the battle rules of the battle's. + if self.layer.scene_name() == "dialog" { + self.dialog_frames += 1; + let dealt = self.layer.bound_channels(); + if self.yes_no_prompt() { + self.prompt_frames += 1; + if dealt.iter().any(|channel| channel.as_str() == "macro_next") { + self.next_on_a_prompt = true; + } + } + } + if self.layer.scene_name() == "overworld" + && self.rested_nurse() + && self.layer.bound_channels().iter().any(|channel| channel.as_str() == "macro_talk") + { + self.talk_at_a_rested_nurse = true; + } // The pad the *next* frame will choose from, for the maps that have been accused of // dealing one button. Only the overworld: a warp in flight reads `unknown` and a text box // is a pad of its own. @@ -2017,3 +2085,99 @@ fn the_fly_leaves_the_pewter_building_from_the_rung_ten_checkpoint() { run.blocked ); } + +/// The rung-10 Pokemon Center checkpoint, or `None` to skip. +fn center_checkpoint() -> Option { + std::env::var_os("FLY_CENTER_CHECKPOINT").map(|path| { + flysim::store::load(std::path::Path::new(&path)) + .expect("the checkpoint should be a FLYSIM01 envelope") + }) +} + +/// From the rung-10 Pokemon Center checkpoint: the fly leaves the centre and stops answering YES. +/// +/// **What was live** (2026-09-22, v0.4.4, rank 10 PEWTER CITY): the fly on **map 0x3a at (3, 3)**, +/// facing the nurse over her counter, and since the 09:39 restart the macro starts were `YES` +/// **2,142**, `TALK` 107, `GO FRONTIER` 26, `BACK` 24, the event log ending `YES start/done` for +/// ever. This is row 41, first measured in the rung-9 trap hunt and named as the next trap by +/// 12.11. +/// +/// **What the survey found** (`infra/docs/macros-traps.md` row 41, and +/// `examples/scene_probe.rs`'s `FLY_PROBE_CATCH=nurse`): the nurse's conversation is a ring of +/// **forty-six A presses** -- welcome, the offer, the YES/NO box on **one** frame of the +/// forty-six, "OK. We'll need your POKeMON.", the machine, "fighting fit!", "We hope to see you +/// again!", the box closes for a single frame, and the next A press opens the whole thing again. +/// The party read **70/70 and healthy** throughout, so every press of it changed nothing, and +/// `HEAL` was never in it: its precondition reads the live party and answers no. What was on the +/// pad was the dialog's `NEXT`, `YES`, `NO` -- two names for one A press -- and `TALK` to get back +/// in, whose ledger entry was read one tile shorter than its own precondition and so was never +/// written. +/// +/// The claims, none of them about where the fly goes next: +/// +/// - `YES` starts **under five** in the whole run, against 1,278 in the rung-9 hunt from the same +/// room. Not zero: the fly may legitimately answer a hurt party's prompt. +/// - `NEXT` is on **no** pad while a readable YES/NO prompt is open (12.10 in a dialog). +/// - `TALK` is on **no** pad while the fly faces a nurse the party has no use for. +/// - the fly **leaves map 0x3a** on a bounded number of macros. +/// +/// ```sh +/// FLY_ROM=/path/to/pokemon-red.gb \ +/// FLY_CENTER_CHECKPOINT=.local/checkpoints/release-rank10-pokecenter.checkpoint \ +/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture +/// ``` +#[test] +fn the_fly_leaves_the_pokemon_center_from_the_rung_ten_checkpoint() { + let rom = skip_without_rom!(); + let Some(checkpoint) = center_checkpoint() else { + eprintln!("skipped: no FLY_CENTER_CHECKPOINT"); + return; + }; + let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint); + let from = run.map(); + assert_eq!(from, PEWTER_POKECENTER, "the checkpoint is the room the stream stalled in"); + // The premise of the whole trap: there was nothing to heal. + assert!(run.party_rested(), "the checkpoint's party is already full and healthy"); + + let mut left = None; + for frame in 0..120_000u32 { + run.frame(); + if left.is_none() && run.map() != from { + left = Some(frame); + } + } + eprintln!( + "from map {from:#04x} in {:.1} brain minutes: route {:?}, macros {:?}, dialog frames {} \ + (prompt on {}), blocked {:?}", + run.ms / 60_000.0, + run.route, + run.started, + run.dialog_frames, + run.prompt_frames, + run.blocked + ); + eprintln!("macros spent in the centre: {:?}", run.started_on_the_first_map); + + assert!( + !run.next_on_a_prompt, + "`NEXT` was on the pad at a YES/NO box, where an A press is `YES`" + ); + assert!( + !run.talk_at_a_rested_nurse, + "`TALK` was on the pad at a nurse the party had no use for" + ); + // In the **centre**, which is what row 41 is about: the run goes on to leave Pewter's gym + // door and fight there, and the gym's own boxes are the fly playing the game rather than the + // ring. 1,278 of 1,295 in the rung-9 hunt from this room; 2,142 live. + let yes = run.started_on_the_first_map.get("YES").copied().unwrap_or(0); + assert!(yes < 5, "`YES` started {yes} times in the centre: {:?}", run.started_on_the_first_map); + let Some(left) = left else { + panic!("the fly never left map {from:#04x}: {:?}", run.started) + }; + eprintln!("it left map {from:#04x} on frame {left}"); + assert!( + run.macros_on_the_first_map < 400, + "leaving the centre cost {} macros", + run.macros_on_the_first_map + ); +}