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:
commit
6fd6d840dc
19 changed files with 1104 additions and 56 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
|
|
||||||
|
|
@ -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'."
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue