Merge fix/loop-row58: the gym's people are there when the screen does not draw them, and the game's own moves write no ledger

This commit is contained in:
acamilo 2026-09-23 13:43:18 +00:00
commit 6fd6d840dc
19 changed files with 1104 additions and 56 deletions

View file

@ -893,3 +893,41 @@ point of reading the figure; a border drawn somewhere the cursor is not parked i
box; and a menu of more than two options is not this menu. What the pad makes of a readable prompt box; and a menu of more than two options is not this menu. What the pad makes of a readable prompt
is `pokemon_red::macros::palette`'s business (`docs/design/macros.md` 12.12 and 12.20), not this is `pokemon_red::macros::palette`'s business (`docs/design/macros.md` 12.12 and 12.20), not this
accessor's. accessor's.
## 12. The people off the screen, and a battle decided (2026-09-23, `docs/design/macros.md` 12.22)
Two readings row 58 added, both out of bytes the seam already had or a byte bracketed by two it had.
### `state::offscreen_npcs`
`CheckSpriteAvailability` (`engine/overworld/movement.asm`) writes `$ff` into
`SPRITESTATEDATA1_IMAGEINDEX` (offset 2) for a sprite that is a toggleable object switched off, that
is outside its window, or that stands on a text box's tiles. The window, for a sprite whose
`SPRITESTATEDATA2_MOVEMENTBYTE1` (offset 6) is `WALK` (`$fe`) or `STAY` (`$ff`), compares the
sprite's biased `MAPY` / `MAPX` (offsets 4 and 5) with `wYCoord` / `wXCoord`: drawn when equal, or
when the sprite's is greater by at most `SCREEN_HEIGHT / 2 - 1` (8) rows and
`SCREEN_WIDTH / 2 - 1` (9) columns. A scripted mover (movement byte below `WALK`) skips the test.
So a `$ff` sprite of the loaded map whose coordinates fall **outside** the window, and whose
movement byte is `WALK` or above, is reported with those coordinates: the cartridge would hide it
for being off the screen whatever else were true, and a sprite it is not updating does not move.
Everything else `$ff` is not reported. Measured from the row-58 checkpoint at the Pewter Gym's
doormat: BROCK at (4, 1) and the Jr. Trainer at (3, 6) reported, the guide at (7, 10) drawn.
What it cannot tell is a toggleable object switched off from one out of sight, because both are
`$ff` outside the window. Its one reader is the rung's own list of people; the ladder's person
places are Oak's lab and the gyms, and only the lab and Viridian Gym carry toggleable people
(`data/maps/toggleable_objects.asm`).
### `poke::CUR_OPPONENT`, `wCurOpponent`
Written when a battle is decided (`home/trainers.asm` for a trainer, the encounter check for a wild
one), cleared by `EndOfBattle` in the same block that clears `wIsInBattle`. Not in the generated
table: `ram/wram.asm` at the pinned commit declares `wIsInBattle:: db`,
`wPartyGainExpFlags:: flag_array PARTY_LENGTH` (one byte), `wCurOpponent:: db`,
`wBattleType:: db`, `wDamageMultipliers:: db`, `wGymLeaderNo:: db`, `wTrainerNo:: db`, and the
table's `wIsInBattle` (`$d057`), `wBattleType` (`$d05a`) and `wTrainerNo` (`$d05d`) are exactly
where that layout puts them, so the byte is `wBattleType - 1` = `$d059`, asserted against both
neighbours in `scene/tests.rs`. Measured on the cartridge in the Pewter Gym: `$00` in the
overworld, `$cd` (`OPP_JR_TRAINER_M`) from the last box of the trainer's challenge through the
219-frame transition and the battle. `controllable` reads it as zero.

View file

@ -1445,6 +1445,66 @@ Nothing presses for the fly and nothing is ranked: one button leaves a pad it co
one wall moves to the tile that earned it, and one walk goes where it can. The decoder, the reward catalog, the adapter one wall moves to the tile that earned it, and one walk goes where it can. The decoder, the reward catalog, the adapter
version, the roles and the compatibility string are untouched. version, the roles and the compatibility string are untouched.
### 12.22 The rung's people are in the room when the screen does not show them (2026-09-23, row 58)
Live on v0.5.3, rank 10, for twenty-five minutes: `GO OBJECTIVE` into the Pewter Gym, `GO OUT`
straight back out, with `GO ITEM`, `GO FRONTIER`, `YES` and `NO` mixed in. Per ten brain minutes
about 93 `GO OUT`, 47 `GO OBJECTIVE`, 200 starts in all, every one `done`, **no reward event of any
kind**, the exploration count frozen at 1,892. Check 10 saw ten distinct names and said nothing.
Surveyed from the live checkpoint with the route probe (`FLY_PROBE_CATCH=route`,
`FLY_PROBE_CATCH_MAP=54`), which reads the room on the fly's Nth arrival.
- **The objective saw the room through the screen.** `objective_targets` read `npcs`, which is
what the cartridge *draws*, and `CheckSpriteAvailability` writes `$ff` into the image index of
every sprite outside a window of the player's coordinate. From the doormat at (4, 13) that window
holds the guide at (7, 10) and nobody else: BROCK at (4, 1) and the Jr. Trainer at (3, 6) are not
drawn. With the guide talked to (12.20), the rung's list was empty, so `GO OBJECTIVE` had nothing
to aim at inside and 12.5's rule -- the ways out are withheld while the rung's person is in the
room -- let `GO OUT` onto the pad. Outside, `GO OBJECTIVE` aimed at the gym's door. The pair
undoes itself in about a second, and nothing on either side of the door earns anything.
- **So the rung reads the people the cartridge hides only for being off the screen.** The window
is a function of `wYCoord`, `wXCoord` and the sprite's own biased coordinates, all already read,
so a sprite whose `$ff` falls outside it is one the cartridge would hide for that reason whatever
else were true, and a sprite the cartridge is not updating does not move
(`state::offscreen_npcs`). A `$ff` *inside* the window, or on a scripted mover, is not the
screen's and is not reported. Only the rung reads the list: a sprite outside the window may also
be a toggleable object switched off, which reads the same, so `GO NPC`, `TALK` and objects keep
what is drawn.
- **Facing any of the rung's people is the arrival.** 12.5 left out only the one ahead, which was
enough for one target; a gym names three, and in front of BROCK `GO OBJECTIVE` still had the
trainer to walk to. A fly facing a person the rung is waiting on has nothing left for a walk to
do, and `TALK` is the press.
Three frames the seam read as the fly's own were the cartridge's, and each wrote a ledger entry that
emptied the room again once the first fix let the fly into it:
- **A warp's tear.** `wCurMap` changes thirty-two frames before the header, the coordinates and the
warp table follow it, while the screen fades, and no joypad bit is set until the fade is over.
The seam read "map 54 at (16, 17)" -- Pewter City's doormat under the gym's id -- as an overworld
and dealt it a pad; a walk started there planned over the wrong map, and what it aimed at went
into the blocked ledger under the gym's id (live: `GO OUT` started and finished in 0.05 s). The
driver now reads a tear as the map byte having changed while the fly still stands on a warp of
the loaded table that leads to the map the byte names, deals it as `Unknown` with an empty pad,
and records no ground from it. Teleport pads -- Saffron Gym, two Silph Co. floors -- do not change
the map byte, so they are never a tear; a tear is bounded at ninety frames all the same.
- **A battle's transition.** Between a trainer's challenge closing and the battle screen there are
219 frames with every joypad and script bit clear. The pad was dealt, a walk toward BROCK pressed
into the animation and gave up after three refused steps -- BROCK blocked for ten brain minutes --
and the Jr. Trainer's conversation read as over, so the trainer the fly then lost to was
"talked to" for the session. `wCurOpponent` is set when a battle is decided and cleared by
`EndOfBattle` with `wIsInBattle`; it is not in the generated table and is derived as the byte
between two that are, both neighbours checked in a test (`macros-wram.md` section 12), and
`controllable` reads it.
- **A trainer walking up.** 12.4 reads a macro the cartridge ended by taking the joypad as a
refusal and wrote the target blocked and the tile pushed at once. A trainer who sees the fly
takes the joypad the same way. The entries now wait until the cartridge gives the joypad back:
in the overworld it was a refusal and is written as before; a battle teaches the ledgers nothing.
Nothing is ranked, nothing presses for the fly, and no button is added to any pad: `GO OBJECTIVE`
has a person to walk to where it had none, `GO OUT` is withheld by 12.5's own rule, and three
frames that were never the fly's deal nothing. 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 ## 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 ## 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.") ## for the Pokécenter. heal should be a macro.")

View file

@ -71,6 +71,7 @@ log_info() {
: "${WD_LOOP_DOMINANCE_PCT:=95}" # or one macro being this share of the window : "${WD_LOOP_DOMINANCE_PCT:=95}" # or one macro being this share of the window
: "${WD_LOOP_MIN_EVENTS:=20}" # floor under the dominance rule (see check 10) : "${WD_LOOP_MIN_EVENTS:=20}" # floor under the dominance rule (see check 10)
: "${WD_LOOP_STALL_PCT:=90}" # decisions that ended refused/blocked/timeout: this share is a stall (row 57) : "${WD_LOOP_STALL_PCT:=90}" # decisions that ended refused/blocked/timeout: this share is a stall (row 57)
: "${WD_LOOP_BUSY_MIN:=100}" # this many decisions with no reward and no new ground, two probes running, is busy going nowhere (row 58)
: "${WD_LOOP_REPORT:=${WD_RUN_DIR}/loop.json}" : "${WD_LOOP_REPORT:=${WD_RUN_DIR}/loop.json}"
mkdir -p "$WD_RUN_DIR" "$WD_STATE_DIR" mkdir -p "$WD_RUN_DIR" "$WD_STATE_DIR"
@ -215,6 +216,11 @@ write_textfile_metrics() {
echo "# HELP fly_loop_done Macros that ended done in the last check-10 window (-1 before the first probe)." echo "# HELP fly_loop_done Macros that ended done in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_done gauge" echo "# TYPE fly_loop_done gauge"
echo "fly_loop_done $(cat "${WD_RUN_DIR}/loop.done" 2>/dev/null || echo -1)" echo "fly_loop_done $(cat "${WD_RUN_DIR}/loop.done" 2>/dev/null || echo -1)"
# Row 58: an undo pair diluted by other macros completes everything and
# earns nothing; the reward events in the window are what say so.
echo "# HELP fly_loop_rewards Reward events in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_rewards gauge"
echo "fly_loop_rewards $(cat "${WD_RUN_DIR}/loop.rewards" 2>/dev/null || echo -1)"
echo "# HELP fly_places_delta Growth in game.uniqueLocations (the exploration count) since the previous check-10 probe. -1 when there is no previous probe to compare against." echo "# HELP fly_places_delta Growth in game.uniqueLocations (the exploration count) since the previous check-10 probe. -1 when there is no previous probe to compare against."
echo "# TYPE fly_places_delta gauge" echo "# TYPE fly_places_delta gauge"
echo "fly_places_delta $(cat "${WD_RUN_DIR}/loop.places_delta" 2>/dev/null || echo -1)" echo "fly_places_delta $(cat "${WD_RUN_DIR}/loop.places_delta" 2>/dev/null || echo -1)"
@ -791,6 +797,16 @@ check_capture_freeze() {
# this probe AND the previous one (two probes, so a single # this probe AND the previous one (two probes, so a single
# unlucky window never flags). # unlucky window never flags).
# #
# Row 58: `GO OBJECTIVE` into the Pewter Gym, `GO OUT` straight back out, for
# 25 minutes, with GO ITEM / GO FRONTIER / YES / NO mixed in — ten distinct
# names, every macro `done`, so neither the sequence rule (four names at
# most) nor zero-progress could fire, and the exploration count sat still.
# What the window did not have was a single reward event. So the stream also
# carries the `reward` events, and one more rule reads them, behind the same
# gate:
# unrewarded — WD_LOOP_BUSY_MIN+ decisions and no reward event in the
# window, on this probe AND the previous one.
#
# The tile rule is what separates a loop from a legitimately repeating # The tile rule is what separates a loop from a legitimately repeating
# explorer (macros-traps.md: `GO FRONTIER` x19 over 93 tiles is a walk longer # explorer (macros-traps.md: `GO FRONTIER` x19 over 93 tiles is a walk longer
# than the frame cap, not a trap), and the watchdog has no tile counter. The # than the frame cap, not a trap), and the watchdog has no tile counter. The
@ -841,7 +857,7 @@ loop_status_fields() {
loop_macro_stream() { loop_macro_stream() {
[ -f "$FLY_EVENT_LOG" ] || return 0 [ -f "$FLY_EVENT_LOG" ] || return 0
tail -n "$WD_LOOP_TAIL_LINES" "$FLY_EVENT_LOG" 2>/dev/null \ tail -n "$WD_LOOP_TAIL_LINES" "$FLY_EVENT_LOG" 2>/dev/null \
| jq -r 'fromjson? // empty | select(.kind == "macro") | "\(.brainMs)\t\(.label)"' -R 2>/dev/null \ | jq -r 'fromjson? // empty | select(.kind == "macro" or .kind == "reward") | "\(.brainMs)\t\(.label)\t\(.kind)"' -R 2>/dev/null \
|| true || true
} }
@ -849,23 +865,28 @@ loop_macro_stream() {
# #
# total US distinct US topCount US period US repeats US windowFrom US # total US distinct US topCount US period US repeats US windowFrom US
# windowTo US topName US block US tail US decisions US refused US blocked US # windowTo US topName US block US tail US decisions US refused US blocked US
# timeout US done # timeout US done US rewards
# #
# `total` counts start events inside the window, `decisions` starts plus # `total` counts start events inside the window, `decisions` starts plus
# refusals (the sequence, `distinct` and `topCount` are over decisions), and # refusals (the sequence, `distinct` and `topCount` are over decisions), and
# the last four count each outcome in the window. `period`/`repeats` describe # the next four count each outcome in the window, and `rewards` the reward
# events in it (row 58). `period`/`repeats` describe
# the shortest repeating block at the END of the sequence (0/0 when nothing # the shortest repeating block at the END of the sequence (0/0 when nothing
# repeats), `block` is that block comma-joined, and `tail` is the last 12 # repeats), `block` is that block comma-joined, and `tail` is the last 12
# names for context. The window ends at the newest macro event's own brain # names for context. The window ends at the newest macro event's own brain
# clock, not at `now`: brain time is the only clock the event log carries. # clock, not at `now`: brain time is the only clock the event log carries.
loop_analyze() { loop_analyze() {
awk -F'\t' -v win="$WD_LOOP_WINDOW_MS" -v maxp="$WD_LOOP_MAX_PERIOD" ' awk -F'\t' -v win="$WD_LOOP_WINDOW_MS" -v maxp="$WD_LOOP_MAX_PERIOD" '
{ ms[NR] = $1 + 0; lbl[NR] = $2; n = NR } # Reward lines are counted and nothing else: the window still ends at the
# newest macro event, as it always has.
$3 == "reward" { rms[++nr] = $1 + 0; next }
{ ms[++n] = $1 + 0; lbl[n] = $2 }
END { END {
if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\0370\0370\0370\0370\0370\n"; exit } if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\0370\0370\0370\0370\0370\0370\n"; exit }
to = ms[n]; from = to - win to = ms[n]; from = to - win
k = 0; distinct = 0; topn = 0; top = "" k = 0; distinct = 0; topn = 0; top = ""
starts = 0; refused = 0; blocked = 0; timeout = 0; done = 0 starts = 0; refused = 0; blocked = 0; timeout = 0; done = 0; rewards = 0
for (q = 1; q <= nr; q++) if (rms[q] >= from) rewards++
for (i = 1; i <= n; i++) { for (i = 1; i <= n; i++) {
if (ms[i] < from) continue if (ms[i] < from) continue
if (lbl[i] ~ / blocked$/) { blocked++; continue } if (lbl[i] ~ / blocked$/) { blocked++; continue }
@ -898,9 +919,9 @@ loop_analyze() {
tailstr = "" tailstr = ""
start = k - 11; if (start < 1) start = 1 start = k - 11; if (start < 1) start = 1
for (j = start; j <= k; j++) tailstr = tailstr (tailstr == "" ? "" : ", ") seq[j] for (j = start; j <= k; j++) tailstr = tailstr (tailstr == "" ? "" : ", ") seq[j]
printf "%d\037%d\037%d\037%d\037%d\037%d\037%d\037%s\037%s\037%s\037%d\037%d\037%d\037%d\037%d\n", \ printf "%d\037%d\037%d\037%d\037%d\037%d\037%d\037%s\037%s\037%s\037%d\037%d\037%d\037%d\037%d\037%d\n", \
starts, distinct, topn, period, repeats, from, to, top, block, tailstr, \ starts, distinct, topn, period, repeats, from, to, top, block, tailstr, \
k, refused, blocked, timeout, done k, refused, blocked, timeout, done, rewards
}' }'
} }
@ -958,20 +979,21 @@ check_loop() {
echo "$delta" > "${WD_RUN_DIR}/loop.places_delta" echo "$delta" > "${WD_RUN_DIR}/loop.places_delta"
local analysis total distinct topn period repeats win_from win_to top block tailstr local analysis total distinct topn period repeats win_from win_to top block tailstr
local decisions refused blocked timeouts completed local decisions refused blocked timeouts completed rewards
analysis="$(loop_macro_stream | loop_analyze)" analysis="$(loop_macro_stream | loop_analyze)"
IFS=$'\037' read -r total distinct topn period repeats win_from win_to top block tailstr \ IFS=$'\037' read -r total distinct topn period repeats win_from win_to top block tailstr \
decisions refused blocked timeouts completed <<< "$analysis" decisions refused blocked timeouts completed rewards <<< "$analysis"
total="${total:-0}"; distinct="${distinct:-0}"; topn="${topn:-0}" total="${total:-0}"; distinct="${distinct:-0}"; topn="${topn:-0}"
period="${period:-0}"; repeats="${repeats:-0}" period="${period:-0}"; repeats="${repeats:-0}"
decisions="${decisions:-0}"; refused="${refused:-0}"; blocked="${blocked:-0}" decisions="${decisions:-0}"; refused="${refused:-0}"; blocked="${blocked:-0}"
timeouts="${timeouts:-0}"; completed="${completed:-0}" timeouts="${timeouts:-0}"; completed="${completed:-0}"; rewards="${rewards:-0}"
echo "$distinct" > "${WD_RUN_DIR}/loop.distinct" echo "$distinct" > "${WD_RUN_DIR}/loop.distinct"
echo "$period" > "${WD_RUN_DIR}/loop.period" echo "$period" > "${WD_RUN_DIR}/loop.period"
echo "$repeats" > "${WD_RUN_DIR}/loop.repeats" echo "$repeats" > "${WD_RUN_DIR}/loop.repeats"
echo "$refused" > "${WD_RUN_DIR}/loop.refused" echo "$refused" > "${WD_RUN_DIR}/loop.refused"
echo "$(( blocked + timeouts ))" > "${WD_RUN_DIR}/loop.blocked" echo "$(( blocked + timeouts ))" > "${WD_RUN_DIR}/loop.blocked"
echo "$completed" > "${WD_RUN_DIR}/loop.done" echo "$completed" > "${WD_RUN_DIR}/loop.done"
echo "$rewards" > "${WD_RUN_DIR}/loop.rewards"
local prev_suspected=0 local prev_suspected=0
[ -f "${WD_RUN_DIR}/loop.suspected" ] && prev_suspected="$(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)" [ -f "${WD_RUN_DIR}/loop.suspected" ] && prev_suspected="$(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)"
@ -992,6 +1014,14 @@ check_loop() {
[ "$decisions" -gt 0 ] && [ "$completed" -eq 0 ] && [ "$grown" -eq 0 ] && idle=1 [ "$decisions" -gt 0 ] && [ "$completed" -eq 0 ] && [ "$grown" -eq 0 ] && idle=1
echo "$idle" > "$idle_file" echo "$idle" > "$idle_file"
# Busy going nowhere (row 58), judged over two probes like zero progress:
# many decisions, not one reward event, no new ground.
local busy_file="${WD_RUN_DIR}/loop.prev_unrewarded" prev_busy=0 busy=0
[ -f "$busy_file" ] && prev_busy="$(cat "$busy_file" 2>/dev/null || echo 0)"
case "${prev_busy:-}" in ''|*[!0-9]*) prev_busy=0 ;; esac
[ "$decisions" -ge "$WD_LOOP_BUSY_MIN" ] && [ "$rewards" -eq 0 ] && [ "$grown" -eq 0 ] && busy=1
echo "$busy" > "$busy_file"
local failed=$(( refused + blocked + timeouts )) local failed=$(( refused + blocked + timeouts ))
local suspected=0 reason="" local suspected=0 reason=""
if [ "$decisions" -gt 0 ] && [ "$grown" -eq 0 ]; then if [ "$decisions" -gt 0 ] && [ "$grown" -eq 0 ]; then
@ -1016,6 +1046,11 @@ check_loop() {
# which is silence (above), not a loop. # which is silence (above), not a loop.
suspected=1 suspected=1
reason="dominant" reason="dominant"
elif [ "$busy" -eq 1 ] && [ "$prev_busy" -eq 1 ]; then
# Row 58: an undo pair diluted by other macros. Every one of them
# completes, none of them earns anything, and the ground stays put.
suspected=1
reason="unrewarded"
fi fi
fi fi
echo "$suspected" > "${WD_RUN_DIR}/loop.suspected" echo "$suspected" > "${WD_RUN_DIR}/loop.suspected"
@ -1047,6 +1082,7 @@ check_loop() {
--argjson blocked "$blocked" \ --argjson blocked "$blocked" \
--argjson timeouts "$timeouts" \ --argjson timeouts "$timeouts" \
--argjson completed "$completed" \ --argjson completed "$completed" \
--argjson rewards "$rewards" \
--argjson windowFrom "${win_from:-0}" \ --argjson windowFrom "${win_from:-0}" \
--argjson windowTo "${win_to:-0}" \ --argjson windowTo "${win_to:-0}" \
--argjson windowMs "$WD_LOOP_WINDOW_MS" \ --argjson windowMs "$WD_LOOP_WINDOW_MS" \
@ -1075,6 +1111,7 @@ check_loop() {
macroStarts: $total, macroStarts: $total,
decisions: $decisions, decisions: $decisions,
outcomes: { done: $completed, blocked: $blocked, timeout: $timeouts, refused: $refused }, outcomes: { done: $completed, blocked: $blocked, timeout: $timeouts, refused: $refused },
rewards: $rewards,
tail: (if $tail == "" then [] else ($tail | split(", ")) end) tail: (if $tail == "" then [] else ($tail | split(", ")) end)
}, },
dominant: { name: (if $dominant == "" then null else $dominant end), dominant: { name: (if $dominant == "" then null else $dominant end),
@ -1098,7 +1135,9 @@ check_loop() {
local mins=$(( WD_LOOP_WINDOW_MS / 60000 )) local mins=$(( WD_LOOP_WINDOW_MS / 60000 ))
if [ "$suspected" -eq 1 ]; then if [ "$suspected" -eq 1 ]; then
if [ "$reason" = "stalled" ] || [ "$reason" = "zero-progress" ]; then if [ "$reason" = "unrewarded" ]; then
log_err "loop suspected (unrewarded): ${decisions} decisions and no reward event in the last ${mins} brain minutes, two probes running — ${completed} done, top [${top}] x${topn}, ${distinct} distinct name(s), tail [${tailstr}], exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — macros that complete and earn nothing are an undo pair or a ring. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
elif [ "$reason" = "stalled" ] || [ "$reason" = "zero-progress" ]; then
log_err "loop suspected (${reason}): [${shown}] — ${decisions} decisions in the last ${mins} brain minutes, ${total} started, ${completed} done, ${refused} refused, $(( blocked + timeouts )) blocked or timed out, exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a pad whose buttons cannot run is a macro bug. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'." log_err "loop suspected (${reason}): [${shown}] — ${decisions} decisions in the last ${mins} brain minutes, ${total} started, ${completed} done, ${refused} refused, $(( blocked + timeouts )) blocked or timed out, exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a pad whose buttons cannot run is a macro bug. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
elif [ "$reason" = "sequence" ]; then elif [ "$reason" = "sequence" ]; then
log_err "loop suspected: [${shown}] x${repeats} (period ${period}) in the last ${mins} brain minutes — ${total} macro starts, ${distinct} distinct name(s), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a loop is a macro target-choice bug and a bounce would only restore the same loop. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'." log_err "loop suspected: [${shown}] x${repeats} (period ${period}) in the last ${mins} brain minutes — ${total} macro starts, ${distinct} distinct name(s), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a loop is a macro target-choice bug and a bounce would only restore the same loop. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."

View file

@ -2655,3 +2655,107 @@ the fly is no longer held in the pocket.
- `infra/tests/lint.sh`: ALL CHECKS PASSED, the two new check-10 cases and the de-PII guard included. - `infra/tests/lint.sh`: ALL CHECKS PASSED, the two new check-10 cases and the de-PII guard included.
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`** -- byte-identical to the - `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`** -- byte-identical to the
base `d5d9249`. Decoder, reward catalog, adapter version and roles untouched. base `d5d9249`. Decoder, reward catalog, adapter version and roles untouched.
## 2026-09-23, row 58: the gym's door, in and out
### What was live
Map 2 (Pewter City) and map 54 (Pewter Gym), rank 10, v0.5.3, for twenty-five minutes: `GO OBJECTIVE`
`done` into the gym, `GO OUT` `done` straight back out, `GO ITEM` / `GO FRONTIER` / `YES` / `NO`
mixed in. Per ten brain minutes about 93 `GO OUT`, 47 `GO OBJECTIVE`, 200 starts, 0-5 refused or
blocked, **no reward event of any kind**, `uniqueLocations` frozen at 1,892. `GO OUT` often started
and finished inside 0.05-0.17 s. Check 10 saw ten distinct names and never flagged.
### The survey: the room on the Nth arrival
`FLY_PROBE_CATCH=route` drives the real palette from the checkpoint; `FLY_PROBE_CATCH_MAP=54`
stops it forty frames into the fly's Nth arrival on the gym and dumps the room with every person's
ledger entries. From the bare checkpoint, preferring `GO OBJECTIVE`, the base walks the pair
itself inside seven brain minutes: **`GO OUT` 1,118, `GO OBJECTIVE` 583 in 33 brain minutes**, the
gym pad `["GO OUT"]`, and on the doormat:
- `objective_targets` empty, `person_targets` = the guide at (7, 10), talked;
- BROCK at (4, 1) and the Jr. Trainer at (3, 6) **absent**: `CheckSpriteAvailability` had written
`$ff` into their image index because they are outside the window of (4, 13);
- pushed tiles `(54, (16, 17))` and `(2, (5, 13))` -- Pewter's gym door and the gym's doormat,
recorded under the *other* map's id.
### Why nothing inside the gym was offered, and why the door was
| # | trap | trigger | test | fix, or why it is left |
| --- | --- | --- | --- | --- |
| 58 | the rung's list of people is read from the sprites the cartridge draws, so a person off the screen is not in the room; with the one drawn person talked to, `GO OBJECTIVE` has nothing inside, 12.5 lets `GO OUT` onto the pad, and outside `GO OBJECTIVE` aims at the door | any rung earned by a person who is more than four rows or five columns from where the fly arrives; the Pewter Gym from its doormat | `the_rungs_people_are_in_the_room_when_the_screen_does_not_show_them`, `only_the_rung_reads_people_off_the_screen`, `a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map`, `the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint` (ROM) | **fixed**: `state::offscreen_npcs` reports a `$ff` sprite whose coordinates fall outside `CheckSpriteAvailability`'s own window (movement byte `WALK` or above), and only the rung's list reads it. `docs/design/macros.md` 12.22, `macros-wram.md` section 12 |
| 58b | in front of one of the rung's people, `GO OBJECTIVE` still walks to another | a room with more than one of them: the gym's leader and trainer | `facing_one_of_the_rungs_people_is_the_arrival` | **fixed**: facing any of them is the arrival, and `TALK` is the press |
| 58c | a warp's tear -- `wCurMap` changed, header, coordinates and warp table not yet -- reads as a controllable overworld for thirty-two frames; a pad is dealt, a walk plans over the wrong map, and its target and tile go into the ledgers under the new map's id | every warp; live, `GO OUT` started and finished in 0.05 s | `a_warps_tear_deals_no_pad`, `a_teleport_pad_is_not_a_tear` | **fixed** in the driver: the map byte changed and the fly still stands on a loaded warp into the map the byte names (a doormat's `LAST_MAP` under a town's id included) is `Unknown` with an empty pad, bounded at ninety frames. Teleport pads never change the map byte |
| 58d | the 219 frames of a battle transition read as a controllable overworld: a walk toward the leader presses into the animation and blocks him for ten brain minutes, and the trainer's conversation reads as over, so a trainer the fly then loses to is "talked to" for the session | every trainer battle, and every wild one | `a_battle_decided_and_not_yet_begun_is_the_cartridges` | **fixed**: `controllable` reads `wCurOpponent`, set when a battle is decided and cleared by `EndOfBattle`; derived as `wBattleType - 1`, both neighbours asserted |
| 58e | a trainer walking up to the fly is read as the cartridge refusing the step (12.4): the target is blocked and the tile pushed on the spot | every trainer's line of sight a walk crosses | `a_trainer_walking_up_teaches_the_ledgers_nothing`, `a_scripted_push_back_records_the_tile_it_happened_on`, `a_walk_the_cartridge_pushes_back_excludes_what_it_was_walking_to` | **fixed**: the entries wait for the cartridge to give the joypad back; the overworld is a refusal, written as before, and a battle writes nothing |
| 58f | check 10 cannot see an undo pair diluted by other names | ten distinct names, every macro `done`, 211 decisions in ten brain minutes | `lint.sh` check 10 cases 7 and 8 | **fixed**: `unrewarded`, 100+ decisions and no reward event on two probes with no new ground. Against the live row-58 log it flags; the rules before it did not |
### Before and after
The route survey, same checkpoint, 120,000 frames (33 brain minutes), base `main` at `174dc7e`
(row 57 merged) against this branch:
| measure | base | branch, `GO OBJECTIVE` preferred | branch, `TALK` preferred |
| --- | ---: | ---: | ---: |
| `GO OUT` done | **1,118** | **0** | **0** |
| `GO OBJECTIVE` done on the gym | **583** (all maps) | **4** | **4** |
| rung at the end | 10 | **11, BOULDER BADGE** | **11, BOULDER BADGE** |
| pushed tiles under the wrong map's id | `(54, (16, 17))`, `(2, (5, 13))` and four more doormats | none | none |
The ROM-gated run, `the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint`, 30.1
brain minutes on the stub rotation: the base goes through the door once, is back out in 309
frames and never above row 11 (fails); the branch goes through four times, walks straight back out
once, spends 30,879 frames in the gym and stands on row 2 beside BROCK (passes). Row 56's
`the_fly_leaves_the_pewter_gym_guides_ring_from_the_rung_ten_checkpoint` now reaches rung 11 at
8.45 brain minutes, which it never did; row 57's pocket test passes, and its part one no longer
walls `(54, (16, 17))`, the tear's tile.
The trap hunt, 30 brain minutes each from the same checkpoint and seed, the stub rotation
(`FLY_TRAP_STUB=1`; the brain is stepped and the readout replaced), base `174dc7e` against this
branch:
| measure | base | branch |
| --- | ---: | ---: |
| distinct (map, tile) | 329 | **437** |
| windows flagged | 17 | 23 |
| macros started / done | 139 / 137 | 131 / 129 |
| `GO OUT` done | 3 | 0 |
| frames between battle turns | 51,328 | 53,336 |
| rung reached | 10 | 10 |
**The stub does not walk the ring on either arm** -- it spends half of both runs in battles and
goes through the gym's door once -- so the hunt says little about this row either way, and the
flagged-window count rises (17 -> 23) on battle time, the same judgement as rows 50 and 56. The
route survey above is the reproduction; the hunt is reported, not smoothed.
### Residuals, named rather than worked around
- **After the badge, a new pair at the Pewter/Route 3 edge.** With the rung earned, the objective
is Mt. Moon (map 59), and on Route 3 `GO OBJECTIVE` has nothing to aim at: `geography` carries
Route 3's neighbour as Route 4 to the **east** and a Mt. Moon door **on Route 3**, while the
disassembly (`data/maps/headers/Route3.asm`, `objects/Route4.asm`) and the cartridge
(`wCurMapConnections` north and west, row 54b) say Route 4 is **north** and Mt. Moon's doors
are **on Route 4**, whose ground is in two pieces like Route 2's. The survey that prefers
`GO OBJECTIVE` walks `GO OBJECTIVE` east into Route 3 and `GO ROUTE` back west 538 times from
21 brain minutes. It is row 54b's residual and it needs a `SPLIT` row for Route 4; the next
brief. Check 10's new `unrewarded` rule sees it.
- **A toggleable object switched off reads as present from outside the window.** Only the rung's
own list reads the off-screen people; among the ladder's person places, only Oak's lab (behind
this run) and Viridian Gym carry toggleable people.
- **The tear is read stateful and bounded**: an arrival onto a warp into the map it arrived on,
under a map byte that changed, is a tear for at most ninety frames.
- **The real-brain hunt was not run to the end on this branch**: two 30-minute arms were started
and stopped at a quarter done when row 57 merged and the branch was rebased; the box was loaded
at twelve. The stub arms above are the hunt.
### Gates
- `cargo test --workspace` with `FLY_ROM` and `FLY_DATASET`: 1,267 passed, 1 failed --
`flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
the known boot-time failure, identical on the base.
- `cargo clippy --all-targets`: **0 warnings**.
- `npm test` 663 passed; `npm run typecheck` clean.
- `infra/tests/lint.sh`: ALL CHECKS PASSED, check 10's two new cases and the de-PII guard included.
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`**, byte-identical to the
base. Decoder, reward catalog, adapter version and roles untouched.

View file

@ -602,11 +602,14 @@ pct exec <ctid> -- cat /run/fly/wd/loop.json | jq .
| `fly_loop_refused` | macro presses refused in the window: a bound button pressed, nothing run | | `fly_loop_refused` | macro presses refused in the window: a bound button pressed, nothing run |
| `fly_loop_blocked` | macros that ended `blocked` or `timeout` in the window | | `fly_loop_blocked` | macros that ended `blocked` or `timeout` in the window |
| `fly_loop_done` | macros that ended `done` in the window | | `fly_loop_done` | macros that ended `done` in the window |
| `fly_loop_rewards` | reward events in the window |
The flag needs **both** halves: at most 3 distinct macro names with the block repeating 20+ The flag needs **both** halves: at most 3 distinct macro names with the block repeating 20+
times, one macro at 95%+ of the window's decisions, 90%+ of 20+ decisions ending refused, times, one macro at 95%+ of the window's decisions, 90%+ of 20+ decisions ending refused,
blocked or timed out (`stalled`), or decisions with no `done` among them on two probes in a row blocked or timed out (`stalled`), or decisions with no `done` among them on two probes in a row
(`zero-progress`) — **and** no growth in the exploration count. A decision is a `start` or a (`zero-progress`), or 100+ decisions with no reward event among them on two probes in a row
(`unrewarded`, row 58: `GO OBJECTIVE` in and `GO OUT` out of one door, diluted by eight other
names, every macro `done`) — **and** no growth in the exploration count. A decision is a `start` or a
`refused`: a refused press starts nothing, which is why counting starts alone read row 57's `refused`: a refused press starts nothing, which is why counting starts alone read row 57's
pad (`GO ROUTE refused` ~740 times in ten brain minutes, `macros-traps.md`) as one start and pad (`GO ROUTE refused` ~740 times in ten brain minutes, `macros-traps.md`) as one start and
one name. A one name. A

View file

@ -1199,10 +1199,51 @@ LPCAT
fail "check 10: the zero-progress case gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected), journal: $(cat "$lp_fixture/journal.log")" fail "check 10: the zero-progress case gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected), journal: $(cat "$lp_fixture/journal.log")"
fi fi
# (7) Row 58: the gym door, in and out. GO OBJECTIVE / GO OUT diluted by
# eight other names, every macro `done`, no reward event, no new ground --
# neither the four-name sequence rule, dominance nor zero-progress fires.
# Two probes of it flag; the same window with one reward in it does not.
lp_reset
lp_cycle 20 "GO OBJECTIVE" "GO OUT" "GO OUT" "GO ITEM" "GO OBJECTIVE" "GO OUT" \
"GO FRONTIER" "YES" "NO" "GO ROUTE" "NEXT" "TALK" "GO SHOP" \
| lp_outcomes "$lp_fixture/events.jsonl" "done"
lp_status "$lp_fixture/status.json" 1892
lp_pass
lp_pass
lp_first="$(lp_metric fly_loop_suspected)"
lp_pass
if [ "$lp_first" = "0" ] && [ "$(lp_metric fly_loop_suspected)" = "1" ] \
&& [ "$(lp_metric fly_loop_rewards)" = "0" ] \
&& [ "$(lp_metric fly_loop_distinct_macros)" = "10" ] \
&& grep -q 'loop suspected (unrewarded): 260 decisions and no reward event' "$lp_fixture/journal.log"; then
pass "check 10: an undo pair diluted by eight other names, all done, no reward over two probes flags as unrewarded"
else
fail "check 10: the row-58 log gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected) rewards=$(lp_metric fly_loop_rewards) distinct=$(lp_metric fly_loop_distinct_macros), journal: $(cat "$lp_fixture/journal.log")"
fi
if lp_report="$(jq -e -r '[.reason, (.window.decisions|tostring), (.window.rewards|tostring), .action] | join(" ")' "$lp_fixture/run/loop.json" 2>/dev/null)" \
&& [ "$lp_report" = "unrewarded 260 0 none" ]; then
pass "check 10: loop.json carries the reward events in the window"
else
fail "check 10: loop.json read back as '${lp_report:-UNREADABLE}' — expected 'unrewarded 260 0 none'"
fi
lp_reset
{ cat "$lp_fixture/events.jsonl"
printf '{"id":999998,"wallMs":1758000999998,"brainMs":150000,"kind":"reward","label":"WILD KO 54:1:1","value":0.1,"rewardKind":"wildwin"}\n'; } \
> "$lp_fixture/events-rewarded.jsonl"
mv -f "$lp_fixture/events-rewarded.jsonl" "$lp_fixture/events.jsonl"
lp_pass
lp_pass
lp_pass
if [ "$(lp_metric fly_loop_suspected)" = "0" ] && [ "$(lp_metric fly_loop_rewards)" = "1" ]; then
pass "check 10: the same busy window with one reward in it does not flag"
else
fail "check 10: a rewarded busy window gave suspected=$(lp_metric fly_loop_suspected) rewards=$(lp_metric fly_loop_rewards)"
fi
# The ethos, asserted rather than reviewed: over every case above, check 10 # The ethos, asserted rather than reviewed: over every case above, check 10
# restarted nothing. It reports; a human or a review agent decides. # restarted nothing. It reports; a human or a review agent decides.
if [ ! -s "$lp_fixture/systemctl.log" ]; then if [ ! -s "$lp_fixture/systemctl.log" ]; then
pass "check 10: never acts — no unit was restarted across any of the six cases" pass "check 10: never acts — no unit was restarted across any of the eight cases"
else else
fail "check 10 ACTED, which it must never do: $(cat "$lp_fixture/systemctl.log")" fail "check 10 ACTED, which it must never do: $(cat "$lp_fixture/systemctl.log")"
fi fi

View file

@ -365,6 +365,24 @@ impl Wram {
self.set(ram::wNumSprites, count) self.set(ram::wNumSprites, count)
} }
/// One sprite the cartridge is not drawing: `$ff` in its image index, which is what
/// `CheckSpriteAvailability` writes for a sprite off the screen or switched off, and the
/// movement byte that decides whether the window test applies to it (row 58).
pub fn npc_undrawn(
&mut self,
slot: u8,
picture: u8,
x: u8,
y: u8,
movement: u8,
) -> &mut Self {
self.npc(slot, picture, x, y, 0x00);
let data1 = ram::wSpriteStateData1 + u16::from(slot) * poke::SPRITE_BYTES;
let data2 = ram::wSpriteStateData2 + u16::from(slot) * poke::SPRITE_BYTES;
self.set(data1 + poke::SPRITE_IMAGE_INDEX, poke::SPRITE_NOT_DRAWN)
.set(data2 + poke::SPRITE_MOVEMENT_BYTE, movement)
}
/// The current map's sign table: `bg_event`s, `Y, X` per entry with no bias, and a text id /// The current map's sign table: `bg_event`s, `Y, X` per entry with no bias, and a text id
/// each. /// each.
pub fn signs(&mut self, signs: &[(u8, u8, u8)]) -> &mut Self { pub fn signs(&mut self, signs: &[(u8, u8, u8)]) -> &mut Self {

View file

@ -18,13 +18,20 @@ use crate::macros::{
use super::super::mapgrid::MapGrids; use super::super::mapgrid::MapGrids;
use super::super::state::PokeState; use super::super::state::PokeState;
use super::cartridge::{Areas, Frontiers, MacroState, Pushed, Stood, Talked, Targets, Tile}; use super::cartridge::{
Areas, Frontiers, LAST_MAP, MacroState, Pushed, Stood, Talked, Targets, Tile, outdoors,
};
use super::geography; use super::geography;
use super::executor::{MacroAbort, MacroMachine, Refusal}; use super::executor::{MacroAbort, MacroMachine, Refusal};
use super::palette::{self, MacroId, Palette}; use super::palette::{self, MacroId, Palette};
use super::plan; use super::plan;
use super::state::{GameState, Scene}; use super::state::{GameState, Scene};
/// The longest a warp's tear is honoured (row 58): the thirty-two frames measured at the Pewter Gym
/// door, with room for a slower fade, and short enough that a false reading costs a second and a
/// half of an empty pad rather than a stall.
pub const TEAR_FRAMES: u16 = 90;
/// The macro palette over Pokémon Red. /// The macro palette over Pokémon Red.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PokemonPalette { pub struct PokemonPalette {
@ -99,6 +106,19 @@ pub struct PokemonPalette {
nearest: Option<(u8, u32)>, nearest: Option<(u8, u32)>,
/// Whether the last `observe` was the frame that number fell on. /// Whether the last `observe` was the frame that number fell on.
nearer: bool, nearer: bool,
/// The map of the last frame that was not a warp's tear, and how many tear frames have run
/// since (row 58, [`PokemonPalette::tear`], [`TEAR_FRAMES`]).
///
/// Measured on the cartridge at the Pewter Gym's door: `wCurMap` changes to the new map
/// **thirty-two frames** before the map header, the coordinates and the warp table follow it,
/// while the screen fades. On those frames every reading in the seam describes the map the fly
/// just left under the new map's id -- the player "on map 54 at (16, 17)", which is Pewter
/// City's doormat -- and nothing sets the joypad bits `controllable` reads until the fade is
/// over, so the scene read `Overworld` and a pad was dealt. A walk started there plans over
/// the wrong map, ends when the cartridge takes the joypad at the end of the fade, and the
/// ledgers wrote what it had been aiming at against the new map's id.
settled: Option<u8>,
tear_frames: u16,
/// The brain clock of the frame being decided, from [`MacroPalette::clock`]. /// The brain clock of the frame being decided, from [`MacroPalette::clock`].
/// ///
/// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather /// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather
@ -126,10 +146,46 @@ impl PokemonPalette {
grids: MapGrids::default(), grids: MapGrids::default(),
nearest: None, nearest: None,
nearer: false, nearer: false,
settled: None,
tear_frames: 0,
now_ms: 0.0, now_ms: 0.0,
} }
} }
/// Whether this frame is a warp's tear (row 58): the map byte has changed since the last
/// settled frame, and the fly still stands on a warp of the *loaded* table that leads to the
/// map the byte now names. Updates the settled map on every frame that is not one.
///
/// On a tear the warp table is still the map the fly just left, so the tile underfoot is the
/// door it walked through: Pewter City's (16, 17), whose destination is the gym, under the
/// gym's id; or a building's doormat, whose destination is `LAST_MAP`, under the id of the
/// town outside. Once the header loads, the table is the new map's and the tile underfoot is
/// the arrival warp, which leads back where the fly came from -- so the reading ends by itself.
/// Measured: thirty-two frames at the gym door each way.
///
/// The map byte having changed is what keeps the teleport pads of Saffron Gym and Silph Co. --
/// the three maps in Red with a warp to themselves -- from reading as a tear: a pad moves the
/// fly without changing the map. Bounded by [`TEAR_FRAMES`] all the same, because an empty pad
/// that did not end would be a fly that waits for ever.
fn tear(&mut self, state: &mut dyn MacroState) -> bool {
let Some(player) = state.player() else { return false };
let changed = self.settled.is_some_and(|was| was != player.map);
let torn = changed
&& self.tear_frames < TEAR_FRAMES
&& state.warps().iter().any(|warp| {
(warp.x, warp.y) == (player.x, player.y)
&& (warp.destination_map == player.map
|| (warp.destination_map == LAST_MAP && outdoors(player.map)))
});
if torn {
self.tear_frames += 1;
} else {
self.tear_frames = 0;
self.settled = Some(player.map);
}
torn
}
/// Frames the running macro has spent, for a log line. /// Frames the running macro has spent, for a log line.
pub fn frames(&self) -> u32 { pub fn frames(&self) -> u32 {
self.machine.frames() self.machine.frames()
@ -217,7 +273,7 @@ impl PokemonPalette {
} }
// A tile the cartridge drove the fly off: no window, because the map is like that until // A tile the cartridge drove the fly off: no window, because the map is like that until
// the event that unlocks it, and nothing here knows which event that is (row 37). // the event that unlocks it, and nothing here knows which event that is (row 37).
if let Some((map, tile)) = self.machine.take_pushed() { while let Some((map, tile)) = self.machine.take_pushed() {
self.pushed.record(map, tile); self.pushed.record(map, tile);
} }
// A refusal from where the fly is standing: that button is not dealt again from this tile // A refusal from where the fly is standing: that button is not dealt again from this tile
@ -243,6 +299,10 @@ impl MacroPalette for PokemonPalette {
} }
fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed { fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed {
let torn = {
let mut state = PokeState::new(memory);
self.tear(&mut state)
};
let (scene, bindings, standing, stepping, approach) = { let (scene, bindings, standing, stepping, approach) = {
let Self { let Self {
machine, machine,
@ -265,7 +325,10 @@ impl MacroPalette for PokemonPalette {
// `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the // `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the
// palette and the scene the feed reports cannot disagree about which frame they are // palette and the scene the feed reports cannot disagree about which frame they are
// for. // for.
let scene = state.scene(); // A warp's tear is a warp in flight: the cartridge is driving and the seam's readings
// are the last map's under the new map's id, which is section 12.13's `Unknown` with
// nothing on screen -- an empty pad the fly waits out, for thirty-two frames (row 58).
let scene = if torn { Scene::Unknown } else { state.scene() };
// Whether a conversation has ended, and how, is a question about the frames *after* // Whether a conversation has ended, and how, is a question about the frames *after*
// the `TALK` gave the buttons back, so the machine is given every frame rather than // the `TALK` gave the buttons back, so the machine is given every frame rather than
// only the ones it owns (`docs/design/macros.md` section 12.4). // only the ones it owns (`docs/design/macros.md` section 12.4).
@ -279,7 +342,7 @@ impl MacroPalette for PokemonPalette {
// master: while the cartridge is walking it -- a warp in flight, a ledge hop, a script // master: while the cartridge is walking it -- a warp in flight, a ledge hop, a script
// -- the coordinates and the loaded map header are from different frames, and a tile // -- the coordinates and the loaded map header are from different frames, and a tile
// recorded from that pair is a tile of nowhere. // recorded from that pair is a tile of nowhere.
let standing = (!state.scripted()).then(|| state.player()).flatten(); let standing = (!state.scripted() && !torn).then(|| state.player()).flatten();
// And the tile the step in flight is landing on (row 54). Read from the same frame and // And the tile the step in flight is landing on (row 54). Read from the same frame and
// behind the same "the fly is its own master" gate as the ground itself. // behind the same "the fly is its own master" gate as the ground itself.
let stepping = standing.and_then(|_| state.stepping_onto()); let stepping = standing.and_then(|_| state.stepping_onto());
@ -455,7 +518,7 @@ impl MacroPalette for PokemonPalette {
let _ = self.machine.take_reached(); let _ = self.machine.take_reached();
// A rollback is not the map pushing the fly anywhere, nor its frontier going out of // A rollback is not the map pushing the fly anywhere, nor its frontier going out of
// reach: the fly is about to be standing somewhere else. // reach: the fly is about to be standing somewhere else.
let _ = self.machine.take_pushed(); while self.machine.take_pushed().is_some() {}
let _ = self.machine.take_exhausted(); let _ = self.machine.take_exhausted();
let _ = self.machine.take_refused(); let _ = self.machine.take_refused();
// The cached palette was dealt for a frame that is being thrown away. Dropping it makes // The cached palette was dealt for a frame that is being thrown away. Dropping it makes
@ -525,6 +588,57 @@ mod tests {
} }
} }
#[test]
fn a_warps_tear_deals_no_pad() {
// Row 58, measured at the Pewter Gym's door: `wCurMap` names the gym for thirty-two frames
// while the header, the coordinates and the warp table are still Pewter City's -- "map 54
// at (16, 17)", which is the town's doormat. A pad dealt there started a walk over the
// wrong map, and what it was aiming at went into the ledgers under the gym's id.
let mut wram = Wram::overworld();
wram.map(maps::PEWTER_CITY, 20, 18, 16, 18)
.warps(&[(16, 17, 0, maps::PEWTER_GYM), (29, 13, 0, maps::PEWTER_MUSEUM_1F)]);
let mut palette = PokemonPalette::new(7);
let settled = palette.observe(&mut wram, &NoLedger);
assert_eq!(settled.scene, SceneId::Overworld);
// The step onto the door lands and the map byte changes; nothing else has loaded.
wram.map(maps::PEWTER_GYM, 20, 18, 16, 17);
let torn = palette.observe(&mut wram, &NoLedger);
assert_eq!(torn.scene, SceneId::Unknown, "a warp in flight");
assert!(torn.bindings.is_empty(), "and nothing to press: {:?}", torn.bindings);
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Unknown);
// The header loads: the gym's own size, its doormat, its own table.
wram.map(maps::PEWTER_GYM, 5, 7, 4, 13).warps(&[(4, 13, 2, 0xff), (5, 13, 2, 0xff)]);
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Overworld);
// And out again: the doormat's `LAST_MAP` under the town's id is the same tear.
wram.map(maps::PEWTER_CITY, 5, 7, 4, 13);
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Unknown);
}
#[test]
fn a_teleport_pad_is_not_a_tear() {
// Saffron Gym and two Silph Co. floors warp to themselves. Standing on a pad whose
// destination is the map the fly is on is an ordinary frame there, and an empty pad on it
// would be a fly that waits for ever: the map byte did not change, so it is not a tear.
let mut wram = Wram::overworld();
wram.map(0xb2, 10, 9, 1, 1).warps(&[(1, 1, 3, 0xb2), (5, 5, 0, 0xb2)]);
let mut palette = PokemonPalette::new(7);
for _ in 0..3 {
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Overworld);
}
// And a tear that does not end is still bounded.
wram.map(0x02, 10, 9, 1, 1).warps(&[(1, 1, 0, 0x02)]);
let mut torn = 0;
for _ in 0..(TEAR_FRAMES + 10) {
if palette.observe(&mut wram, &NoLedger).scene == SceneId::Unknown {
torn += 1;
}
}
assert_eq!(torn, u32::from(TEAR_FRAMES), "at most {TEAR_FRAMES} frames");
}
#[test] #[test]
fn the_tile_a_step_is_landing_on_is_ground_the_run_has_covered() { fn the_tile_a_step_is_landing_on_is_ground_the_run_has_covered() {
// Row 54 of `infra/docs/macros-traps.md`. `wXCoord` and `wYCoord` are the tile the step // Row 54 of `infra/docs/macros-traps.md`. `wXCoord` and `wYCoord` are the tile the step

View file

@ -541,6 +541,16 @@ struct PendingTalk {
at: Tile, at: Tile,
} }
/// What a macro the cartridge ended by taking the joypad earned, held until the cartridge gives
/// the joypad back ([`MacroMachine::pending_push`], row 58).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PendingPush {
/// The tile the fly was driven off, for the pushed ledger (row 37).
tile: Option<(u8, Tile)>,
/// The target the macro was aimed at, for the blocked ledger (section 12.4).
target: Option<(u8, TargetKey)>,
}
/// A walk the frame cap cut short, as the next start needs it. /// A walk the frame cap cut short, as the next start needs it.
/// ///
/// Keyed by the target rather than by the macro, because that is what a resumed walk *is*: the /// Keyed by the target rather than by the macro, because that is what a resumed walk *is*: the
@ -589,7 +599,7 @@ pub struct MacroMachine {
/// and the blocked ledger could only say it about the target the walk was aimed at. Recorded /// and the blocked ledger could only say it about the target the walk was aimed at. Recorded
/// from the frame the push is seen, because by the time the next macro starts the fly has been /// from the frame the push is seen, because by the time the next macro starts the fly has been
/// walked somewhere else. /// walked somewhere else.
pushed_tile: Option<(u8, Tile)>, pushed_tile: Vec<(u8, Tile)>,
/// A refusal the route search or the precondition made, and where the fly stood for it -- /// A refusal the route search or the precondition made, and where the fly stood for it --
/// `(map, slot, tile)`, waiting to be taken into the session's ledger /// `(map, slot, tile)`, waiting to be taken into the session's ledger
/// ([`super::cartridge::Targets::record_refused`], row 57 of `infra/docs/macros-traps.md`). /// ([`super::cartridge::Targets::record_refused`], row 57 of `infra/docs/macros-traps.md`).
@ -637,6 +647,21 @@ pub struct MacroMachine {
/// the next press will not undo. One hold of frames is the window, because that is how long /// 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. /// the fly has to choose again; anything later and something else happened in between.
pending_answer: Option<PendingAnswer>, pending_answer: Option<PendingAnswer>,
/// A macro the cartridge ended by taking the joypad, whose ledger entries wait for the
/// cartridge to give it back (row 58).
///
/// Section 12.4 and row 37 read "the cartridge took the joypad" as the cartridge *refusing*
/// the step -- the Viridian gate's "This is private property!" and the walk back -- and wrote
/// the target into the blocked ledger and the tile into the pushed one on the spot. A trainer
/// who sees the fly takes the joypad the same way: the "!", the walk up, the challenge. In the
/// Pewter Gym that cost BROCK: the fly walked toward him past the Jr. Trainer's line of sight,
/// the trainer's walk ended the macro, BROCK went into the blocked ledger for ten brain
/// minutes and the tile the walk set out from into the pushed one for the session -- and a fly
/// that lost the battle and walked back found the leader excluded and the way out on the pad.
/// What the cartridge does when it gives the joypad back is what says which it was: back in
/// the overworld is a refusal and is written as one; a battle is a battle, and nothing about
/// the target or the ground is learned from it.
pending_push: Vec<PendingPush>,
/// A finished `TALK`'s target, waiting to be taken into the session's talked ledger. /// 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 /// The machine records rather than keeps: the ledger is the driver's
@ -664,12 +689,13 @@ impl MacroMachine {
blocked: Vec::new(), blocked: Vec::new(),
reached: None, reached: None,
exhausted: None, exhausted: None,
pushed_tile: None, pushed_tile: Vec::new(),
refused_at: None, refused_at: None,
timed_out: None, timed_out: None,
resume: VecDeque::new(), resume: VecDeque::new(),
pending_talk: None, pending_talk: None,
pending_answer: None, pending_answer: None,
pending_push: Vec::new(),
talked: None, talked: None,
rng: if seed == 0 { 1 } else { seed }, rng: if seed == 0 { 1 } else { seed },
} }
@ -903,8 +929,11 @@ impl MacroMachine {
} }
/// The tile a scripted push-back earned, taken rather than read (row 37). /// The tile a scripted push-back earned, taken rather than read (row 37).
///
/// Call it until it answers `None`: the entries a script held back are written together when
/// it gives the joypad back (row 58).
pub fn take_pushed(&mut self) -> Option<(u8, Tile)> { pub fn take_pushed(&mut self) -> Option<(u8, Tile)> {
self.pushed_tile.take() if self.pushed_tile.is_empty() { None } else { Some(self.pushed_tile.remove(0)) }
} }
/// Where the last `no route` or `precondition` refusal happened, taken rather than read /// Where the last `no route` or `precondition` refusal happened, taken rather than read
@ -936,7 +965,7 @@ impl MacroMachine {
self.reached = None; self.reached = None;
// A rollback is not the map pushing the fly anywhere, and it is not the frontier being // A rollback is not the map pushing the fly anywhere, and it is not the frontier being
// out of reach either: the fly is about to be somewhere else entirely. // out of reach either: the fly is about to be somewhere else entirely.
self.pushed_tile = None; self.pushed_tile.clear();
self.exhausted = None; self.exhausted = None;
self.refused_at = None; self.refused_at = None;
self.timed_out = None; self.timed_out = None;
@ -948,6 +977,8 @@ impl MacroMachine {
self.pending_talk = None; self.pending_talk = None;
// Nor is it a prompt reopening: the frames the answer was made in are being thrown away. // Nor is it a prompt reopening: the frames the answer was made in are being thrown away.
self.pending_answer = None; self.pending_answer = None;
// Nor the cartridge refusing a step: the frames it happened in are being thrown away too.
self.pending_push.clear();
} }
/// Whether the fly is standing somewhere other than where the running macro began. /// Whether the fly is standing somewhere other than where the running macro began.
@ -976,6 +1007,7 @@ impl MacroMachine {
/// - the fly answered `NO` — not talked, and that one is decided in [`MacroMachine::finish`]. /// - the fly answered `NO` — not talked, and that one is decided in [`MacroMachine::finish`].
pub fn observe_frame(&mut self, state: &mut dyn MacroState) { pub fn observe_frame(&mut self, state: &mut dyn MacroState) {
self.observe_answer(state); self.observe_answer(state);
self.observe_push(state);
let Some(pending) = self.pending_talk else { return }; let Some(pending) = self.pending_talk else { return };
if state.scripted() { if state.scripted() {
self.pending_talk = None; self.pending_talk = None;
@ -1002,6 +1034,34 @@ impl MacroMachine {
} }
} }
/// One frame after the cartridge took the joypad from a macro: decide what it was (row 58).
///
/// Back in the overworld with the buttons the fly's again: a refusal, written exactly as
/// section 12.4 and row 37 always wrote it. A battle: a trainer's challenge, and it teaches the
/// ledgers nothing. Anything else -- the text, the walk, the frames between -- is still the
/// cartridge's, and the decision waits.
fn observe_push(&mut self, state: &mut dyn MacroState) {
if self.pending_push.is_empty() {
return;
}
match class(state.scene()) {
Class::Battle | Class::ForcedSwitch => self.pending_push.clear(),
Class::Overworld => {
// Every macro the script ended while it held the joypad -- the walk it interrupted
// and any press made into its text -- in the order they ended.
for pending in std::mem::take(&mut self.pending_push) {
if let Some(tile) = pending.tile {
self.pushed_tile.push(tile);
}
if let Some(target) = pending.target {
self.blocked.push(target);
}
}
}
_ => {}
}
}
/// Whether the answer still standing on `map` is a `NO` to a readable prompt: a declined offer /// Whether the answer still standing on `map` is a `NO` to a readable prompt: a declined offer
/// rather than a conversation walked through (section 12.20). /// rather than a conversation walked through (section 12.20).
fn declined_out_of(&self, map: u8) -> bool { fn declined_out_of(&self, map: u8) -> bool {
@ -1127,14 +1187,18 @@ impl MacroMachine {
// entrance -- with no window, in the middle of the town. A dozen of those fenced // entrance -- with no window, in the middle of the town. A dozen of those fenced
// the fly into a pocket no walk could leave. The tile a walk last stood the fly on // the fly into a pocket no walk could leave. The tile a walk last stood the fly on
// is its own record of where the cartridge took over. // is its own record of where the cartridge took over.
if let Some(player) = at { //
// Held until the cartridge gives the joypad back, which is what says whether this
// was a refusal or a trainer walking up (row 58, [`MacroMachine::observe_push`]).
let tile = at.map(|player| {
let current = Tile::new(player.x, player.y); let current = Tile::new(player.x, player.y);
let tile = match active.plan.front() { let tile = match active.plan.front() {
Some(Step::Walk(walk)) => walk.expect.unwrap_or(current), Some(Step::Walk(walk)) => walk.expect.unwrap_or(current),
_ => active.from.unwrap_or(current), _ => active.from.unwrap_or(current),
}; };
self.pushed_tile = Some((player.map, tile)); (player.map, tile)
} });
self.pending_push.push(PendingPush { tile, target: None });
} }
let (closer, stalled) = walk_flags(&active); let (closer, stalled) = walk_flags(&active);
// A walk the cap cut short keeps its route for the next hold; any other ending means // A walk the cap cut short keeps its route for the next hold; any other ending means
@ -1181,8 +1245,15 @@ impl MacroMachine {
// target's own fact, not the world's, so it is excluded for the window like // 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 // any other refusal. Without it the gate was walked into once per hold for
// ever, because every macro that hit it ended `Done`. // ever, because every macro that hit it ended `Done`.
if pushed && let Some(entry) = active.target { //
self.blocked.push(entry); // Held with the tile above, and for the same reason: a trainer's walk up to the
// fly takes the joypad exactly as the gate's walk back does, and only what the
// cartridge does next tells them apart (row 58).
if pushed
&& let Some(entry) = active.target
&& let Some(pending) = self.pending_push.last_mut()
{
pending.target = Some(entry);
} }
// A `GO FRONTIER` whose press faced new ground it could not stand on: `Done`, // A `GO FRONTIER` whose press faced new ground it could not stand on: `Done`,
// because facing it is what the arrival promises, and excluded, because the // because facing it is what the arrival promises, and excluded, because the

View file

@ -1687,9 +1687,18 @@ pub fn objective_goals(state: &mut dyn MacroState) -> Vec<Aim> {
if objective.target.is_some() { if objective.target.is_some() {
let ahead = Tile::new(player.x, player.y).step(player.facing); let ahead = Tile::new(player.x, player.y).step(player.facing);
let here_tile = Tile::new(player.x, player.y); let here_tile = Tile::new(player.x, player.y);
let mut ranked: Vec<(u32, Tile, TalkTarget)> = objective_targets(state) let targets = objective_targets(state);
// **Facing any of them is the arrival** (row 58). With one target this was already
// true -- the thing ahead is left out and nothing else is left -- but a gym has three
// people the ladder names, and standing in front of the leader left the Jr. Trainer
// to walk to: `GO OBJECTIVE` walked to him, then back to the leader, and `TALK` was
// the one press it never made room for. A fly facing a person the rung is waiting on
// has nothing left for a walk to do.
if targets.iter().any(|(tile, _)| Some(*tile) == ahead) {
return Vec::new();
}
let mut ranked: Vec<(u32, Tile, TalkTarget)> = targets
.into_iter() .into_iter()
.filter(|(tile, _)| Some(*tile) != ahead)
.map(|(tile, target)| (tile.distance(here_tile), tile, target)) .map(|(tile, target)| (tile.distance(here_tile), tile, target))
.collect(); .collect();
ranked.sort_unstable(); ranked.sort_unstable();
@ -1795,7 +1804,18 @@ pub fn objective_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)>
return Vec::new(); return Vec::new();
} }
let targets = match kind { let targets = match kind {
PlaceKind::Person => path::person_targets(state), // Row 58: the room's people, drawn or not. From the Pewter Gym's doormat the only person
// on screen is the guide, and with him talked to this list was empty -- so `GO OUT` was a
// candidate and `GO OBJECTIVE` had nothing to aim at, while BROCK stood twelve tiles up the
// room, outside the window the cartridge draws. The whole map's grid is what the walk
// plans over (section 15), so a person off the screen is somewhere a walk can go.
PlaceKind::Person => {
let mut all = path::person_targets(state);
all.extend(path::offscreen_person_targets(state));
all
}
// Not objects: every item ball in the game is a toggleable object, so a ball the run has
// picked up and one out of sight read alike from outside the window.
PlaceKind::Object => path::interactable_targets(state), PlaceKind::Object => path::interactable_targets(state),
}; };
targets targets

View file

@ -348,6 +348,28 @@ pub fn person_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> {
out out
} }
/// The people of this map the cartridge is not drawing only because they are off the screen,
/// keyed as [`person_targets`] keys the drawn ones (row 58).
///
/// Kept apart from [`person_targets`] on purpose: that list is what `GO NPC`, `TALK` and the
/// talked ledger's facing test read, and a sprite outside the window may also be a toggleable
/// object the cartridge has switched off ([`GameState::offscreen_npcs`]). The one reader is the
/// ladder's own target list, which has to know the leader is in the room before the fly can see
/// him.
///
/// [`GameState::offscreen_npcs`]: super::state::GameState::offscreen_npcs
pub fn offscreen_person_targets(state: &mut dyn MacroState) -> Vec<(Tile, TalkTarget)> {
let mut out: Vec<(Tile, TalkTarget)> = state
.offscreen_npcs()
.iter()
.filter(|npc| npc.person())
.map(|npc| (Tile::new(npc.x, npc.y), TalkTarget::Sprite(npc.slot)))
.collect();
out.sort_unstable();
out.dedup();
out
}
/// What is on `tile`: the thing a press at it would talk to, or `None` for bare ground. /// What is on `tile`: the thing a press at it would talk to, or `None` for bare ground.
/// ///
/// People first, because a person standing on a sign's tile is what the press would reach. /// People first, because a person standing on a sign's tile is what the press would reach.

View file

@ -655,6 +655,15 @@ pub trait GameState {
/// the same sixteen slots. [`Npc::person`] is the test that separates the two. /// the same sixteen slots. [`Npc::person`] is the test that separates the two.
fn npcs(&mut self) -> Vec<Npc>; fn npcs(&mut self) -> Vec<Npc>;
/// Sprites of the current map the cartridge is not drawing only because they are off the
/// screen (row 58, `pokemon_red::state::offscreen_npcs`).
///
/// Defaulted to none, which narrows: a seam that cannot answer knows the drawn sprites and
/// nothing more, which is what every reader had before row 58.
fn offscreen_npcs(&mut self) -> Vec<Npc> {
Vec::new()
}
/// The current map's signs, i.e. its `bg_event` text tiles. /// The current map's signs, i.e. its `bg_event` text tiles.
/// ///
/// Empty on a map with none. Required rather than defaulted like the rest of this trait: an /// Empty on a map with none. Required rather than defaulted like the rest of this trait: an

View file

@ -18,7 +18,7 @@ use crate::adapter::PlaceKind;
use crate::emulator::buttons; use crate::emulator::buttons;
use super::cartridge::{ use super::cartridge::{
BLOCKED_MINUTES_DEFAULT, CHEAPEST_PURCHASE, Edge, ExitId, FACINGS, MacroState, Objective, BLOCKED_MINUTES_DEFAULT, CHEAPEST_PURCHASE, Edge, ExitId, FACINGS, LAST_MAP, MacroState, Objective,
TalkTarget, TargetKey, TargetLedger, Targets, Tile, battle_entry, button, item, price, TalkTarget, TargetKey, TargetLedger, Targets, Tile, battle_entry, button, item, price,
}; };
use super::geography::Amenity; use super::geography::Amenity;
@ -89,6 +89,8 @@ struct World {
warps: Vec<Warp>, warps: Vec<Warp>,
connections: Connections, connections: Connections,
npcs: Vec<Npc>, npcs: Vec<Npc>,
/// Sprites the cartridge is not drawing only because they are off the screen (row 58).
offscreen: Vec<Npc>,
signs: Vec<Sign>, signs: Vec<Sign>,
list: List, list: List,
@ -223,6 +225,7 @@ impl World {
warps: Vec::new(), warps: Vec::new(),
connections: Connections::default(), connections: Connections::default(),
npcs: Vec::new(), npcs: Vec::new(),
offscreen: Vec::new(),
signs: Vec::new(), signs: Vec::new(),
list: List::None, list: List::None,
cursor: 0, cursor: 0,
@ -636,6 +639,10 @@ impl GameState for World {
self.npcs.clone() self.npcs.clone()
} }
fn offscreen_npcs(&mut self) -> Vec<Npc> {
self.offscreen.clone()
}
fn signs(&mut self) -> Vec<Sign> { fn signs(&mut self) -> Vec<Sign> {
self.signs.clone() self.signs.clone()
} }
@ -811,6 +818,13 @@ fn drive(
// The loop's own bookkeeping, so a test sees what the next decision would see: whatever the // The loop's own bookkeeping, so a test sees what the next decision would see: whatever the
// finish earned goes into the session's ledgers, which is `PokemonPalette::record_talk`'s job // finish earned goes into the session's ledgers, which is `PokemonPalette::record_talk`'s job
// in the sim loop and this line's here. // in the sim loop and this line's here.
settle(machine, world);
Ok(machine.outcome().expect("a finished macro has an outcome").1)
}
/// `PokemonPalette::record_talk`, over the fixture: whatever the machine has earned goes into the
/// session's ledgers.
fn settle(machine: &mut MacroMachine, world: &mut World) {
while let Some((map, target)) = machine.take_blocked() { while let Some((map, target)) = machine.take_blocked() {
world.targets.record_blocked(map, target); world.targets.record_blocked(map, target);
} }
@ -823,11 +837,25 @@ fn drive(
if let Some((map, target)) = machine.take_reached() { if let Some((map, target)) = machine.take_reached() {
world.targets.record_reached(map, target); world.targets.record_reached(map, target);
} }
while let Some((map, tile)) = machine.take_pushed() {
assert_eq!(map, world.map);
world.pushes.insert(tile);
}
if let Some((map, target)) = machine.take_talked() { if let Some((map, target)) = machine.take_talked() {
assert_eq!(map, world.map); assert_eq!(map, world.map);
world.talked.insert(target); world.talked.insert(target);
} }
Ok(machine.outcome().expect("a finished macro has an outcome").1) }
/// The cartridge gives the joypad back in the overworld: one frame of it, observed, and whatever
/// it decided taken into the ledgers (row 58).
fn hand_back(machine: &mut MacroMachine, world: &mut World) {
world.scene = Scene::Overworld;
world.scripted = false;
world.scripted_at = None;
world.switch = None;
machine.observe_frame(world);
settle(machine, world);
} }
/// A palette of exactly one button, for a script whose macro no scene binds any more. /// A palette of exactly one button, for a script whose macro no scene binds any more.
@ -3510,7 +3538,11 @@ fn a_walk_the_cartridge_pushes_back_excludes_what_it_was_walking_to() {
let north = TargetKey::Exit(ExitId::Edge(Edge::North)); let north = TargetKey::Exit(ExitId::Edge(Edge::North));
assert!(on_the_pad(&mut world, MacroKind::GoRoute)); assert!(on_the_pad(&mut world, MacroKind::GoRoute));
assert_eq!(run(&mut world, MacroKind::GoRoute), Ok(MacroAbort::Done)); let mut machine = MacroMachine::new(0x1234_5678);
assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoRoute), Ok(MacroAbort::Done));
// The gate's text is still up: the cartridge has not given the joypad back (row 58).
assert!(!world.targets.blocked(world.map, north), "nothing decided inside the script");
hand_back(&mut machine, &mut world);
assert!( assert!(
world.targets.blocked(world.map, north), world.targets.blocked(world.map, north),
"the road the cartridge refused is excluded for the window" "the road the cartridge refused is excluded for the window"
@ -5023,10 +5055,13 @@ fn an_escorted_walk_walls_the_tile_it_reached_not_the_one_it_set_out_from() {
let mut machine = MacroMachine::new(1); let mut machine = MacroMachine::new(1);
let _ = run_with(&mut machine, &mut world, MacroKind::GoRoute); let _ = run_with(&mut machine, &mut world, MacroKind::GoRoute);
let (map, tile) = machine.take_pushed().expect("the script moved the fly: a push-back"); // Row 58: written when the cartridge gives the joypad back in the overworld.
assert_eq!(map, maps::PEWTER_CITY); let reached = world.player;
assert_ne!(tile, Tile::new(3, 6), "not the tile the walk set out from"); hand_back(&mut machine, &mut world);
assert_eq!(tile, world.player, "the tile the walk had reached when the script took over"); let pushed: Vec<Tile> = world.pushes.iter().copied().collect();
assert_eq!(pushed.len(), 1, "the script moved the fly: a push-back");
assert_ne!(pushed[0], Tile::new(3, 6), "not the tile the walk set out from");
assert_eq!(pushed[0], reached, "the tile the walk had reached when the script took over");
} }
/// The push-back writes the ledger, and it writes the *tile* rather than the target. /// The push-back writes the ledger, and it writes the *tile* rather than the target.
@ -5042,10 +5077,13 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() {
let mut machine = MacroMachine::new(1); let mut machine = MacroMachine::new(1);
let _ = run_with(&mut machine, &mut world, MacroKind::Talk); let _ = run_with(&mut machine, &mut world, MacroKind::Talk);
let pushed = machine.take_pushed(); assert!(world.pushes.is_empty(), "nothing is decided while the cartridge holds the joypad");
// Row 58: the ledger is written when the cartridge gives the joypad back in the overworld,
// which is what tells the gate's walk back from a trainer's walk up.
hand_back(&mut machine, &mut world);
assert_eq!( assert_eq!(
pushed, world.pushes.iter().copied().collect::<Vec<_>>(),
Some((world.map, Tile::new(3, 3))), vec![Tile::new(3, 3)],
"the tile the macro was standing on, not the person it was facing" "the tile the macro was standing on, not the person it was facing"
); );
} }
@ -5198,3 +5236,123 @@ fn a_completed_heal_writes_the_nurse_into_the_talked_ledger() {
assert_eq!(center.player, Tile::new(3, 3)); assert_eq!(center.player, Tile::new(3, 3));
assert!(!precondition(MacroKind::Talk, &mut center)); assert!(!precondition(MacroKind::Talk, &mut center));
} }
// ---------------------------------------------------------------------------------------------
// Row 58: the gym door, in and out
// ---------------------------------------------------------------------------------------------
/// The Pewter Gym as the fly finds it on its doormat: the guide on screen and talked to, the
/// leader and the Jr. Trainer up the room and not drawn.
fn pewter_gym_doormat() -> World {
let mut world = World::room().at(4, 13);
world.map = maps::PEWTER_GYM;
world.size = MapSize { width: 10, height: 14 };
world.facing = Facing::Up;
world.warps = vec![
Warp { x: 4, y: 13, destination_warp: 3, destination_map: LAST_MAP },
Warp { x: 5, y: 13, destination_warp: 3, destination_map: LAST_MAP },
];
world.npcs = vec![Npc { slot: 3, picture: 1, x: 7, y: 10, facing: Facing::Down }];
world.offscreen = vec![
Npc { slot: 1, picture: 2, x: 4, y: 1, facing: Facing::Down },
Npc { slot: 2, picture: 3, x: 3, y: 6, facing: Facing::Right },
];
world.talked.insert(TalkTarget::Sprite(3));
// Pewter's errands are paid, as they were live: the objective is the rung's own place.
world.areas.insert((Amenity::Mart, maps::PEWTER_CITY));
world.areas.insert((Amenity::Center, maps::PEWTER_CITY));
world.objective = Some(Objective {
map: world.map,
tile: None,
warp: None,
edge: None,
target: Some(PlaceKind::Person),
});
world
}
#[test]
fn the_rungs_people_are_in_the_room_when_the_screen_does_not_show_them() {
// Row 58, live for twenty-five minutes: `GO OBJECTIVE` into the Pewter Gym, `GO OUT` straight
// back out, ~200 macro starts per ten brain minutes and no reward at all. From the doormat the
// cartridge draws only the guide, who had been talked to, so the rung's own list was empty:
// `GO OBJECTIVE` had nothing to aim at and `GO OUT` -- whose candidates 12.5 withholds only
// while the rung's person is in the room -- was the pad. BROCK was twelve rows up.
let mut world = pewter_gym_doormat();
let targets = super::palette::objective_targets(&mut world);
assert!(
targets.contains(&(Tile::new(4, 1), TalkTarget::Sprite(1))),
"the leader is one of the rung's people: {targets:?}"
);
assert!(on_the_pad(&mut world, MacroKind::GoObjective), "there is someone to walk to");
assert!(!on_the_pad(&mut world, MacroKind::GoOut), "and the room is not left while he is in it");
// What the base saw, for the record: the drawn sprites alone leave nothing.
world.offscreen.clear();
assert!(super::palette::objective_targets(&mut world).is_empty());
assert!(!on_the_pad(&mut world, MacroKind::GoObjective));
assert!(on_the_pad(&mut world, MacroKind::GoOut), "the undo pair's inside half");
}
#[test]
fn only_the_rung_reads_people_off_the_screen() {
// A sprite outside the window may be a toggleable object the cartridge has switched off, and
// the two read alike from here (`state::offscreen_npcs`). The rung's list is the one reader:
// `GO NPC`, `TALK` and the objects are what they were.
let mut world = pewter_gym_doormat();
world.objective = None;
assert!(super::palette::untalked_people(&mut world).is_empty(), "`GO NPC` sees what is drawn");
world.objective = Some(Objective {
map: world.map,
tile: None,
warp: None,
edge: None,
target: Some(PlaceKind::Object),
});
assert!(
super::palette::objective_targets(&mut world).is_empty(),
"an item ball out of sight and one picked up read alike, so objects are not guessed at"
);
}
#[test]
fn facing_one_of_the_rungs_people_is_the_arrival() {
// A gym names three people and 12.5's "leave out the one ahead" was written for one: in front
// of the leader, `GO OBJECTIVE` still had the Jr. Trainer to walk to, and at the trainer it had
// the leader. The walk is done when any of them is ahead, and `TALK` is the press.
let mut world = pewter_gym_doormat().at(4, 2);
world.facing = Facing::Up;
world.npcs = vec![Npc { slot: 1, picture: 2, x: 4, y: 1, facing: Facing::Down }];
world.offscreen = vec![Npc { slot: 2, picture: 3, x: 3, y: 6, facing: Facing::Right }];
assert!(on_the_pad(&mut world, MacroKind::Talk));
assert!(!on_the_pad(&mut world, MacroKind::GoObjective), "no walk left while facing him");
world.facing = Facing::Left;
assert!(on_the_pad(&mut world, MacroKind::GoObjective), "turned away, the walk is back");
}
#[test]
fn a_trainer_walking_up_teaches_the_ledgers_nothing() {
// The other half of the gym. A walk toward the leader crossed the Jr. Trainer's line of sight;
// the trainer's "!" and walk up took the joypad, which 12.4 reads as the cartridge refusing
// the step, so BROCK went into the blocked ledger for ten brain minutes and the tile into the
// pushed one for the session. What the cartridge does when it gives the joypad back is what
// tells a refusal from a challenge.
let mut world = World::room().at(3, 3);
world.map = 0x00;
world.connections = Connections { north: true, south: false, east: false, west: false };
world.switch = Some((4, Scene::Dialog));
world.scripted_at = Some(4);
let north = TargetKey::Exit(ExitId::Edge(Edge::North));
let mut machine = MacroMachine::new(0x1234_5678);
assert_eq!(run_with(&mut machine, &mut world, MacroKind::GoRoute), Ok(MacroAbort::Done));
// The challenge closes into a battle.
world.scene = Scene::Battle { own_turn: false, forced_switch: false };
machine.observe_frame(&mut world);
settle(&mut machine, &mut world);
// And the battle ends back in the overworld: nothing was refused.
hand_back(&mut machine, &mut world);
assert!(!world.targets.blocked(world.map, north), "a challenge is not the road refusing");
assert!(world.pushes.is_empty(), "and the ground is as walkable as it was");
}

View file

@ -105,7 +105,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String {
format!( format!(
"started={} map={:?} party={} battle={} type={} font={:#04x} textbox={:#04x} \ "started={} map={:?} party={} battle={} type={} font={:#04x} textbox={:#04x} \
list={:#04x} cursor=({},{},{},{},{:#04x}) prompt={} joy={} sim={} flags5={:#04x} \ list={:#04x} cursor=({},{},{},{},{:#04x}) prompt={} joy={} sim={} flags5={:#04x} \
flags6={:#04x} move={:#04x} \ flags6={:#04x} move={:#04x} opp={:#04x} \
corners=({:#04x},{:#04x},{:#04x},{:#04x})", corners=({:#04x},{:#04x},{:#04x},{:#04x})",
state::started(memory), state::started(memory),
state::map_size(memory).map(|size| (size.width, size.height)), state::map_size(memory).map(|size| (size.width, size.height)),
@ -126,6 +126,7 @@ pub fn why_unknown(memory: &mut dyn MemoryReader) -> String {
memory.read8(ram::wStatusFlags5), memory.read8(ram::wStatusFlags5),
memory.read8(ram::wStatusFlags6), memory.read8(ram::wStatusFlags6),
memory.read8(ram::wMovementFlags), memory.read8(ram::wMovementFlags),
memory.read8(state::poke::CUR_OPPONENT),
box_corners[0], box_corners[0],
box_corners[1], box_corners[1],
box_corners[2], box_corners[2],

View file

@ -410,3 +410,18 @@ fn the_start_menus_box_is_read_the_same_way() {
.cursor(2, 11, 0, 7, poke::pad::DOWN | poke::pad::UP | poke::pad::START); .cursor(2, 11, 0, 7, poke::pad::DOWN | poke::pad::UP | poke::pad::START);
assert_eq!(detect(&mut corners), Scene::Unknown, "four corners are not the start menu"); assert_eq!(detect(&mut corners), Scene::Unknown, "four corners are not the start menu");
} }
#[test]
fn a_battle_decided_and_not_yet_begun_is_the_cartridges() {
// Row 58. Between a trainer's challenge closing and the battle screen the transition runs for
// 219 frames with every joypad and script bit clear; `wCurOpponent` is what says a battle has
// been decided. The byte is derived, not generated: it sits between two generated ones.
assert_eq!(poke::CUR_OPPONENT, ram::wIsInBattle + 2, "after wIsInBattle and one flag byte");
assert_eq!(poke::CUR_OPPONENT, ram::wTrainerNo - 4, "and four before wTrainerNo");
let mut wram = Wram::overworld();
assert_eq!(detect(&mut wram), Scene::Overworld);
wram.set(poke::CUR_OPPONENT, 0xcd);
assert_eq!(detect(&mut wram), Scene::Unknown, "OPP_JR_TRAINER_M, decided");
wram.set(poke::CUR_OPPONENT, 0x00);
assert_eq!(detect(&mut wram), Scene::Overworld, "and `EndOfBattle` clears it");
}

View file

@ -132,6 +132,19 @@ pub mod poke {
/// deliberately *not* here: standing on a doormat is an ordinary overworld state, and it is the /// deliberately *not* here: standing on a doormat is an ordinary overworld state, and it is the
/// one `docs/design/room-escape.md` cares most about. /// one `docs/design/room-escape.md` cares most about.
pub const SCRIPTED_MOVEMENT: u8 = 0xc0; pub const SCRIPTED_MOVEMENT: u8 = 0xc0;
/// `wCurOpponent` (row 58): the species of a wild opponent or `OPP_ID_OFFSET` plus a
/// trainer's class, written when a battle is *decided* -- `home/trainers.asm` for a trainer,
/// the encounter check for a wild one -- and cleared by `EndOfBattle` together with
/// `wIsInBattle`. Not in the generated table, so it is derived rather than pinned:
/// `ram/wram.asm` at the pinned commit declares `wIsInBattle:: db`,
/// `wPartyGainExpFlags:: flag_array PARTY_LENGTH` (one byte), `wCurOpponent:: db`,
/// `wBattleType:: db`, `wDamageMultipliers:: db`, `wGymLeaderNo:: db`, `wTrainerNo:: db` in
/// that order, and the table's `wIsInBattle` (`$d057`), `wBattleType` (`$d05a`) and
/// `wTrainerNo` (`$d05d`) sit exactly where that layout puts them, so the byte between is
/// `wBattleType - 1` with both neighbours checked. Measured on the cartridge in the Pewter Gym:
/// zero in the overworld, non-zero from the frame a trainer's challenge closes to the end of
/// the battle, including the 219 frames of the battle transition in between.
pub const CUR_OPPONENT: u16 = super::ram::wBattleType - 1;
/// `constants/battle_constants.asm`: the non-volatile status byte. /// `constants/battle_constants.asm`: the non-volatile status byte.
pub const SLP_MASK: u8 = 0b111; pub const SLP_MASK: u8 = 0b111;
@ -184,6 +197,19 @@ pub mod poke {
pub const SPRITE_BYTES: u16 = 16; pub const SPRITE_BYTES: u16 = 16;
/// `MACRO object_event` stores map coordinates plus four. /// `MACRO object_event` stores map coordinates plus four.
pub const SPRITE_COORD_BIAS: u8 = 4; pub const SPRITE_COORD_BIAS: u8 = 4;
/// `constants/map_object_constants.asm`: `SPRITESTATEDATA1_IMAGEINDEX`, and the `$ff` that
/// `CheckSpriteAvailability` writes there for a sprite it will not draw.
pub const SPRITE_IMAGE_INDEX: u16 = 2;
pub const SPRITE_NOT_DRAWN: u8 = 0xff;
/// `SPRITESTATEDATA2_MOVEMENTBYTE1`, and `WALK` (`$fe`): a movement byte below it is a
/// scripted mover, which `CheckSpriteAvailability` never hides for being off the screen.
pub const SPRITE_MOVEMENT_BYTE: u16 = 6;
pub const MOVEMENT_WALK: u8 = 0xfe;
/// `CheckSpriteAvailability`'s window, in map tiles past the player's own coordinate:
/// `SCREEN_HEIGHT / 2 - 1` rows and `SCREEN_WIDTH / 2 - 1` columns, compared against the
/// sprite's *biased* coordinate.
pub const DRAWN_ROWS: u8 = 8;
pub const DRAWN_COLUMNS: u8 = 9;
/// `constants/map_data_constants.asm`: `wCurMapConnections` bits. /// `constants/map_data_constants.asm`: `wCurMapConnections` bits.
pub const CONNECTION_EAST: u8 = 1; pub const CONNECTION_EAST: u8 = 1;
@ -280,12 +306,22 @@ pub fn started(memory: &mut dyn MemoryReader) -> bool {
} }
/// Whether the player's buttons reach the player: no ignored joypad, no simulated input, no /// Whether the player's buttons reach the player: no ignored joypad, no simulated input, no
/// scripted movement, no warp in flight, not mid-ledge-hop. /// scripted movement, no warp in flight, not mid-ledge-hop, no battle decided and not yet begun.
/// ///
/// The masks are the reward adapter's own scripted gate, minus the door bits — see /// The masks are the reward adapter's own scripted gate, minus the door bits — see
/// [`poke::SCRIPTED_MOVEMENT`]. /// [`poke::SCRIPTED_MOVEMENT`].
///
/// **A battle decided is the cartridge's** (row 58). Between a trainer's challenge closing and the
/// battle screen, the battle transition runs for 219 frames with every joypad and script bit
/// clear, so the seam read an overworld the fly could walk in: the pad was dealt, a walk toward
/// the gym leader pressed into an animation, gave up after three refused steps, and put the
/// leader into the blocked ledger for ten brain minutes -- and the Jr. Trainer's conversation read
/// as over, so the trainer the fly was about to lose to went into the talked ledger for the
/// session. [`poke::CUR_OPPONENT`] is set on the frame the battle is decided and cleared with the
/// battle's own end.
pub fn controllable(memory: &mut dyn MemoryReader) -> bool { pub fn controllable(memory: &mut dyn MemoryReader) -> bool {
read(memory, ram::wJoyIgnore) == 0 read(memory, poke::CUR_OPPONENT) == 0
&& read(memory, ram::wJoyIgnore) == 0
&& read(memory, ram::wSimulatedJoypadStatesIndex) == 0 && read(memory, ram::wSimulatedJoypadStatesIndex) == 0
&& read(memory, ram::wStatusFlags5) & poke::SCRIPTED_STATUS5 == 0 && read(memory, ram::wStatusFlags5) & poke::SCRIPTED_STATUS5 == 0
&& read(memory, ram::wStatusFlags6) & poke::SCRIPTED_STATUS6 == 0 && read(memory, ram::wStatusFlags6) & poke::SCRIPTED_STATUS6 == 0
@ -1045,6 +1081,78 @@ pub fn npcs(memory: &mut dyn MemoryReader) -> Vec<Npc> {
npcs npcs
} }
/// The people and objects of the current map the cartridge is not drawing **only because they are
/// off the screen** (row 58).
///
/// [`npcs`] reports what is drawn, and the Pewter Gym showed what that costs: from the gym's
/// doormat at (4, 13) BROCK at (4, 1) and the Jr. Trainer at (3, 6) are both outside the window, so
/// the macros saw one person in the room -- the guide, already talked to -- and concluded the
/// room held nothing the ladder wanted.
///
/// `CheckSpriteAvailability` (`engine/overworld/movement.asm`) writes `$ff` into a sprite's image
/// index for three reasons: it is a toggleable object switched off, it is outside the window, or the
/// tile under it is a text box's (a tile id past the map tileset). The window is a pure function of
/// bytes this crate already reads -- `wYCoord`, `wXCoord` and the sprite's own biased `MAPY` /
/// `MAPX` -- so a sprite the cartridge hides and whose coordinates lie **outside** that window is
/// one it would hide for that reason whatever else were true, and its coordinates are still the
/// map's: a sprite the cartridge is not updating does not move. A sprite hidden **inside** the
/// window is hidden for another reason and is not reported. A scripted mover (movement byte below
/// `WALK`) skips the window test altogether, so its `$ff` is never the screen's and it is never
/// reported either.
///
/// What this cannot tell is the first reason from the second for a sprite outside the window: a
/// toggleable object that is off reads the same as one that is merely far away. That is named, not
/// guessed: [`crate::pokemon_red::macros::palette::objective_targets`] is the one reader, and the
/// ladder's places that name a person are Oak's lab and the gyms, of which only the lab and Viridian
/// Gym carry toggleable people (`data/maps/toggleable_objects.asm`).
pub fn offscreen_npcs(memory: &mut dyn MemoryReader) -> Vec<Npc> {
let Some(size) = map_size(memory) else { return Vec::new() };
let player_y = read(memory, ram::wYCoord);
let player_x = read(memory, ram::wXCoord);
if player_x >= size.width || player_y >= size.height {
return Vec::new();
}
// `CheckSpriteAvailability`, one axis: `cp b / jr z, skip / jr nc, invisible / add n / cp b /
// jr c, invisible` against the biased coordinate `b`.
let drawn = |own: u8, sprite: u8, reach: u8| {
sprite == own || (own < sprite && u16::from(sprite) <= u16::from(own) + u16::from(reach))
};
let count = read(memory, ram::wNumSprites).min(poke::SPRITE_SLOTS - 1);
let mut out = Vec::new();
for slot in 1..=count {
let data1 = ram::wSpriteStateData1 + u16::from(slot) * poke::SPRITE_BYTES;
let data2 = ram::wSpriteStateData2 + u16::from(slot) * poke::SPRITE_BYTES;
let picture = read(memory, data1);
if picture == 0 || read(memory, data1 + poke::SPRITE_IMAGE_INDEX) != poke::SPRITE_NOT_DRAWN
{
continue;
}
if read(memory, data2 + poke::SPRITE_MOVEMENT_BYTE) < poke::MOVEMENT_WALK {
continue;
}
let y = read(memory, data2 + 4);
let x = read(memory, data2 + 5);
if y < poke::SPRITE_COORD_BIAS || x < poke::SPRITE_COORD_BIAS {
continue;
}
let (map_x, map_y) = (x - poke::SPRITE_COORD_BIAS, y - poke::SPRITE_COORD_BIAS);
if map_x >= size.width || map_y >= size.height {
continue;
}
if drawn(player_y, y, poke::DRAWN_ROWS) && drawn(player_x, x, poke::DRAWN_COLUMNS) {
continue;
}
out.push(Npc {
slot,
picture,
x: map_x,
y: map_y,
facing: facing_from(read(memory, data1 + 9)),
});
}
out
}
/// The current tileset's list of passable tile ids, terminator included. /// The current tileset's list of passable tile ids, terminator included.
/// ///
/// `CheckTilePassable` walks the list at `wTilesetCollisionPtr` — a little-endian pointer into the /// `CheckTilePassable` walks the list at `wTilesetCollisionPtr` — a little-endian pointer into the
@ -1625,6 +1733,10 @@ impl GameState for PokeState<'_> {
npcs(self.memory) npcs(self.memory)
} }
fn offscreen_npcs(&mut self) -> Vec<Npc> {
offscreen_npcs(self.memory)
}
fn signs(&mut self) -> Vec<Sign> { fn signs(&mut self) -> Vec<Sign> {
signs(self.memory) signs(self.memory)
} }

View file

@ -1040,3 +1040,30 @@ fn a_state_with_no_cache_still_answers_and_a_state_with_no_cartridge_answers_non
// Which is the frame the window predicate is for. // Which is the frame the window predicate is for.
assert_eq!(state.walkable(3, 6), Walkable::No); assert_eq!(state.walkable(3, 6), Walkable::No);
} }
#[test]
fn a_sprite_the_cartridge_hides_off_the_screen_is_still_on_the_map() {
// Row 58, the Pewter Gym from its doormat at (4, 13). The cartridge draws the guide; BROCK at
// (4, 1) and the Jr. Trainer at (3, 6) are outside `CheckSpriteAvailability`'s window, so it
// writes `$ff` into their image index and `npcs` -- which reports what is drawn -- skips them.
const STAY: u8 = 0xff;
let mut wram = Wram::overworld();
wram.map(0x36, 5, 7, 4, 13)
.npc(3, 0x2b, 7, 10, 0x00)
.npc_undrawn(1, 0x1f, 4, 1, STAY)
.npc_undrawn(2, 0x0e, 3, 6, STAY)
// Undrawn *inside* the window: switched off, or under a text box -- not the screen's doing.
.npc_undrawn(4, 0x05, 5, 11, STAY)
// Undrawn outside it, but a scripted mover, which the window test never hides.
.npc_undrawn(5, 0x05, 8, 1, 0x00);
let drawn: Vec<u8> = npcs(&mut wram).iter().map(|npc| npc.slot).collect();
assert_eq!(drawn, vec![3]);
let off: Vec<(u8, u8, u8)> =
offscreen_npcs(&mut wram).iter().map(|npc| (npc.slot, npc.x, npc.y)).collect();
assert_eq!(off, vec![(1, 4, 1), (2, 3, 6)], "the leader and the trainer, where they stand");
// Walk up the room and the trainer is inside the window: a `$ff` there is not the screen's.
wram.map(0x36, 5, 7, 4, 8);
let off: Vec<u8> = offscreen_npcs(&mut wram).iter().map(|npc| npc.slot).collect();
assert_eq!(off, vec![1], "only the leader is still off the screen from (4, 8)");
}

View file

@ -1355,9 +1355,9 @@ fn dialog_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64
/// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette /// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette
fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) { fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) {
use flybrain_gb::MacroPalette; use flybrain_gb::MacroPalette;
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, TargetKey}; use flybrain_gb::pokemon_red::macros::cartridge::{FACINGS, MacroState, TalkTarget, TargetKey};
use flybrain_gb::pokemon_red::macros::path::Way; use flybrain_gb::pokemon_red::macros::path::Way;
use flybrain_gb::pokemon_red::macros::{PokemonPalette, palette, path}; use flybrain_gb::pokemon_red::macros::{PokemonPalette, Tile, palette, path};
let budget = env_usize("FLY_PROBE_FRAMES", 240_000); let budget = env_usize("FLY_PROBE_FRAMES", 240_000);
let mut rng = env_usize("FLY_PROBE_RNG", 20_260_923) as u32 | 1; let mut rng = env_usize("FLY_PROBE_RNG", 20_260_923) as u32 | 1;
@ -1379,6 +1379,14 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
let mut outcomes: BTreeMap<String, u64> = BTreeMap::new(); let mut outcomes: BTreeMap<String, u64> = BTreeMap::new();
let mut single_refusals = 0u32; let mut single_refusals = 0u32;
let mut caught_at: Option<usize> = None; let mut caught_at: Option<usize> = None;
// `FLY_PROBE_CATCH_MAP=54` with `FLY_PROBE_CATCH_ENTRIES=4` reads the frame the fly is given
// the buttons back on its fourth arrival on map 54 (row 58: the pad in and out of one door).
let catch_map: Option<u8> =
std::env::var("FLY_PROBE_CATCH_MAP").ok().and_then(|value| value.parse().ok());
let catch_entries = env_usize("FLY_PROBE_CATCH_ENTRIES", 4);
let mut entries = 0usize;
let mut arrived_at = 0usize;
let mut last_map: Option<u8> = None;
// `FLY_PROBE_HOLD=right:96,up:32` holds raw directions first and prints where the fly is // `FLY_PROBE_HOLD=right:96,up:32` holds raw directions first and prints where the fly is
// every eight frames: what the cartridge does with a press, before any macro is asked. // every eight frames: what the cartridge does with a press, before any macro is asked.
@ -1476,7 +1484,12 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
} }
} }
if let Some((name, outcome)) = macros.take_finished() { if let Some((name, outcome)) = macros.take_finished() {
*outcomes.entry(format!("{name} {outcome:?}")).or_default() += 1; *outcomes
.entry(format!(
"{name} {outcome:?} on {:?}",
state::player(gb).map(|p| p.map)
))
.or_default() += 1;
if !matches!(outcome, flybrain_gb::Outcome::Done) { if !matches!(outcome, flybrain_gb::Outcome::Done) {
println!( println!(
"f{frame:<6} {:?} {name} {outcome:?}", "f{frame:<6} {:?} {name} {outcome:?}",
@ -1487,11 +1500,12 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
since_decision += 1; since_decision += 1;
if frame < trace_frames { if frame < trace_frames {
println!( println!(
" t{frame:<5} {:?} mask {mask:#04x} running {:?} marks {:?} stood {}", " t{frame:<5} {:?} mask {mask:#04x} running {:?} marks {:?} stood {} | {}",
state::player(gb).map(|p| (p.map, p.x, p.y, p.facing)), state::player(gb).map(|p| (p.map, p.x, p.y, p.facing)),
macros.running(), macros.running(),
macros.fences().1, macros.fences().1,
macros.stood() macros.stood(),
scene::why_unknown(gb)
); );
} }
gb.set_buttons(mask); gb.set_buttons(mask);
@ -1502,8 +1516,32 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
caught_at = Some(frame); caught_at = Some(frame);
break; break;
} }
if let (Some(want), Some(player)) = (catch_map, player) {
if player.map == want && last_map != Some(want) {
entries += 1;
arrived_at = frame;
}
last_map = Some(player.map);
// Forty frames in: the first frames on a new map byte still carry the old map's
// warps (the tear 12.16 names), and a reading there says nothing about the room.
if player.map == want
&& entries >= catch_entries
&& frame >= arrived_at + 40
&& !running
&& matches!(observed.scene, flybrain_gb::SceneId::Overworld)
&& !observed.bindings.is_empty()
{
caught_at = Some(frame);
break;
}
}
} }
println!("```\n"); println!("```\n");
let progress = adapter.progress();
println!(
"- at the end: rank {} ({}), badges {}, unique tiles {}",
progress.rank, progress.rank_label, progress.counter, progress.unique_locations
);
println!("- refusals: {refusals:?}"); println!("- refusals: {refusals:?}");
println!("- outcomes: {outcomes:?}"); println!("- outcomes: {outcomes:?}");
let Some(frame) = caught_at else { let Some(frame) = caught_at else {
@ -1512,8 +1550,13 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
return; return;
}; };
println!( println!(
"\n## Caught on frame {frame} ({:.1} brain minutes): one button, refused twenty holds running\n", "\n## Caught on frame {frame} ({:.1} brain minutes): {}\n",
frame as f64 * MS_PER_FRAME / 60_000.0 frame as f64 * MS_PER_FRAME / 60_000.0,
if single_refusals >= catch_after {
"one button, refused twenty holds running".to_string()
} else {
format!("arrival {entries} on map {catch_map:?}")
}
); );
let (pushed, frontiers) = macros.fences(); let (pushed, frontiers) = macros.fences();
println!("- pushed tiles (no window): {pushed:?}"); println!("- pushed tiles (no window): {pushed:?}");
@ -1525,6 +1568,18 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
println!("- objective: {:?}", state.objective()); println!("- objective: {:?}", state.objective());
println!("- `objective_goals`: {:?}", palette::objective_goals(state)); println!("- `objective_goals`: {:?}", palette::objective_goals(state));
println!("- `objective_targets`: {:?}", palette::objective_targets(state)); println!("- `objective_targets`: {:?}", palette::objective_targets(state));
let drawn = path::person_targets(state);
for (tile, target) in drawn.iter().copied().chain(path::offscreen_person_targets(state)) {
println!(
" - person {target:?} at ({:2},{:2}) {}: talked {}, blocked {}, reached {}",
tile.x,
tile.y,
if drawn.contains(&(tile, target)) { "drawn" } else { "off the screen" },
state.talked(target),
state.blocked(TargetKey::Thing(target)),
state.reached(TargetKey::Thing(target))
);
}
println!("- `untalked_people`: {:?}", palette::untalked_people(state)); println!("- `untalked_people`: {:?}", palette::untalked_people(state));
println!("- `untalked_objects`: {:?}", palette::untalked_objects(state)); println!("- `untalked_objects`: {:?}", palette::untalked_objects(state));
println!("- `facing_untalked`: {}", palette::facing_untalked(state)); println!("- `facing_untalked`: {}", palette::facing_untalked(state));
@ -1549,16 +1604,44 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
reach reach
); );
} }
println!("\n### The fly's own neighbourhood (pushed = `P`, player = `@`)\n\n```"); // A room small enough to print whole is printed whole, with its people on it (row 58:
for y in player.y.saturating_sub(3)..=player.y.saturating_add(3) { // the gym's leader is twelve rows from the door).
let row: String = (player.x.saturating_sub(6)..=player.x.saturating_add(6)) let size = state.map_size().expect("a loaded map");
let whole = size.width <= 24 && size.height <= 24;
let people: Vec<(Tile, TalkTarget)> = path::person_targets(state)
.into_iter()
.chain(path::offscreen_person_targets(state))
.collect();
let (rows, columns) = if whole {
(0..=size.height - 1, 0..=size.width - 1)
} else {
(
player.y.saturating_sub(3)..=player.y.saturating_add(3),
player.x.saturating_sub(6)..=player.x.saturating_add(6),
)
};
let grid = state.map_grid();
println!(
"\n### The fly's {} (pushed = `P`, player = `@`, a person = `N`; grid {})\n\n```",
if whole { "whole map" } else { "own neighbourhood" },
grid.is_some()
);
for y in rows {
let row: String = columns
.clone()
.map(|x| { .map(|x| {
let walk = match grid.as_deref() {
Some(grid) => grid.walkable(x, y),
None => state.walkable(x, y),
};
if x == player.x && y == player.y { if x == player.x && y == player.y {
'@' '@'
} else if people.iter().any(|(tile, _)| *tile == Tile::new(x, y)) {
'N'
} else if state.pushed_tile(x, y) { } else if state.pushed_tile(x, y) {
'P' 'P'
} else { } else {
match state.walkable(x, y) { match walk {
flybrain_gb::pokemon_red::macros::state::Walkable::Yes => '.', flybrain_gb::pokemon_red::macros::state::Walkable::Yes => '.',
flybrain_gb::pokemon_red::macros::state::Walkable::No => '#', flybrain_gb::pokemon_red::macros::state::Walkable::No => '#',
flybrain_gb::pokemon_red::macros::state::Walkable::Unknown => '?', flybrain_gb::pokemon_red::macros::state::Walkable::Unknown => '?',
@ -1566,9 +1649,14 @@ fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64)
} }
}) })
.collect(); .collect();
println!("y{y:2} x{:2}.. {row}", player.x.saturating_sub(6)); println!("y{y:2} x{:2}.. {row}", if whole { 0 } else { player.x.saturating_sub(6) });
} }
println!("```"); println!("```");
for (tile, target) in &people {
let aims: Vec<Tile> = FACINGS.iter().filter_map(|facing| tile.step(*facing)).collect();
let reach = path::route(state, &aims).map(|route| route.goal);
println!("- a route to {target:?} at ({:2},{:2}): {reach:?}", tile.x, tile.y);
}
}); });
} }

View file

@ -3182,3 +3182,111 @@ fn the_pewter_east_pad_is_never_one_dead_button_from_the_rung_ten_checkpoint() {
// ten brain minutes in. // ten brain minutes in.
assert!(minutes < 1.0, "the fly waited {minutes:.2} brain minutes for a window to lapse"); assert!(minutes < 1.0, "the fly waited {minutes:.2} brain minutes for a window to lapse");
} }
/// The row-58 checkpoint (Pewter City, outside the gym, taken during the loop), or `None` to skip.
fn door_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_DOOR_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// From Pewter City, the checkpoint taken while the fly was walking in and out of the gym's door.
///
/// **What was live** (2026-09-23, rank 10 PEWTER CITY, v0.5.3): for twenty-five minutes
/// `GO OBJECTIVE` into the Pewter Gym and `GO OUT` straight back out, with `GO ITEM`,
/// `GO FRONTIER`, `YES` and `NO` mixed in -- about 93 `GO OUT` and 47 `GO OBJECTIVE` per ten brain
/// minutes, every one `done`, and not one reward event. The watchdog saw ten distinct names.
///
/// **What the survey found** (`infra/docs/macros-traps.md` row 58): from the gym's doormat the
/// cartridge draws only the guide, already talked to, and hides BROCK and the Jr. Trainer for being
/// off the screen -- so the rung's list of people was empty, `GO OBJECTIVE` had nothing to aim at
/// inside and `GO OUT` was the pad; outside, `GO OBJECTIVE` aimed at the door. And three frames
/// the seam read as the fly's own were the cartridge's: a warp's tear, a battle's transition, and
/// a trainer walking up -- each of which wrote an entry that kept the room empty.
///
/// The claims, none of them about which button the fly presses:
///
/// - **the gym is not a door in and a door out**: at most three arrivals end in the fly walking
/// straight back out inside ten seconds, against one every few seconds on the base;
/// - **the fly goes up the room**: it stands at row 6 or above on map 54, where the Jr. Trainer
/// is, which it never does on the base.
///
/// Rung 11 is printed and not asserted: which button the fly presses at the leader is the fly's.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_DOOR_CHECKPOINT=.local/checkpoints/release-rank10-row58.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture the_gym
/// ```
#[test]
fn the_gym_is_not_a_door_in_and_a_door_out_from_the_rung_ten_checkpoint() {
let rom = skip_without_rom!();
let Some(checkpoint) = door_checkpoint() else {
eprintln!("skipped: no FLY_DOOR_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert_eq!(run.map(), PEWTER_CITY, "the checkpoint is the town outside the gym's door");
let mut arrivals = 0u32;
let mut bounces = 0u32;
let mut arrived_at: Option<u32> = None;
let mut highest_row: Option<u8> = None;
let mut frames_in_gym = 0u32;
let mut badge = None;
let mut previous = run.map();
for frame in 0..108_000u32 {
run.frame();
let map = run.map();
if map != previous {
if map == PEWTER_GYM {
arrivals += 1;
arrived_at = Some(frame);
} else if previous == PEWTER_GYM {
if arrived_at.is_some_and(|at| frame - at < 600) {
bounces += 1;
}
arrived_at = None;
}
previous = map;
}
if map == PEWTER_GYM {
frames_in_gym += 1;
if let Some(player) = flybrain_gb::pokemon_red::state::player(&mut run.gb)
&& u32::from(player.map) == PEWTER_GYM
{
highest_row = Some(highest_row.map_or(player.y, |row| row.min(player.y)));
}
}
if badge.is_none() && run.adapter.progress().rank >= 11 {
badge = Some(frame);
}
}
let progress = run.adapter.progress();
eprintln!(
"{:.1} brain minutes: gym arrivals {arrivals}, straight back out {bounces}, frames in the \
gym {frames_in_gym}, highest row reached {highest_row:?}, macros {:?}, rank {} ({})",
run.ms / 60_000.0,
run.started,
progress.rank,
progress.rank_label
);
match badge {
Some(frame) => eprintln!(
"rung 11 at frame {frame} ({:.2} brain minutes)",
f64::from(frame) * MS_PER_FRAME / 60_000.0
),
None => eprintln!("rung 11 not reached inside the budget"),
}
assert!(arrivals > 0, "the fly never went through the gym's door: {:?}", run.route);
assert!(
bounces <= 3,
"{bounces} of {arrivals} arrivals walked straight back out: {:?}",
run.started
);
assert!(
highest_row.is_some_and(|row| row <= 6),
"the fly never went up the room past the doormat rows: highest row {highest_row:?}"
);
}