Merge fix/loop-20260922T1012: readable YES/NO prompts deal their answers, the nurse enters the talked ledger, a prompt that reopens unchanged is excluded
This commit is contained in:
commit
e65e712cee
11 changed files with 1058 additions and 13 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue