Compare commits

...

9 commits

Author SHA1 Message Date
acamilo
b085622a60 docs: v0.4.5 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 11:18:36 +00:00
acamilo
e65e712cee Merge fix/loop-20260922T1012: readable YES/NO prompts deal their answers, the nurse enters the talked ledger, a prompt that reopens unchanged is excluded 2026-09-22 11:18:34 +00:00
acamilo
9280cfac2e macros: count the centre's presses, not the whole run's
The first run of the new ROM test failed on YES 134, and every one past
the sixth was in Pewter City or the gym the fly had walked to -- which is
the fly playing the game, not the ring. Row 41 is about the presses spent
in the room, so the harness records starts by name while the fly is still
on the map it resumed on: 6 macros there, YES 2.
2026-09-22 11:17:30 +00:00
acamilo
e1b4819ebe docs: row 41 worked, with the survey and the before/after
Where the fly was standing, the conversation press by press, why TALK
retired nothing, the four rows this branch closes and the one it leaves,
the ROM-gated run, and the hunt: 1 distinct tile to 437, 73/73 flagged
windows to 54/73, YES in a text box on map 0x3a 1424 to 4, dialog frames
69,469 to 5,492.

The 54 that still flag are a different trap on ground the before arm
never reached -- GO FRONTIER runs and a GO OBJECTIVE/BACK/GO FRONTIER
cycle in Pewter City -- and that is the next brief.
2026-09-22 11:16:18 +00:00
acamilo
5b9f5a54ca docs: section 12.12, the nurse's box
What was live, what the survey read, and what the three candidate
readings in the brief turned out to be: not the prompt, not HEAL, but the
dialog pad dealt unconditionally with TALK to get back into it. The
scene-by-scene table splits the dialog row in two and the pad-empty sweep
gains the reopen ledger's own residual. macros-wram.md narrows its "no
observable for a choice" claim rather than withdrawing it.
2026-09-22 10:39:15 +00:00
acamilo
0bf4b965fb macros: tests for the nurse's box
Unit: HEAL is off a full party's pad, including the rung-10 party's own
numbers, which is the assertion that the loop was never the heal's; TALK
is off a rested nurse's pad and on a hurt one's, and an ordinary
villager is unaffected; her 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 one that settled the
box is not; a completed heal and a declined prompt both retire her.

ROM-gated from the live checkpoint (FLY_CENTER_CHECKPOINT): the fly
leaves map 0x3a on a bounded number of macros, 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 run also counts dialog and prompt frames, which is
what makes the forty-six-to-one ratio visible.
2026-09-22 10:39:15 +00:00
acamilo
70a2433e25 macros: the nurse is talked to once, and a reopened box excludes its answer
TALK's precondition reaches over a counter because the cartridge does;
its talked-ledger entry was read one tile ahead. So TALK was bound at the
counter and recorded nothing, 107 times, and the nurse was never retired.
The entry now comes from the same reading the precondition makes.

A completed HEAL writes her into the talked ledger: the macro has had the
conversation with its own presses, and the reached window would otherwise
expire in ten brain minutes and offer the ring again. A declined prompt
writes it too -- 12.4 inverted for one person, because the pad only ever
offers NO at her prompt when the party is already full.

And the general rule: a YES or NO whose prompt is up again on the same
map and tile within one hold did nothing, so that answer joins the
blocked ledger for its window.
2026-09-22 10:39:04 +00:00
acamilo
ce7644e2e8 macros: a choice's pad is its answers, and TALK is not a nurse's errand
On a box that is a readable YES/NO prompt, NEXT is YES under another
name -- an A press at a two-option menu confirms the option the cursor is
on -- so it is off that pad and the pad is the box's two answers. That is
12.10's pair rule in a dialog.

At the nurse's own prompt only the answer that changes something is
bound: YES with a hurt or statused party, NO with a full one, read from
the same byte HEAL's precondition has read since section 13.

And TALK is off the pad 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. Nobody else is
narrowed.

An answer the reopened-prompt ledger excludes leaves the pad, and the
exclusion never empties one: a box nothing can answer cannot be left.
2026-09-22 10:39:04 +00:00
acamilo
86f0e7018d macros: read the nurse's YES/NO box, and survey her conversation
Row 41 is the Pokemon Center nurse: 2,142 YES starts on one tile of map
0x3a with a party at 70/70. The survey that names it is a new probe mode
(FLY_PROBE_CATCH=nurse) that leaves the box with B and then pulses A,
printing every state the conversation passes through with the two-option
menu's own geometry beside it. It is a ring of forty-six presses and the
box is a choice on exactly one of them.

So the reading: wFontLoaded plus the border DisplayTwoOptionMenu draws,
plus the cursor it parks inside it. Both halves are needed -- the cursor
bytes survive the box closing, so all forty-six frames carry the
geometry. It claims that box and not "a choice is open" in general.

TargetKey gains Answer { at, yes } for the reopened-prompt exclusion.
2026-09-22 10:38:52 +00:00
12 changed files with 1065 additions and 13 deletions

View file

@ -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.

View file

@ -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 |

View file

@ -640,3 +640,10 @@ then purge; then the stale-doc pass.
the party list and SWITCH the bag since v0.4.0; fixed, THROW BALL now 15/0 in the forest run.
ROM test fails on v0.4.3 and passes here. Ethos check held. Row 41 (a nurse box answered YES
1,278 times) is next.
- 2026-09-22 (v0.4.5, loop review, auto): row 41. In the Pewter center the nurse's conversation is a
ring of 46 A presses with one YES/NO choice; the dialog pad dealt NEXT and YES unconditionally
(one press, two names) and TALK was bound over the counter but recorded one tile ahead, so the
nurse never entered the talked ledger: YES x2,142. Fixed: a readable prompt deals its answers with
NEXT off it, only the answer that changes something is bound, TALK is off at a rested nurse, the
nurse is talked after a heal or a decline, a prompt that reopens unchanged is excluded. ROM test:
leaves the center on frame 326. Hunt: 1 -> 437 tiles, YES 1,424 -> 4. Ethos check held.

View file

@ -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.

View file

@ -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

View file

@ -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<Tile>,
/// 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<Answered>,
/// 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<TalkTarget>,
}
/// 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<PendingTalk>,
/// 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<PendingAnswer>,
/// 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<Vec<Step>> {
///
/// `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))
}

View file

@ -477,10 +477,10 @@ pub fn scene_set(scene: Scene, state: &mut dyn MacroState) -> Vec<MacroKind> {
// 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<MacroKind> {
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<MacroKind> =
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<TargetKey> {
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

View file

@ -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<u32>,
/// 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<u8> {
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));
}

View file

@ -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),

View file

@ -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).
///

View file

@ -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::<String>()
.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;

View file

@ -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<String>,
/// 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<flysim::store::Checkpoint> {
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
);
}