Merge fix/loop-row57: an escort walls the tile it reached, and a refusal that teaches nothing is not dealt again

This commit is contained in:
acamilo 2026-09-23 11:26:29 +00:00
commit 6d5827716f
15 changed files with 1378 additions and 28 deletions

View file

@ -1374,6 +1374,77 @@ answers at one person in the twenty-minute reproduction.
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched.
### 12.21 A button refused from here is not dealt again from here, a last resort walks where it can, and an escort walls the tile it fired on (2026-09-23, row 57)
Live on v0.5.3, rank 10, map 2 (**Pewter City**), scene `overworld`: the pad was **`GO ROUTE` and
nothing else**, and it was refused about **740 times per ten brain minutes for more than two
hours**, with one `GO ROUTE start` / `blocked` pair every ten brain minutes, no button pressed and
the exploration count frozen. The watchdog read one start and one name, and never flagged it. The
`refused` event's `value 3.0` is the slot, not a reason (row 55); the feed carries no reason.
The ledgers that dealt that pad are session state and a restore starts them empty, so the trap
does not come back from the checkpoint by itself: a twenty-brain-minute hunt with the real brain
from the live frame covers 356 tiles. It was reproduced by **earning** them -- a new probe mode
(`examples/scene_probe.rs`, `FLY_PROBE_CATCH=route`) drives the real palette from the checkpoint and
can seed each ledger -- and three facts came out, each measured on the cartridge.
- **The pushed ledger walled the tile a walk set out from, not the tile the script fired on.**
Pewter City's youngster takes the joypad on four tiles by the road east
(`PewterCityPlayerLeavingEastCoords`) and walks the fly to the gym until Brock is beaten. `GO
ROUTE` aims east every time, because Route 3 is the one connection the run has not crossed, so it
is escorted every time -- and row 37's ledger, which has **no window**, recorded the macro's
starting tile. Measured from the ratchet's rollback snapshot: one `GO ROUTE` from the town's
south entrance walked 26 tiles to (37, 18), was escorted, and walled **(18, 35)**, the south
entrance. Row 37's rule is right for a press (the fly is standing on the tile the script fires
on) and wrong for a walk. Walks start wherever the last one ended, so the walls accumulate
until the fly stands in a pocket no route leaves.
- **In the pocket every walk refuses `no route`**, and each refusal is recorded where it belongs:
`GO FRONTIER`'s marks the map exhausted (12.14, no window, cleared only by new ground, and there
was none for hours), and `GO OBJECTIVE`'s only goal, the gym's door, goes to the blocked ledger.
With every person and sign already talked to and the errands paid, nothing else is left.
- **The last resort is the one list that ignores the blocked ledger**, by design ("a target the
ledger is resting is still the only place to go", 13.1). So `ways` dealt `GO ROUTE` at the gym's
door, the route search refused it, the refusal wrote the door to a ledger the dealer does not
read, and the button was dealt again on the next hold. The same refusal **re-stamped the door's
window every hold**, which is why `GO OBJECTIVE` never came back either. Once per window the
road east lapsed, `GO ROUTE` walked it, and it was excluded again.
Seeded with that pocket -- three pushed tiles sealing the strip by the road from the town, the
frontier mark, everything talked to, the road east resting -- the cartridge deals exactly the live
pad: `GO ROUTE`, refused `no route` **746 holds running** on one tile over ten brain minutes.
**The fix, all three parts inside the macros:**
- **A walk walls the tile it last stood the fly on**, which is where the cartridge took over; every
other macro keeps the tile it started on. The same walk now walls (37, 18), one of the four
tiles the youngster fires on.
- **A refusal that taught the blocked ledger nothing is recorded with the tile the fly stood on,
and the dealer does not deal that button from that tile for the blocked window.** The dealer
asks the cheap question and `start` the real one, and the blocked ledger closes that gap for
every list but a last resort; this closes it for all of them without a route search in the
dealer, which runs every frame. Only a last resort deals goals the ledger is already resting, so
a `no route` whose every goal was resting already -- and any `precondition` refusal, which writes
nothing -- is the one remembered; a refusal that writes a new exclusion changes the next deal
by itself. (Measured: holding *every* refusal against the tile kept the real brain, which
pressed `GO ROUTE` first while the door was still the second tier's answer, from ever reaching
the last resort below.) It is a fact about *here*: the button is dealt again the moment the fly stands
on any other tile, or when the window closes. A pad with nothing left that can run is empty and
the fly waits, which is section 13.1's honest answer.
- **A last resort that cannot reach the objective's door takes a way out it can reach.** The
narrowing to "the ways toward the objective" is a preference the dealer cannot check, and in the
pocket it chose the gym's door beyond the fence while the road east was three tiles away,
resting in its window (which a last resort ignores). When `start`'s route search cannot reach
the preferred ways it tries the rest of the last resort, nearest reachable first, as every walk
chooses. The pad does not change; only where the pressed macro walks.
In the rebuilt pocket the fly now leaves on **frame 517** (0.14 brain minutes) -- walked east, met
by the youngster, carried to the gym -- against frame 36,325 on the base, when the road's window
lapsed; `GO ROUTE` is refused **once** there against 746 holds running.
Nothing presses for the fly and nothing is ranked: one button leaves a pad it could not run from,
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.
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
## priority to visit the shop at least once per area; make shop macros item purchases. same
## for the Pokécenter. heal should be a macro.")

View file

@ -67,6 +67,7 @@ log_info() {
: "${WD_LOOP_MIN_REPEATS:=20}" # ... repeating at least this many times
: "${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_STALL_PCT:=90}" # decisions that ended refused/blocked/timeout: this share is a stall (row 57)
: "${WD_LOOP_REPORT:=${WD_RUN_DIR}/loop.json}"
mkdir -p "$WD_RUN_DIR" "$WD_STATE_DIR"
@ -187,7 +188,7 @@ write_textfile_metrics() {
# convention as fly_watchdog_encoder_degraded), while the flag itself
# starts at 0, because a 0/1 flag carrying -1 reads as a loop to every
# alert expression that would ever use it.
echo "# HELP fly_loop_suspected 1 when the last check-10 probe found a short macro cycle repeating with no growth in the exploration count. Report only: the watchdog never restarts or presses anything for this."
echo "# HELP fly_loop_suspected 1 when the last check-10 probe found a short macro cycle repeating, one macro dominating, or the decisions stalled (refused/blocked) or completing nothing, with no growth in the exploration count. Report only: the watchdog never restarts or presses anything for this."
echo "# TYPE fly_loop_suspected gauge"
echo "fly_loop_suspected $(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)"
echo "# HELP fly_loop_period Length in macro labels of the shortest repeating block found at the end of the window (0 when nothing repeats, -1 before the first probe)."
@ -199,6 +200,18 @@ write_textfile_metrics() {
echo "# HELP fly_loop_distinct_macros Distinct macro names started in the window (-1 before the first probe)."
echo "# TYPE fly_loop_distinct_macros gauge"
echo "fly_loop_distinct_macros $(cat "${WD_RUN_DIR}/loop.distinct" 2>/dev/null || echo -1)"
# Row 57: outcomes, not only starts. A pad whose one button refuses
# every hold reads as one start and one name; these say what the
# decisions came to (-1 before the first probe).
echo "# HELP fly_loop_refused Macro presses refused (nothing pressed) in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_refused gauge"
echo "fly_loop_refused $(cat "${WD_RUN_DIR}/loop.refused" 2>/dev/null || echo -1)"
echo "# HELP fly_loop_blocked Macros that ended blocked or timed out in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_blocked gauge"
echo "fly_loop_blocked $(cat "${WD_RUN_DIR}/loop.blocked" 2>/dev/null || echo -1)"
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 "fly_loop_done $(cat "${WD_RUN_DIR}/loop.done" 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 "# TYPE fly_places_delta gauge"
echo "fly_places_delta $(cat "${WD_RUN_DIR}/loop.places_delta" 2>/dev/null || echo -1)"
@ -736,10 +749,22 @@ check_capture_freeze() {
# time, not wall time — a box running under 1.0x realtime would otherwise get
# a shorter window than the thresholds were measured over), the distinct macro
# names in it, and the shortest block up to WD_LOOP_MAX_PERIOD that repeats at
# the end of the sequence. Start events only: a start is one decision, and
# counting the `done` beside it would double every sequence and halve every
# period. They are the same names trap_hunt prints, so its numbers and these
# are comparable.
# the end of the sequence. The sequence is the fly's DECISIONS: a `start`, or a
# `refused` (a bound button that was pressed and did nothing, with no start
# beside it). Counting the `done` beside a start would double every sequence
# and halve every period. They are the same names trap_hunt prints, so its
# numbers and these are comparable.
#
# Row 57 (macros-traps.md): counting starts alone was blind to a pad whose
# one button refused. `GO ROUTE refused` ~740 times per 10 brain minutes for
# two hours read as 1 start, 1 distinct name, nothing flagged. So the window
# also counts every OUTCOME (done/blocked/timeout/refused), and two rules read
# them, both behind the same "no new ground" gate as the others:
# stalled — WD_LOOP_MIN_EVENTS+ decisions and WD_LOOP_STALL_PCT% of
# them ended refused, blocked or timed out;
# zero-progress — decisions in the window, not one `done` among them, on
# this probe AND the previous one (two probes, so a single
# unlucky window never flags).
#
# 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
@ -798,9 +823,12 @@ loop_macro_stream() {
# loop_analyze — reads that stream on stdin and echoes one tab-separated line:
#
# total US distinct US topCount US period US repeats US windowFrom US
# windowTo US topName US block US tail
# windowTo US topName US block US tail US decisions US refused US blocked US
# timeout US done
#
# `total` counts start events inside the window, `period`/`repeats` describe
# `total` counts start events inside the window, `decisions` starts plus
# refusals (the sequence, `distinct` and `topCount` are over decisions), and
# the last four count each outcome in the window. `period`/`repeats` describe
# 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
# names for context. The window ends at the newest macro event's own brain
@ -809,13 +837,19 @@ loop_analyze() {
awk -F'\t' -v win="$WD_LOOP_WINDOW_MS" -v maxp="$WD_LOOP_MAX_PERIOD" '
{ ms[NR] = $1 + 0; lbl[NR] = $2; n = NR }
END {
if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\n"; exit }
if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\0370\0370\0370\0370\0370\n"; exit }
to = ms[n]; from = to - win
k = 0; distinct = 0; topn = 0; top = ""
starts = 0; refused = 0; blocked = 0; timeout = 0; done = 0
for (i = 1; i <= n; i++) {
if (ms[i] < from) continue
if (lbl[i] !~ / start$/) continue
name = lbl[i]; sub(/ start$/, "", name)
if (lbl[i] ~ / blocked$/) { blocked++; continue }
if (lbl[i] ~ / timeout$/) { timeout++; continue }
if (lbl[i] ~ / done$/) { done++; continue }
if (lbl[i] ~ / refused$/) refused++
else if (lbl[i] ~ / start$/) starts++
else continue
name = lbl[i]; sub(/ (start|refused)$/, "", name)
seq[++k] = name
if (!(name in cnt)) { cnt[name] = 0; distinct++ }
cnt[name]++
@ -839,8 +873,9 @@ loop_analyze() {
tailstr = ""
start = k - 11; if (start < 1) start = 1
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\n", \
k, distinct, topn, period, repeats, from, to, top, block, tailstr
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", \
starts, distinct, topn, period, repeats, from, to, top, block, tailstr, \
k, refused, blocked, timeout, done
}'
}
@ -898,13 +933,20 @@ check_loop() {
echo "$delta" > "${WD_RUN_DIR}/loop.places_delta"
local analysis total distinct topn period repeats win_from win_to top block tailstr
local decisions refused blocked timeouts completed
analysis="$(loop_macro_stream | loop_analyze)"
IFS=$'\037' read -r total distinct topn period repeats win_from win_to top block tailstr <<< "$analysis"
IFS=$'\037' read -r total distinct topn period repeats win_from win_to top block tailstr \
decisions refused blocked timeouts completed <<< "$analysis"
total="${total:-0}"; distinct="${distinct:-0}"; topn="${topn:-0}"
period="${period:-0}"; repeats="${repeats:-0}"
decisions="${decisions:-0}"; refused="${refused:-0}"; blocked="${blocked:-0}"
timeouts="${timeouts:-0}"; completed="${completed:-0}"
echo "$distinct" > "${WD_RUN_DIR}/loop.distinct"
echo "$period" > "${WD_RUN_DIR}/loop.period"
echo "$repeats" > "${WD_RUN_DIR}/loop.repeats"
echo "$refused" > "${WD_RUN_DIR}/loop.refused"
echo "$(( blocked + timeouts ))" > "${WD_RUN_DIR}/loop.blocked"
echo "$completed" > "${WD_RUN_DIR}/loop.done"
local prev_suspected=0
[ -f "${WD_RUN_DIR}/loop.suspected" ] && prev_suspected="$(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)"
@ -917,13 +959,32 @@ check_loop() {
local grown=1
[ "$delta_known" -eq 1 ] && [ "$delta" -le 0 ] && grown=0
# Zero progress is judged over two probes: a window with decisions and no
# `done` among them is remembered, and only a second one in a row flags.
local idle_file="${WD_RUN_DIR}/loop.prev_idle" prev_idle=0 idle=0
[ -f "$idle_file" ] && prev_idle="$(cat "$idle_file" 2>/dev/null || echo 0)"
case "${prev_idle:-}" in ''|*[!0-9]*) prev_idle=0 ;; esac
[ "$decisions" -gt 0 ] && [ "$completed" -eq 0 ] && [ "$grown" -eq 0 ] && idle=1
echo "$idle" > "$idle_file"
local failed=$(( refused + blocked + timeouts ))
local suspected=0 reason=""
if [ "$total" -gt 0 ] && [ "$grown" -eq 0 ]; then
if [ "$distinct" -le "$WD_LOOP_MAX_DISTINCT" ] && [ "$repeats" -ge "$WD_LOOP_MIN_REPEATS" ]; then
if [ "$decisions" -gt 0 ] && [ "$grown" -eq 0 ]; then
if [ "$decisions" -ge "$WD_LOOP_MIN_EVENTS" ] \
&& [ $(( failed * 100 )) -ge $(( decisions * WD_LOOP_STALL_PCT )) ]; then
# Row 57: the fly keeps deciding and nothing it decides runs. A
# refused press is a decision with no start, which is why the
# rules below were blind to a pad of one button that refused.
suspected=1
reason="stalled"
elif [ "$idle" -eq 1 ] && [ "$prev_idle" -eq 1 ]; then
suspected=1
reason="zero-progress"
elif [ "$distinct" -le "$WD_LOOP_MAX_DISTINCT" ] && [ "$repeats" -ge "$WD_LOOP_MIN_REPEATS" ]; then
suspected=1
reason="sequence"
elif [ "$total" -ge "$WD_LOOP_MIN_EVENTS" ] \
&& [ $(( topn * 100 )) -ge $(( total * WD_LOOP_DOMINANCE_PCT )) ]; then
elif [ "$decisions" -ge "$WD_LOOP_MIN_EVENTS" ] \
&& [ $(( topn * 100 )) -ge $(( decisions * WD_LOOP_DOMINANCE_PCT )) ]; then
# One macro and almost nothing else. The floor under it is not in
# the spec but is load-bearing: two starts in ten brain minutes are
# 100% of a window and mean the fly is barely deciding at all,
@ -935,7 +996,7 @@ check_loop() {
echo "$suspected" > "${WD_RUN_DIR}/loop.suspected"
local dom_pct=0
[ "$total" -gt 0 ] && dom_pct=$(( topn * 100 / total ))
[ "$decisions" -gt 0 ] && dom_pct=$(( topn * 100 / decisions ))
# The sequence a reader wants: the repeating block when there is one, the
# single dominant name when the dominance rule is what fired.
local shown="$block"
@ -956,6 +1017,11 @@ check_loop() {
--argjson repeats "$repeats" \
--argjson distinct "$distinct" \
--argjson total "$total" \
--argjson decisions "$decisions" \
--argjson refused "$refused" \
--argjson blocked "$blocked" \
--argjson timeouts "$timeouts" \
--argjson completed "$completed" \
--argjson windowFrom "${win_from:-0}" \
--argjson windowTo "${win_to:-0}" \
--argjson windowMs "$WD_LOOP_WINDOW_MS" \
@ -982,6 +1048,8 @@ check_loop() {
window: {
brainMsFrom: $windowFrom, brainMsTo: $windowTo, brainMs: $windowMs,
macroStarts: $total,
decisions: $decisions,
outcomes: { done: $completed, blocked: $blocked, timeout: $timeouts, refused: $refused },
tail: (if $tail == "" then [] else ($tail | split(", ")) end)
},
dominant: { name: (if $dominant == "" then null else $dominant end),
@ -1005,13 +1073,15 @@ check_loop() {
local mins=$(( WD_LOOP_WINDOW_MS / 60000 ))
if [ "$suspected" -eq 1 ]; then
if [ "$reason" = "sequence" ]; then
if [ "$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'."
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'."
else
log_err "loop suspected: [${shown}] is ${dom_pct}% of ${total} macro starts in the last ${mins} brain minutes (${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. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
fi
elif [ "$prev_suspected" -eq 1 ]; then
log_info "loop cleared: ${total} macro starts over ${distinct} distinct name(s) in the last ${mins} brain minutes, longest repeat x${repeats} (period ${period}), exploration count ${places:-?} delta ${delta}. Nothing was ever done about the loop; if a fix went in, this is it landing."
log_info "loop cleared: ${total} macro starts (${decisions} decisions, ${completed} done, ${refused} refused) over ${distinct} distinct name(s) in the last ${mins} brain minutes, longest repeat x${repeats} (period ${period}), exploration count ${places:-?} delta ${delta}. Nothing was ever done about the loop; if a fix went in, this is it landing."
fi
}

View file

@ -2531,3 +2531,127 @@ own measurement and the fourth branch in a row to run into it. The merge is Fabl
- `flysim --print-compatibility`: **648 bytes, sha256 `4929f340...9ebd9`** -- byte-identical to this
branch's base `2de2dce`. Decoder, reward catalog, adapter version and roles untouched.
## 2026-09-23, row 57: one dead button, a last resort that could not see it, and walls the walks built
### What was live
Map 2 (Pewter City), scene `overworld`, rank 10, v0.5.3: the pad was **`GO ROUTE` and nothing
else**, `GO ROUTE refused` about **740 times per ten brain minutes for more than two hours**, one
`GO ROUTE start` / `blocked` pair every ten brain minutes, no button pressed, `uniqueLocations`
frozen at 1,846. The live log: `GO FRONTIER` blocked and timed out through 02:36-02:38, three
"Stuck" rollbacks to the rung's snapshot (the town's south entrance), `GO FRONTIER refused` at
02:38:44 and `GO ROUTE refused` every hold from 02:38:45. The refusal's `value 3.0` is the slot
(row 55). Check 10 read one start and one name and never flagged.
### The survey: earning the ledgers a restore throws away
The pad was dealt by session ledgers, and a restore starts them empty: from the live checkpoint
the pad is `GO OBJECTIVE, GO ROUTE, GO ITEM, GO NPC, GO FRONTIER`, and a twenty-brain-minute hunt
with the real brain covers 356 tiles; sixty brain minutes from the pre-deploy gym checkpoint
covers 1,064 without meeting it. `FLY_PROBE_CATCH=route` (`examples/scene_probe.rs`) drives the
real palette from the checkpoint and seeds each ledger (`examples/support/ledgers.rs`, shared
with the hunt as `FLY_TRAP_SEED_*`); `FLY_PROBE_RATCHET=1` starts from the rollback snapshot.
- **One escorted walk, one wall in the wrong place.** From the rollback snapshot at (18, 35),
`GO ROUTE` walked 26 tiles to (37, 18) -- one of `PewterCityPlayerLeavingEastCoords`, where the
youngster takes the joypad and walks the fly to the gym -- and the pushed ledger (row 37, no
window) walled **(18, 35)**: the tile the walk set out from.
- **The pocket.** Seeded with three pushed tiles sealing the strip by the road east from the town,
every tile stood on, every sign and person talked to, the frontier mark and the road east
resting: the pad is `GO OBJECTIVE, GO ROUTE`, `GO OBJECTIVE` refuses `no route` once, and then
`GO ROUTE` alone, refused `no route` every hold -- **746 holds running on one tile** in ten
brain minutes. Read at the frame: `stranded` true, `ways(Route)` = the gym's door (16, 17),
blocked and unreachable; the road east reachable and blocked. At the window's lapse `GO ROUTE`
walks east and the youngster carries the fly out.
| # | trap | trigger | test | fix, or why it is left |
| --- | --- | --- | --- | --- |
| 57 | a last resort is dealt by a dealer that ignores the blocked ledger, so its `no route` refusal cannot take it off the pad, and the refusal re-stamps the objective's door every hold | any map where the fly is walled off from the way toward the objective with nothing else left; Pewter City, two hours, `GO ROUTE` refused ~740 times per ten brain minutes | `a_way_out_refused_from_here_is_not_dealt_again_from_here`, part two of `the_pewter_east_pad_is_never_one_dead_button_from_the_rung_ten_checkpoint` (ROM-gated) | **fixed**: a refusal that teaches the blocked ledger nothing -- a `no route` whose every goal was already resting there, which only a last resort deals, or a `precondition` -- is recorded with the fly's tile, and the dealer does not deal that button from that tile for the blocked window. Dealt again the moment the fly stands elsewhere or the window closes. `docs/design/macros.md` 12.21 |
| 57b | the last resort narrows to the ways toward the objective, so a reachable way out resting in its window is never tried while the preferred door is unreachable | the same pocket: the gym's door beyond the fence, the road east three tiles away | `a_last_resort_that_cannot_reach_the_objectives_door_takes_a_way_out_it_can_reach`, part two of the ROM test | **fixed**: when `start`'s route search cannot reach the preferred ways it tries the rest of the last resort, nearest reachable first. The pad is unchanged |
| 57c | an escorted walk walls the tile it **set out from**, with no window, so walks toward a scripted road fence the fly in | Pewter City's youngster: every `GO ROUTE` toward Route 3 without the Boulder Badge | `an_escorted_walk_walls_the_tile_it_reached_not_the_one_it_set_out_from`, part one of the ROM test | **fixed**: a walk records the tile it last stood the fly on, which is where the cartridge took over; every other macro keeps its starting tile |
| 57d | check 10 counts macro starts only, so a pad whose one button is refused every hold reads as one start and one name | the whole of row 57 | `infra/tests/lint.sh` check-10 cases 5 and 6 | **fixed**: the window counts every outcome; `stalled` (90% of 20+ decisions refused, blocked or timed out) and `zero-progress` (decisions and no `done`, two probes running), both behind the no-new-ground gate; `fly_loop_refused`, `fly_loop_blocked`, `fly_loop_done` exported. It still never acts |
### The ROM-gated run, from the live checkpoint
`the_pewter_east_pad_is_never_one_dead_button_from_the_rung_ten_checkpoint`
(`FLY_PEWTER_EAST_CHECKPOINT`):
| measure | base `d5d9249` | this branch |
| --- | ---: | ---: |
| part one: tile the escorted walk walls | **(18, 35)**, where it set out | **(37, 18)**, where the script fired |
| part two: `GO ROUTE` refused in the pocket | **746 holds running** | **1** |
| part two: the fly leaves the pocket | frame 36,325 (10.1 brain min) | **frame 517 (0.14)** |
### The trap hunt, before and after
Same seed and checkpoint, twenty brain minutes, the real brain; base `d5d9249` (plus the survey
seam, no behaviour) against this branch.
**From the bare checkpoint** the two arms are **identical** -- 356 distinct tiles, 73 of 73
windows flagged, 1,272 macros, 3 refusals -- because the ledgers that make this row are built over
tens of minutes of a live session and a restore starts them empty. Sixty brain minutes from the
pre-deploy gym checkpoint on the base (1,064 tiles, 47 scattered refusals) does not meet it either.
**From the rebuilt pocket** (`FLY_TRAP_SEED_*`: the three-tile seal, every tile stood on, the
frontier mark, everything talked to, the road east resting):
| measure | before | after |
| --- | ---: | ---: |
| refused | **748** (`GO ROUTE` 747) | **6** |
| longest run of one macro refused on one tile | **747** | **1** |
| the first ten brain minutes | in the pocket: one overworld run of 35,874 frames | out at once; longest overworld run 1,948 |
| distinct (map, tile) | 303 | **439** |
| macros started | 592 | 1,168 |
| windows flagged | **31** / 73 | 66 / 73 |
**The flagged-window count rises, and this says why rather than smoothing it.** The before arm's
ten stuck minutes are not flagged at all: the hunt, like check 10 before this row, counts macro
*starts*, and a refused press starts nothing, so a window of 740 refusals on one tile reads as
silence. The after arm is flagged for battles (`NEXT` on one tile, row 50's rule) and, from 14.75
brain minutes, for **`GO OUT, GO OUT, GO OBJECTIVE` x16 to x35 on five tiles at the gym's door**.
That ring is **not this branch's**. The base, run 35 brain minutes from the same pocket with the
real brain, reaches the same ring -- `GO OUT, GO OBJECTIVE, GO OUT` x14 to x20 from 23.75 brain
minutes, about fourteen after it leaves the pocket at ten, `GO OUT` done 435 -- and driven with
uniform choices for 42 brain minutes from the pocket it runs it harder (`GO OUT` done **853**, `GO OBJECTIVE` 235, `GO ROUTE` refused
749) than this branch (`GO OUT` 243, `GO OBJECTIVE` 91, `GO ROUTE` refused 4). It is row 56's
named next brief -- `GO OBJECTIVE`'s aim at a person inside a building -- reached sooner because
the fly is no longer held in the pocket.
### Residuals, named rather than worked around
- **The live pocket is reconstructed, not recovered.** The pushed tiles the session held are
session state; the three-tile seal is the smallest one that reproduces the live pad and its
refusal rate. Live, the once-per-window `GO ROUTE` ended `blocked` in 3.8 s with no escort;
in the reconstruction it ends in the escort. The mechanism is the same; the exact walls are not
known.
- **A press answered by a script still walls its own tile** (row 37's rule, unchanged): the
`NEXT` that advanced the youngster's last box walled (11, 18), where the escort ends. Not a trap
measured here; named.
- **The hunt from the bare checkpoint cannot see this row**: the ledgers that make it are built
over tens of minutes of a live session. The seeded arm is the measurement.
- **The hunt's window rule still counts starts only.** It now prints refusals by macro and the
longest run on one tile, but a window of refusals is still not flagged; changing what the hunt
flags moves every earlier row's before/after and is left for its own change.
- **A trainer's sight line is a push-back too.** `scripted` is any joypad takeover, so a walk that
steps into a trainer's view walls the tile it reached -- and, before this row, the tile it set
out from -- with no window, though the trainer beaten opens it again. Moved, not introduced;
named.
- **Rung 11 is not reached** on any arm, as in row 56: `GO OBJECTIVE`'s aim at a person behind a
door is still the next brief.
- The runbook's "at most 3 distinct macro names" and `WD_LOOP_MAX_DISTINCT=4` in the watchdog
disagree; older than this row and left.
### Gates
- `cargo test --workspace --no-fail-fast` with `FLY_ROM` and `FLY_DATASET`: **1,245 passed, 1
failed** -- `flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`,
the debug-build boot failure row 56 recorded (`/status` still the booting header, `total` 1
against 38), **failing identically on the base `d5d9249`**. Nothing under `crates/flysim/src`
changes on this branch.
- ROM-gated in release with the live checkpoint: `the_pewter_east_pad_is_never_one_dead_button_from_the_rung_ten_checkpoint` passes.
- `cargo clippy --all-targets`: **0 warnings**.
- `npm test`: 655 passed, 0 failed; `npm run typecheck`: clean.
- `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
base `d5d9249`. Decoder, reward catalog, adapter version and roles untouched.

View file

@ -599,9 +599,17 @@ pct exec <ctid> -- cat /run/fly/wd/loop.json | jq .
| `fly_loop_repeats` | how many times that block repeats at the end of the 10-brain-minute window |
| `fly_loop_distinct_macros` | distinct macro names started in the window |
| `fly_places_delta` | growth in `game.uniqueLocations` since the previous probe (`-1` = no previous probe) |
| `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_done` | macros that ended `done` in the window |
The flag needs **both** halves: at most 3 distinct macro names with the block repeating 20+
times, or one macro at 95%+ of the window — **and** no growth in the exploration count. A
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
(`zero-progress`) — **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
pad (`GO ROUTE refused` ~740 times in ten brain minutes, `macros-traps.md`) as one start and
one name. A
repeating macro over ground that keeps growing is a walk longer than the 600-frame cap, not a
trap (`macros-traps.md`: `GO FRONTIER` x19 across 93 tiles), and the watchdog is deliberately
quiet about it. The thresholds are `WD_LOOP_*` in `infra/bin/fly-watchdog`; the 3-name ceiling

View file

@ -889,6 +889,26 @@ LPCAT
printf '{"id":999999,"wallMs":1758'
} > "$out"
}
# lp_outcomes FILE OUTCOME — like lp_events, but each name on stdin is one
# decision that ended OUTCOME: `refused` writes the refusal alone (nothing
# started, which is what a refused press is), anything else a start and
# that outcome. Row 57's shape is `GO ROUTE refused` every 800 brain ms.
lp_outcomes() {
local out="$1" outcome="$2" id=0 ms=0 nm
{
while IFS= read -r nm; do
if [ "$outcome" != "refused" ]; then
id=$(( id + 1 ))
printf '{"id":%d,"wallMs":%d,"brainMs":%d,"kind":"macro","label":"%s start","value":3}\n' \
"$id" "$(( 1758000000000 + id ))" "$ms" "$nm"
fi
id=$(( id + 1 ))
printf '{"id":%d,"wallMs":%d,"brainMs":%d,"kind":"macro","label":"%s %s","value":3}\n' \
"$id" "$(( 1758000000000 + id ))" "$ms" "$nm" "$outcome"
ms=$(( ms + 800 ))
done
} > "$out"
}
# lp_status FILE PLACES — the /status.json fields check 10 reads. `places`
# is `game.uniqueLocations`; there is no `places` field in the contract.
lp_status() {
@ -1001,10 +1021,54 @@ LPCAT
fail "check 10: expected the flag to clear on places growth, got flagged=${lp_flagged} then suspected=$(lp_metric fly_loop_suspected) places_delta=$(lp_metric fly_places_delta), journal: $(cat "$lp_fixture/journal.log")"
fi
# (5) Row 57: a pad of one button that refuses every hold. One start in the
# window and one name, so the sequence and dominance rules over starts
# alone never fired; the outcomes say it is a stall.
lp_reset
{ lp_cycle 700 "GO ROUTE" | lp_outcomes "$lp_fixture/refused.jsonl" refused
echo "GO ROUTE" | lp_outcomes "$lp_fixture/blocked.jsonl" blocked
cat "$lp_fixture/refused.jsonl" "$lp_fixture/blocked.jsonl"; } > "$lp_fixture/events.jsonl"
lp_status "$lp_fixture/status.json" 1846
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_refused)" = "700" ] \
&& [ "$(lp_metric fly_loop_blocked)" = "1" ] \
&& [ "$(lp_metric fly_loop_done)" = "0" ] \
&& grep -q 'loop suspected (stalled): \[GO ROUTE\]' "$lp_fixture/journal.log"; then
pass "check 10: a pad whose one button is refused every hold flags as stalled (700 refused, 1 blocked, 0 done)"
else
fail "check 10: the row-57 refusal log gave first=${lp_first} suspected=$(lp_metric fly_loop_suspected) refused=$(lp_metric fly_loop_refused) blocked=$(lp_metric fly_loop_blocked) done=$(lp_metric fly_loop_done), journal: $(cat "$lp_fixture/journal.log")"
fi
if lp_report="$(jq -e -r '[.reason, (.window.macroStarts|tostring), (.window.decisions|tostring), (.window.outcomes.refused|tostring), (.window.outcomes.done|tostring), .action] | join(" ")' "$lp_fixture/run/loop.json" 2>/dev/null)" \
&& [ "$lp_report" = "stalled 1 701 700 0 none" ]; then
pass "check 10: loop.json carries the decisions and every outcome, not only the starts"
else
fail "check 10: loop.json read back as '${lp_report:-UNREADABLE}' — expected 'stalled 1 701 700 0 none'"
fi
# (6) Zero progress: a handful of decisions, every one blocked, too few for
# the stall rule's floor. One probe of it is not enough; two in a row are.
lp_reset
lp_cycle 3 "GO OBJECTIVE" "GO FRONTIER" | lp_outcomes "$lp_fixture/events.jsonl" blocked
lp_status "$lp_fixture/status.json" 1846
lp_pass
lp_pass
lp_first="$(lp_metric fly_loop_suspected)"
lp_pass
if [ "$lp_first" = "0" ] && [ "$(lp_metric fly_loop_suspected)" = "1" ] \
&& grep -q 'loop suspected (zero-progress)' "$lp_fixture/journal.log"; then
pass "check 10: decisions that complete nothing over two probes with no new ground flag as zero-progress"
else
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
# The ethos, asserted rather than reviewed: over every case above, check 10
# restarted nothing. It reports; a human or a review agent decides.
if [ ! -s "$lp_fixture/systemctl.log" ]; then
pass "check 10: never acts — no unit was restarted across any of the four cases"
pass "check 10: never acts — no unit was restarted across any of the six cases"
else
fail "check 10 ACTED, which it must never do: $(cat "$lp_fixture/systemctl.log")"
fi

View file

@ -552,6 +552,22 @@ pub trait MacroState: GameState {
false
}
/// Whether the macro in `slot` was refused, for want of a route or of its precondition, on
/// the tile the fly is standing on now, inside the blocked window (`infra/docs/macros-traps.md`
/// row 57).
///
/// The dealer asks the cheap question and `start` asks the real one, so a button can be dealt
/// that its own route search refuses. The blocked ledger usually closes that gap -- the refusal
/// writes what it could not reach and the dealer stops offering it -- but not for a list that
/// deliberately ignores that ledger: a last resort. Live on rung 10, Pewter City, two hours:
/// the pad was `GO ROUTE` and nothing else, refused `no route` every 800 brain ms, because
/// `ways`'s last resort ignores the window its own refusal writes. A refusal from *here* is a
/// fact about here; the fly standing anywhere else, or the window closing, is what can change
/// it. Session state, never checkpointed.
fn refused_here(&mut self, _slot: u8) -> bool {
false
}
/// Whether `GO ITEM` or `GO NPC` has already arrived at `target` and faced it this session.
///
/// The other half of the same stall: `GO NPC` walked to the same villager again and again,
@ -907,6 +923,13 @@ pub trait TargetLedger {
/// Whether `target` on `map` has been arrived at and faced this session.
fn reached(&self, map: u8, target: TargetKey) -> bool;
/// Whether the macro in `slot` was refused standing on `tile` of `map`, inside the window
/// ([`MacroState::refused_here`]). Defaulted, so a ledger that never records one refuses
/// nothing.
fn refused(&self, _map: u8, _slot: u8, _tile: Tile) -> bool {
false
}
}
/// Ledgers that have recorded nothing: every target still a candidate.
@ -948,6 +971,9 @@ pub struct Targets {
/// stayed shut. The window keeps the loop bounded at one walk per target per window while
/// leaving the conversation available.
reached: std::collections::BTreeMap<(u8, TargetKey), f64>,
/// (map, slot, tile) -> the brain millisecond a macro was refused standing there
/// ([`MacroState::refused_here`], row 57). The same window as `blocked`.
refused: std::collections::BTreeMap<(u8, u8, Tile), f64>,
/// The brain clock of the frame being decided, from [`super::driver::PokemonPalette`].
now_ms: f64,
/// How long an entry excludes its target, in brain milliseconds.
@ -989,6 +1015,7 @@ impl Targets {
blocked: std::collections::BTreeMap::new(),
strikes: std::collections::BTreeMap::new(),
reached: std::collections::BTreeMap::new(),
refused: std::collections::BTreeMap::new(),
now_ms: 0.0,
window_ms: minutes * MINUTE_MS,
}
@ -1006,6 +1033,15 @@ impl Targets {
self.window_ms / MINUTE_MS
}
/// Record that the macro in `slot` was refused -- no route, or no precondition -- with the fly
/// standing on `tile` of `map` (row 57). Re-recording restarts the window, as `blocked` does.
pub fn record_refused(&mut self, map: u8, slot: u8, tile: Tile) {
let now = self.now_ms;
let window = self.window_ms;
self.refused.retain(|_, at| now - *at < window);
self.refused.insert((map, slot, tile), now);
}
/// Record a `Blocked` or `Timeout` abort against the target it was aimed at.
///
/// Re-recording restarts the window, which is the honest reading: the walk failed *again*.
@ -1080,4 +1116,8 @@ impl TargetLedger for Targets {
fn reached(&self, map: u8, target: TargetKey) -> bool {
self.reached.get(&(map, target)).is_some_and(|at| self.now_ms - *at < self.window_ms)
}
fn refused(&self, map: u8, slot: u8, tile: Tile) -> bool {
self.refused.get(&(map, slot, tile)).is_some_and(|at| self.now_ms - *at < self.window_ms)
}
}

View file

@ -165,6 +165,44 @@ impl PokemonPalette {
self.frontiers.len()
}
/// Read the frame exactly as the macros read it -- this session's ledgers included -- and hand
/// the state to `visit`. A survey seam (`examples/scene_probe.rs`), not a decision path: it
/// writes nothing, and what it sees is what the next `observe` would deal from.
pub fn inspect<R>(
&mut self,
memory: &mut dyn MemoryReader,
ledger: &dyn RunLedger,
visit: impl FnOnce(&mut dyn MacroState) -> R,
) -> R {
let mut state = PokeState::with_ledgers(
memory,
ledger,
&self.talked,
&self.targets,
&self.stood,
&self.areas,
&self.pushed,
)
.caching_grid(&mut self.grids)
.with_frontiers(&self.frontiers);
visit(&mut state)
}
/// The session's no-window ledgers, for a survey line: the tiles the cartridge has pushed the
/// fly off and the maps whose frontier is marked unreachable.
pub fn fences(&self) -> (&Pushed, &Frontiers) {
(&self.pushed, &self.frontiers)
}
/// The session's ledgers, writable, for a survey that has to rebuild a live session's state
/// from a checkpoint (a restore starts them empty by design). Never called by the loop.
#[doc(hidden)]
pub fn ledgers_mut(
&mut self,
) -> (&mut Talked, &mut Targets, &mut Stood, &mut Pushed, &mut Frontiers) {
(&mut self.talked, &mut self.targets, &mut self.stood, &mut self.pushed, &mut self.frontiers)
}
/// Take whatever the machine's last finished macro earned into the session's ledgers.
fn record_talk(&mut self) {
if let Some((map, target)) = self.machine.take_talked() {
@ -182,6 +220,11 @@ impl PokemonPalette {
if let Some((map, tile)) = self.machine.take_pushed() {
self.pushed.record(map, tile);
}
// A refusal from where the fly is standing: that button is not dealt again from this tile
// for the window (row 57). The fly moving, or the window closing, deals it again.
if let Some((map, slot, tile)) = self.machine.take_refused() {
self.targets.record_refused(map, slot, tile);
}
// A frontier the walk could not reach any of: a fact about this map's ground, with no
// window on it (section 12.14).
if let Some(map) = self.machine.take_exhausted() {
@ -414,6 +457,7 @@ impl MacroPalette for PokemonPalette {
// reach: the fly is about to be standing somewhere else.
let _ = self.machine.take_pushed();
let _ = self.machine.take_exhausted();
let _ = self.machine.take_refused();
// The cached palette was dealt for a frame that is being thrown away. Dropping it makes
// the next `start` before the next `observe` a nameless refusal, which presses nothing
// and reports nothing, rather than a named refusal against a scene that no longer exists.

View file

@ -36,7 +36,7 @@ use super::palette::{
precondition,
shop_screen, stock_index, throw_slot, untalked_objects, untalked_people, ways,
};
use super::path::{self, Route, Way};
use super::path::{self, Exit, Route, Way};
use super::state::{Facing, Scene, ShopScreen};
/// Hard cap on one macro, in game frames. `docs/design/macros.md` section 4: "hard cap 600 frames
@ -590,6 +590,10 @@ pub struct MacroMachine {
/// from the frame the push is seen, because by the time the next macro starts the fly has been
/// walked somewhere else.
pushed_tile: Option<(u8, Tile)>,
/// 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
/// ([`super::cartridge::Targets::record_refused`], row 57 of `infra/docs/macros-traps.md`).
refused_at: Option<(u8, u8, Tile)>,
/// The target a frame-cap `Timeout` spent itself on, and whether the walk ended nearer a goal
/// than it began: [`super::cartridge::Targets::record_timeout`]'s two arguments.
///
@ -661,6 +665,7 @@ impl MacroMachine {
reached: None,
exhausted: None,
pushed_tile: None,
refused_at: None,
timed_out: None,
resume: VecDeque::new(),
pending_talk: None,
@ -689,10 +694,19 @@ impl MacroMachine {
machine.outcome = Some((spec.name, MacroAbort::Refused));
Err(MacroRefused { slot, reason })
};
// Where the fly stands for a refusal that is a fact about *here* (row 57): no route from
// this tile, or a precondition the dealer and the starter answered differently on it.
let here = state.player().map(|player| (player.map, Tile::new(player.x, player.y)));
let refused_here = |machine: &mut Self| {
if let Some((map, tile)) = here {
machine.refused_at = Some((map, slot.0, tile));
}
};
if class(scene) != class(palette.scene) {
return refuse(self, Refusal::WrongScene);
}
if !precondition(spec.kind, state) {
refused_here(self);
return refuse(self, Refusal::Precondition);
}
let mut unreachable: Vec<TargetKey> = Vec::new();
@ -702,6 +716,13 @@ impl MacroMachine {
// all" -- and the route search is the real one, so a bound macro can refuse `no route`
// once per hold for ever while the candidate list never changes. Recording what it
// could not reach is what empties the list and takes the button off the pad.
// Row 57: a refusal that teaches the blocked ledger nothing new -- every goal it could
// not reach is already resting there -- is one the dealer will deal again unchanged,
// because only a last resort deals goals the ledger is resting. That refusal is the
// one remembered where the fly stands. A refusal that writes a new exclusion changes
// the next deal by itself and is not held against the tile: the next deal may be the
// last resort, whose own walk can go where this one could not.
let taught = unreachable.iter().any(|key| !state.blocked(*key));
if let Some(map) = state.player().map(|player| player.map) {
self.blocked.extend(unreachable.into_iter().map(|key| (map, key)));
// And for the frontier, the same fact one level up: every tile of this map the
@ -712,6 +733,9 @@ impl MacroMachine {
self.exhausted = Some(map);
}
}
if !taught {
refused_here(self);
}
return refuse(self, Refusal::NoRoute);
};
// The map the target was chosen on, so an entry cannot be read back on another map.
@ -883,6 +907,12 @@ impl MacroMachine {
self.pushed_tile.take()
}
/// Where the last `no route` or `precondition` refusal happened, taken rather than read
/// (row 57).
pub fn take_refused(&mut self) -> Option<(u8, u8, Tile)> {
self.refused_at.take()
}
/// The frame-cap timeout a walk earned, with whether it ended nearer its goal, taken rather
/// than read. The ledger decides what it means.
pub fn take_timeout(&mut self) -> Option<(u8, TargetKey, bool)> {
@ -908,6 +938,7 @@ impl MacroMachine {
// out of reach either: the fly is about to be somewhere else entirely.
self.pushed_tile = None;
self.exhausted = None;
self.refused_at = None;
self.timed_out = None;
// A rollback puts the fly somewhere else on the map, so every suspended route is a route
// from a tile it is no longer standing on. `take_resume` would refuse them one at a time;
@ -1088,8 +1119,20 @@ impl MacroMachine {
// say (row 37 of `infra/docs/macros-traps.md`). The fly may already have been
// walked a tile by the script, so the tile the macro set out from is the honest
// answer when there is one and the current tile otherwise.
//
// **Except for a walk** (row 57). A walk set out from wherever the last one left
// the fly, and the script fired on the tile the walk had *reached*: Pewter City's
// youngster takes the joypad on four tiles by the road east, and a `GO ROUTE` that
// set out from the town's south entrance twenty-six tiles away walled the south
// 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
// is its own record of where the cartridge took over.
if let Some(player) = at {
let tile = active.from.unwrap_or(Tile::new(player.x, player.y));
let current = Tile::new(player.x, player.y);
let tile = match active.plan.front() {
Some(Step::Walk(walk)) => walk.expect.unwrap_or(current),
_ => active.from.unwrap_or(current),
};
self.pushed_tile = Some((player.map, tile));
}
}
@ -1652,7 +1695,17 @@ fn script(
};
let goals = exit_goals(state, way);
unreachable.extend(goal_keys(&goals));
let (walk, target) = walk_to(state, goals)?;
// A last resort that preferred the way toward the objective, and the route search
// cannot reach it: the rest of the last resort, nearest reachable first (row 57).
let walked = match walk_to(state, goals) {
Some(walked) => Some(walked),
None => {
let rest = super::palette::last_resort_wide(state, way);
let wide = goals_of(state, rest);
if wide.is_empty() { None } else { walk_to(state, wide) }
}
};
let (walk, target) = walked?;
aimed = target;
vec![Step::Walk(walk)]
}
@ -2041,7 +2094,13 @@ fn one_target(goals: &[Goal]) -> Option<TargetKey> {
/// is in the moment it comes down a staircase and lands on the warp tile. A doormat underfoot is
/// different: its press is the point, so it stays.
fn exit_goals(state: &mut dyn MacroState, way: Way) -> Vec<Goal> {
let mut goals: Vec<Goal> = ways(state, way)
let exits = ways(state, way);
goals_of(state, exits)
}
/// [`exit_goals`] over a list of exits the caller already has.
fn goals_of(state: &mut dyn MacroState, exits: Vec<Exit>) -> Vec<Goal> {
let mut goals: Vec<Goal> = exits
.into_iter()
.map(|exit| Goal {
tile: exit.tile,

View file

@ -440,7 +440,10 @@ impl Palette {
pub fn for_scene(scene: Scene, state: &mut dyn MacroState) -> Self {
let mut slots: [Option<MacroSpec>; SLOTS] = [None; SLOTS];
for kind in scene_set(scene, state) {
if precondition(kind, state) {
// A button refused from this very tile inside the window is not dealt again from it
// (row 57): the dealer's question is the cheap one, and `start`'s answer to the real
// one outranks it until the fly stands somewhere else or the window closes.
if precondition(kind, state) && !state.refused_here(kind.slot()) {
slots[usize::from(kind.slot())] = Some(MacroSpec::of(kind));
}
}
@ -1256,6 +1259,49 @@ fn last_resort(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
if toward.is_empty() { every } else { toward }
}
/// Every way out of this kind, when [`ways`] answered with its last resort and that answer was
/// narrowed to the ways toward the objective; empty otherwise.
///
/// Row 57 (`infra/docs/macros-traps.md`). The narrowing is a preference -- "a map the fly has
/// already seen is still the way to the next rung" -- and the dealer cannot search, so it can
/// prefer a door the fly is walled off from over one it can walk to. Live in Pewter City the last
/// resort was the gym's door on the far side of a fence while the road east, resting in the
/// blocked window, was three tiles away. This is the rest of the last resort, for `start` to try
/// when its route search cannot reach the preferred ones; the choice of *which* reachable way is
/// the route search's, nearest first, exactly as it is for every walk.
pub fn last_resort_wide(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
// Only where `ways` fell through to the last resort: a tier that has anything in it is
// already the answer, and so is a room's or a floor's own unexcluded list.
if !exit_tiers(state, way).is_empty() {
return Vec::new();
}
match way {
Way::Exit => {
if !unexcluded_exits(state, way).is_empty() {
return Vec::new();
}
}
Way::Passage => {
if path::exits(state).iter().any(|exit| exit.way == Way::Exit)
|| !unexcluded_exits(state, way).is_empty()
{
return Vec::new();
}
}
Way::Route => {}
}
if !stranded(state) {
return Vec::new();
}
let every: Vec<Exit> =
path::exits(state).into_iter().filter(|exit| exit.way == way).collect();
if toward_objective(state, &every).is_empty() {
// Not narrowed: the last resort was every one of them already.
return Vec::new();
}
every
}
/// The exits of the current map of one kind that no ledger excludes.
fn unexcluded_exits(state: &mut dyn MacroState, way: Way) -> Vec<Exit> {
if !objective_targets(state).is_empty() {

View file

@ -729,6 +729,10 @@ impl MacroState for World {
self.targets.reached(self.map, target)
}
fn refused_here(&mut self, slot: u8) -> bool {
self.targets.refused(self.map, slot, self.player)
}
fn objective(&mut self) -> Option<Objective> {
self.objective
}
@ -786,6 +790,9 @@ fn drive(
if let Some(map) = machine.take_exhausted() {
world.exhausted.insert(map);
}
if let Some((map, slot, tile)) = machine.take_refused() {
world.targets.record_refused(map, slot, tile);
}
return Err(refused);
}
// A walk's cap is its plan's, so the bound here is the ceiling on any macro plus slack.
@ -4873,6 +4880,155 @@ fn a_tile_the_cartridge_pushes_the_fly_off_is_not_a_tile_to_walk_to() {
);
}
// ---------------------------------------------------------------------------------------------
// Row 57: a last resort refused from here is not dealt again from here
// ---------------------------------------------------------------------------------------------
/// Pewter City as the live pad found it: the fly fenced into a corner of the town, the
/// frontier marked, every person and sign accounted for, the errands paid -- and the only way
/// out anything aims at, the gym's door, on the far side of the fence.
fn fenced_in_pewter() -> World {
let mut world = World::room().at(3, 3);
world.map = maps::PEWTER_CITY;
world.size = MapSize { width: 20, height: 12 };
// The fence: a wall the height of the map, with the fly on the near side of it.
for y in 0..12 {
world.walls.insert(Tile::new(10, y));
}
world.warps = vec![Warp { x: 15, y: 3, destination_warp: 0, destination_map: maps::PEWTER_GYM }];
world.seen_maps.insert(maps::PEWTER_GYM);
world.objective = Some(Objective {
map: maps::PEWTER_GYM,
tile: None,
warp: None,
edge: None,
target: Some(PlaceKind::Person),
});
world.areas.insert((Amenity::Mart, maps::PEWTER_CITY));
world.areas.insert((Amenity::Center, maps::PEWTER_CITY));
world.exhausted.insert(maps::PEWTER_CITY);
world
}
#[test]
fn a_way_out_refused_from_here_is_not_dealt_again_from_here() {
// Row 57, live on rung 10 for two hours: the pad was `GO ROUTE` alone, `refused` every 800
// brain ms, nothing pressed. `ways`'s last resort deliberately ignores the blocked ledger, so
// the `no route` refusal -- which writes what it could not reach to that ledger -- could not
// take the button off the pad, and the same refusal re-stamped the gym door every hold, which
// kept `GO OBJECTIVE`'s only goal excluded for ever.
let mut world = fenced_in_pewter();
let door = TargetKey::Exit(ExitId::Warp(0));
let names = |world: &mut World| -> Vec<&'static str> {
let scene = world.scene();
plan::plan_for(scene, world).slots.iter().flatten().map(|spec| spec.name).collect()
};
assert_eq!(names(&mut world), ["GO OBJECTIVE", "GO ROUTE"], "the door, two ways");
assert_eq!(
run(&mut world, MacroKind::GoObjective).map_err(|refused| refused.reason),
Err(Refusal::NoRoute)
);
assert!(world.targets.blocked(world.map, door), "the door is excluded for the window");
assert_eq!(names(&mut world), ["GO ROUTE"], "and the last resort still deals it");
assert_eq!(
run(&mut world, MacroKind::GoRoute).map_err(|refused| refused.reason),
Err(Refusal::NoRoute),
"the route search agrees with the fence"
);
// The trap: before row 57 the same button was dealt again, from the same tile, on the next
// hold, for ever.
assert!(
names(&mut world).is_empty(),
"a button refused from this tile is not dealt again from it: {:?}",
names(&mut world)
);
// It is a fact about *here*, not a retirement: standing anywhere else deals it again ...
world.player = Tile::new(3, 4);
assert_eq!(names(&mut world), ["GO ROUTE"]);
// ... and so does the window closing on the same tile.
world.player = Tile::new(3, 3);
assert!(names(&mut world).is_empty());
world.targets.clock(BLOCKED_MINUTES_DEFAULT * 60_000.0 + 1.0);
assert!(names(&mut world).contains(&"GO ROUTE"), "{:?}", names(&mut world));
}
#[test]
fn a_last_resort_that_cannot_reach_the_objectives_door_takes_a_way_out_it_can_reach() {
// Row 57's pocket had a way out: the road east, three tiles from the fly and resting in the
// blocked window. The last resort ignores that window but *narrows* to the ways toward the
// objective, and the only one was the gym's door on the far side of the fence -- so the
// route search refused, and the reachable road was never tried. The narrowing is a
// preference; when the route search cannot honour it, the rest of the last resort is the
// walk's to take, nearest reachable first.
let mut world = fenced_in_pewter();
world.connections = Connections { north: false, south: true, east: false, west: false };
world.seen_maps.insert(maps::ROUTE_2);
let south = TargetKey::Exit(ExitId::Edge(Edge::South));
world.targets.record_blocked(world.map, south);
world.targets.record_blocked(world.map, TargetKey::Exit(ExitId::Warp(0)));
assert!(on_the_pad(&mut world, MacroKind::GoRoute), "the last resort deals it");
let started_at = world.player;
let outcome = run(&mut world, MacroKind::GoRoute);
assert!(outcome.is_ok(), "the road it can reach is walked, not refused: {outcome:?}");
assert!(
world.player.y > started_at.y,
"south, toward the way out on this side of the fence: {:?}",
world.player
);
// The order the real brain pressed them in, measured on the seeded pocket: `GO ROUTE` first,
// while the door is still the second tier's answer rather than the last resort's. That refusal
// teaches the ledger the door, so it is not held against the tile -- the next deal is the last
// resort, and its walk goes where the first could not.
let mut world = fenced_in_pewter();
world.connections = Connections { north: false, south: true, east: false, west: false };
world.seen_maps.insert(maps::ROUTE_2);
world.targets.record_blocked(world.map, south);
world.exhausted.insert(world.map);
world.objective = Some(Objective {
map: maps::PEWTER_GYM,
tile: None,
warp: None,
edge: None,
target: Some(PlaceKind::Person),
});
assert_eq!(
run(&mut world, MacroKind::GoRoute).map_err(|refused| refused.reason),
Err(Refusal::NoRoute),
"the door, through the second tier, beyond the fence"
);
assert!(on_the_pad(&mut world, MacroKind::GoRoute), "a refusal that taught the ledger is not held");
let started_at = world.player;
assert!(run(&mut world, MacroKind::GoRoute).is_ok(), "the last resort walks where it can");
assert!(world.player.y > started_at.y, "{:?}", world.player);
}
#[test]
fn an_escorted_walk_walls_the_tile_it_reached_not_the_one_it_set_out_from() {
// Row 57's other half. Pewter City's youngster takes the joypad on four tiles by the road
// east and walks the fly to the gym. A `GO ROUTE` that set out from the town's south entrance
// and reached one of those tiles wrote the *south entrance* into the pushed ledger -- a wall
// with no window, twenty-six tiles from where the script fired -- and a dozen of those fenced
// the fly into a pocket. The tile the walk had reached is where the cartridge took over.
let mut world = World::room().at(3, 6);
world.map = maps::PEWTER_CITY;
world.connections = Connections { north: true, south: false, east: false, west: false };
// Three tiles walked north, then the script: the scene changes with the game driving the fly.
world.scripted_at = Some(52);
world.switch = Some((52, Scene::Dialog));
let mut machine = MacroMachine::new(1);
let _ = run_with(&mut machine, &mut world, MacroKind::GoRoute);
let (map, tile) = machine.take_pushed().expect("the script moved the fly: a push-back");
assert_eq!(map, maps::PEWTER_CITY);
assert_ne!(tile, Tile::new(3, 6), "not the tile the walk set out from");
assert_eq!(tile, world.player, "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.
#[test]
fn a_scripted_push_back_records_the_tile_it_happened_on() {

View file

@ -1804,6 +1804,12 @@ impl MacroState for PokeState<'_> {
self.targets.blocked(map, target)
}
/// Whether the macro in `slot` was refused on the tile the fly is standing on (row 57).
fn refused_here(&mut self, slot: u8) -> bool {
let Some(player) = player(self.memory) else { return false };
self.targets.refused(player.map, slot, Tile::new(player.x, player.y))
}
/// Whether `GO ITEM` or `GO NPC` has already reached `target` on the map that is loaded.
fn reached(&mut self, target: TargetKey) -> bool {
let Some(map) = player(self.memory).map(|player| player.map) else { return false };

View file

@ -46,6 +46,9 @@ use flysim::config::Config;
use flysim::macros::macro_layer;
use flysim::snapshot::MacroMode;
#[path = "support/ledgers.rs"]
mod ledgers;
const MS_PER_FRAME: f64 = 1000.0 / 59.7275;
const BURST_MS: f64 = 100.0;
const HOLDS_PER_SLOT: usize = 3;
@ -1337,6 +1340,238 @@ fn dialog_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64
println!("\n{}", separator_table(&classes));
}
/// `FLY_PROBE_CATCH=route`. The live trap was map 2, scene `overworld`, a pad of `GO ROUTE` alone,
/// refused about 740 times per ten brain minutes for hours with no button pressed. The ledgers
/// that dealt that pad are session state and a restore starts them empty, so this *earns* them:
/// it drives the real [`PokemonPalette`] from the checkpoint, choosing uniformly among whatever the
/// scene binds once per hold (the brain's hold, not a ranking), and prints every pad it deals and
/// every refusal with its reason. When the pad is down to one button that keeps refusing, it reads
/// the frame the way the macros do -- ledgers included -- and says which list emptied why, and
/// whether the route search can reach any of the one button's goals.
///
/// `FLY_PROBE_FRAMES` bounds the drive; `FLY_PROBE_RNG` changes the choices; `FLY_PROBE_PREFER`
/// (comma-separated names) presses those buttons whenever they are dealt.
///
/// [`PokemonPalette`]: flybrain_gb::pokemon_red::macros::PokemonPalette
fn route_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) {
use flybrain_gb::MacroPalette;
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, TargetKey};
use flybrain_gb::pokemon_red::macros::path::Way;
use flybrain_gb::pokemon_red::macros::{PokemonPalette, palette, path};
let budget = env_usize("FLY_PROBE_FRAMES", 240_000);
let mut rng = env_usize("FLY_PROBE_RNG", 20_260_923) as u32 | 1;
let hold_frames = 48usize;
let trace_frames = env_usize("FLY_PROBE_TRACE_FRAMES", 0);
// `FLY_PROBE_CATCH_AFTER=0` reads the frame at once, before any choice.
let catch_after = env_usize("FLY_PROBE_CATCH_AFTER", 20) as u32;
let prefer: Vec<String> = std::env::var("FLY_PROBE_PREFER")
.map(|value| value.split(',').map(|name| name.trim().to_string()).collect())
.unwrap_or_default();
let mut macros = PokemonPalette::new(SEED);
if let Some(seeded) = ledgers::seed(&mut macros, gb, adapter, *ms, "FLY_PROBE_SEED") {
println!("\n- seeded: {seeded}");
}
let mut running = false;
let mut since_decision = hold_frames;
let mut last_pad = String::new();
let mut refusals: BTreeMap<String, u64> = BTreeMap::new();
let mut outcomes: BTreeMap<String, u64> = BTreeMap::new();
let mut single_refusals = 0u32;
let mut caught_at: Option<usize> = None;
// `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.
if let Ok(holds) = std::env::var("FLY_PROBE_HOLD") {
println!("\n## Raw holds before the drive\n\n```");
for hold in holds.split(',') {
let (name, frames) = hold.split_once(':').unwrap_or((hold, "32"));
let mask = match name {
"right" => flybrain_gb::buttons::RIGHT,
"left" => flybrain_gb::buttons::LEFT,
"up" => flybrain_gb::buttons::UP,
"down" => flybrain_gb::buttons::DOWN,
"a" => flybrain_gb::buttons::A,
"b" => flybrain_gb::buttons::B,
_ => 0,
};
for index in 0..frames.parse::<usize>().unwrap_or(32) {
gb.set_buttons(mask);
gb.run_frame().expect("a frame should complete");
*ms += MS_PER_FRAME;
adapter.sample(gb, *ms);
if index % 8 == 7 {
println!(
"{name:>5} +{index:<3} {:?} scene {:?} sim={} flags5={:#04x} joyignore={:#04x}",
state::player(gb).map(|p| (p.map, p.x, p.y, p.facing)),
scene::detect(gb),
gb.read8(ram::wSimulatedJoypadStatesIndex),
gb.read8(ram::wStatusFlags5),
gb.read8(ram::wJoyIgnore),
);
}
}
}
println!("```");
}
println!("\n## The drive: the real palette, one uniform choice per hold\n\n```");
for frame in 0..budget {
macros.clock(*ms);
let observed = {
let ledger = AdapterLedger(&*adapter);
macros.observe(gb, &ledger)
};
let names: Vec<&str> = observed.bindings.iter().map(|binding| binding.name).collect();
let player = state::player(gb);
let pad = format!("{:?} {names:?}", observed.scene);
if pad != last_pad {
println!("f{frame:<6} {:?} pad {pad}", player.map(|p| (p.map, p.x, p.y)));
last_pad = pad;
}
let mut mask = 0u8;
if running {
let ledger = AdapterLedger(&*adapter);
match macros.step(gb, &ledger) {
Some(held) => mask = held,
None => running = false,
}
} else if since_decision >= hold_frames && !observed.bindings.is_empty() {
since_decision = 0;
rng ^= rng << 13;
rng ^= rng >> 17;
rng ^= rng << 5;
// `FLY_PROBE_PREFER` names buttons to press whenever they are dealt, first one first:
// the live brain's measured favourites, so the survey can earn the ledgers the live
// session earned. A survey's driver, never the fly's: nothing in the crate reads it.
let preferred = prefer
.iter()
.find_map(|want| observed.bindings.iter().find(|binding| binding.name == want));
let binding = preferred
.unwrap_or(&observed.bindings[rng as usize % observed.bindings.len()]);
let ledger = AdapterLedger(&*adapter);
match macros.start(binding.slot, gb, &ledger) {
flybrain_gb::Started::Running(_) => {
running = true;
if let Some(held) = macros.step(gb, &ledger) {
mask = held;
} else {
running = false;
}
}
flybrain_gb::Started::Refused { name, reason } => {
let key = format!("{} {reason}", name.unwrap_or("-"));
*refusals.entry(key.clone()).or_default() += 1;
if names.len() == 1 && name.is_some() {
single_refusals += 1;
} else {
single_refusals = 0;
}
if refusals[&key] <= 3 || single_refusals == 1 {
println!(
"f{frame:<6} {:?} refused {key} (pad {names:?})",
player.map(|p| (p.map, p.x, p.y))
);
}
}
}
}
if let Some((name, outcome)) = macros.take_finished() {
*outcomes.entry(format!("{name} {outcome:?}")).or_default() += 1;
if !matches!(outcome, flybrain_gb::Outcome::Done) {
println!(
"f{frame:<6} {:?} {name} {outcome:?}",
state::player(gb).map(|p| (p.map, p.x, p.y))
);
}
}
since_decision += 1;
if frame < trace_frames {
println!(
" t{frame:<5} {:?} mask {mask:#04x} running {:?} marks {:?} stood {}",
state::player(gb).map(|p| (p.map, p.x, p.y, p.facing)),
macros.running(),
macros.fences().1,
macros.stood()
);
}
gb.set_buttons(mask);
gb.run_frame().expect("a frame should complete");
*ms += MS_PER_FRAME;
adapter.sample(gb, *ms);
if single_refusals >= catch_after {
caught_at = Some(frame);
break;
}
}
println!("```\n");
println!("- refusals: {refusals:?}");
println!("- outcomes: {outcomes:?}");
let Some(frame) = caught_at else {
println!("- pushed tiles at the end (no window): {:?}", macros.fences().0);
println!("\nThe pad never came down to one refusing button in {budget} frames.");
return;
};
println!(
"\n## Caught on frame {frame} ({:.1} brain minutes): one button, refused twenty holds running\n",
frame as f64 * MS_PER_FRAME / 60_000.0
);
let (pushed, frontiers) = macros.fences();
println!("- pushed tiles (no window): {pushed:?}");
println!("- frontier marks (no window): {frontiers:?}");
let ledger = AdapterLedger(&*adapter);
macros.inspect(gb, &ledger, |state: &mut dyn MacroState| {
let player = state.player().expect("a loaded map");
println!("- player: {player:?}");
println!("- objective: {:?}", state.objective());
println!("- `objective_goals`: {:?}", palette::objective_goals(state));
println!("- `objective_targets`: {:?}", palette::objective_targets(state));
println!("- `untalked_people`: {:?}", palette::untalked_people(state));
println!("- `untalked_objects`: {:?}", palette::untalked_objects(state));
println!("- `facing_untalked`: {}", palette::facing_untalked(state));
println!("- `frontier_aims`: {} tiles", palette::frontier_aims(state).len());
println!("- `frontier_exhausted`: {}", state.frontier_exhausted());
println!("- `stranded`: {}", palette::stranded(state));
for way in [Way::Exit, Way::Passage, Way::Route] {
println!("- `ways({way:?})`: {:?}", palette::ways(state, way).iter().map(|exit| (exit.id, exit.tile)).collect::<Vec<_>>());
}
println!("\n### Every way out, and whether the route search reaches it from here\n");
for exit in path::exits(state) {
let reach = path::route(state, &[exit.tile]).map(|route| route.goal.is_some());
println!(
"- {:?} at ({:2},{:2}) way {:?} -> {:?}: map_visited {:?}, blocked {}, reachable {:?}",
exit.id,
exit.tile.x,
exit.tile.y,
exit.way,
exit.destination(player.map),
exit.destination(player.map).map(|map| state.map_visited(map)),
state.blocked(TargetKey::Exit(exit.id)),
reach
);
}
println!("\n### The fly's own neighbourhood (pushed = `P`, player = `@`)\n\n```");
for y in player.y.saturating_sub(3)..=player.y.saturating_add(3) {
let row: String = (player.x.saturating_sub(6)..=player.x.saturating_add(6))
.map(|x| {
if x == player.x && y == player.y {
'@'
} else if state.pushed_tile(x, y) {
'P'
} else {
match state.walkable(x, y) {
flybrain_gb::pokemon_red::macros::state::Walkable::Yes => '.',
flybrain_gb::pokemon_red::macros::state::Walkable::No => '#',
flybrain_gb::pokemon_red::macros::state::Walkable::Unknown => '?',
}
}
})
.collect();
println!("y{y:2} x{:2}.. {row}", player.x.saturating_sub(6));
}
println!("```");
});
}
/// How the candidate readings of "a two-option box is up" separate the frames of a survey.
///
/// Three columns, because three things could say it and only a measurement says which: the
@ -1372,7 +1607,13 @@ fn main() {
let mut gb = Emulator::new(&rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
.expect("binjgb should accept the cartridge");
let mut adapter = PokemonRedReward::new();
// `FLY_PROBE_RATCHET=1` starts from the ratchet's best snapshot instead: where a "Stuck"
// rollback puts the fly, which is where a live session that spent its rollbacks resumed from.
if std::env::var("FLY_PROBE_RATCHET").is_ok_and(|value| value == "1") {
gb.import_state(&checkpoint.runtime.ratchet_game).expect("the ratchet's snapshot");
} else {
gb.import_state(&checkpoint.runtime.emulator).expect("the checkpoint's emulator state");
}
adapter.import_state(&checkpoint.runtime.reward).expect("the checkpoint's reward ledger");
let channels = flybrain_gb::macro_channels("pokemon-red");
@ -1436,6 +1677,13 @@ fn main() {
return;
}
// Row 57's pad survey: earn the session's ledgers from the checkpoint with the real palette,
// and read the frame the pad comes down to one refusing button on.
if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "route") {
route_survey(&mut gb, &mut adapter, &mut ms);
return;
}
let budget = env_usize("FLY_PROBE_FRAMES", 200_000);
let stuck_after = env_usize("FLY_PROBE_STUCK", 600);
let mut next_burst = ms;

View file

@ -0,0 +1,95 @@
//! Rebuild a live session's macro ledgers from the environment, because a restore starts them
//! empty (`docs/design/macros.md` section 12: the ledgers are session state and never reach the
//! checkpoint). Shared by `scene_probe` and `trap_hunt`; `infra/docs/macros-traps.md` row 57 is
//! the trap that needed it: the pad that stalled was dealt by ledgers no checkpoint carries.
//!
//! Every variable is `<PREFIX>_<NAME>`, so the two examples keep their own namespaces:
//!
//! | name | meaning |
//! | --- | --- |
//! | `PUSHED` | `"x,y;x,y"`: tiles of the loaded map walled the way a scripted push-back walls them |
//! | `EXHAUSTED` | `1`: the loaded map's frontier marked unreachable (section 12.14) |
//! | `TALKED` | `1`: every person and sign on the loaded map written into the talked ledger |
//! | `BLOCKED` | `"warp2;east"`: those exits rested for the blocked window |
//! | `STOOD` | `1`: the adapter's covered ground; `all`: every tile of the map (no new ground at all) |
//!
//! The tile underfoot is always recorded as stood on, or the first observe would read it as new
//! ground and clear the frontier mark being rebuilt. Nothing is seeded when no variable is set.
use flybrain_gb::pokemon_red::macros::PokemonPalette;
use flybrain_gb::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState, TargetKey};
use flybrain_gb::pokemon_red::macros::{Tile, path};
use flybrain_gb::pokemon_red::{PokemonRedReward, state};
use flybrain_gb::{AdapterLedger, Emulator, MacroPalette};
/// Seed `macros` from `<prefix>_*` at brain millisecond `ms`, and say what was seeded.
pub fn seed(
macros: &mut PokemonPalette,
gb: &mut Emulator,
adapter: &PokemonRedReward,
ms: f64,
prefix: &str,
) -> Option<String> {
let var = |name: &str| std::env::var(format!("{prefix}_{name}")).ok();
let names = ["PUSHED", "EXHAUSTED", "TALKED", "BLOCKED", "STOOD"];
if names.iter().all(|name| var(name).is_none()) {
return None;
}
let player = state::player(gb)?;
let map = player.map;
let stood_mode = var("STOOD").unwrap_or_default();
macros.clock(ms);
let (things, covered): (Vec<_>, Vec<Tile>) =
macros.inspect(gb, &AdapterLedger(adapter), |state: &mut dyn MacroState| {
let mut all = path::person_targets(state);
all.extend(path::interactable_targets(state));
let mut covered = Vec::new();
if let Some(size) = state.map_size() {
for y in 0..size.height {
for x in 0..size.width {
if stood_mode == "all" || state.tile_visited(x, y) {
covered.push(Tile::new(x, y));
}
}
}
}
(all, covered)
});
let (talked, targets, stood, pushed, frontiers) = macros.ledgers_mut();
targets.clock(ms);
stood.record(map, Tile::new(player.x, player.y));
if stood_mode == "1" || stood_mode == "all" {
for tile in &covered {
stood.record(map, *tile);
}
}
for pair in var("PUSHED").unwrap_or_default().split(';').filter(|pair| !pair.is_empty()) {
let mut xy = pair.split(',').map(|n| n.trim().parse::<u8>().expect("PUSHED is x,y;x,y"));
let (x, y) = (xy.next().expect("an x"), xy.next().expect("a y"));
pushed.record(map, Tile::new(x, y));
}
if var("EXHAUSTED").as_deref() == Some("1") {
frontiers.record(map);
}
if var("TALKED").as_deref() == Some("1") {
for (_, target) in &things {
talked.record(map, *target);
}
}
for key in var("BLOCKED").unwrap_or_default().split(';').filter(|key| !key.is_empty()) {
let id = match key {
"north" => ExitId::Edge(Edge::North),
"south" => ExitId::Edge(Edge::South),
"east" => ExitId::Edge(Edge::East),
"west" => ExitId::Edge(Edge::West),
warp => ExitId::Warp(warp.trim_start_matches("warp").parse().expect("BLOCKED warpN")),
};
targets.record_blocked(map, TargetKey::Exit(id));
}
Some(format!(
"map {map:#04x}: pushed {pushed:?}, frontier marks {frontiers:?}, {} things talked to, \
{} tiles stood on",
talked.len(),
stood.len()
))
}

View file

@ -35,6 +35,7 @@
//! | `FLY_MACRO_BRAIN` | `FLY_DATASET`, else `data/fafb-v783` | the connectome |
//! | `FLY_TRAP_THREADS` | 4 | sweep threads |
//! | `FLY_TRAP_SEED` | 20260917 | seeds the palette |
//! | `FLY_TRAP_SEED_*` | unset | rebuilds session ledgers a restore starts empty: `PUSHED`, `EXHAUSTED`, `TALKED`, `BLOCKED`, `STOOD` (`examples/support/ledgers.rs`, row 57) |
//!
//! The frame order is `simloop.rs`'s, as `examples/palette_bench.rs` expresses it, so what this
//! measures is the loop that ships rather than a second implementation of it. Without a
@ -58,6 +59,9 @@ use flysim::config::Config;
use flysim::macros::{MacroLayer, macro_layer};
use flysim::snapshot::MacroMode;
#[path = "support/ledgers.rs"]
mod ledgers;
/// One brain minute in milliseconds.
const MINUTE_MS: f64 = 60_000.0;
@ -173,6 +177,9 @@ impl NeuralRecovery for AgentRecovery<'_> {
}
/// Where the fly stood on one frame, and what it started on it.
/// A refused macro and the `(map, x, y)` it was refused on.
type RefusedAt = (&'static str, Option<(u32, u32, u32)>);
struct Trace {
/// `(brain ms, map, x, y)` for every frame the adapter could place the player on.
steps: Vec<(f64, u32, u32, u32)>,
@ -186,6 +193,13 @@ struct Trace {
recoveries: u64,
rungs: Vec<(u32, &'static str, f64)>,
outcomes: BTreeMap<&'static str, u64>,
/// What `FLY_TRAP_SEED_*` rebuilt, when anything (row 57).
seeded: Option<String>,
/// `refused` outcomes by macro, and the run of one macro refused with the fly on one tile:
/// row 57's pad was one button refused 740 times running.
refusals: BTreeMap<&'static str, u64>,
refusal_run: (Option<RefusedAt>, u64),
longest_refusal_run: (u64, &'static str),
/// Frames spent in each scene, so a window full of macros can be read back to the scene that
/// dealt them.
scenes: BTreeMap<&'static str, u64>,
@ -352,6 +366,22 @@ fn run(
config.loop_.game = "pokemon-red".to_string();
config.macros.mode = mode;
let mut macros: Option<MacroLayer> = macro_layer(&config, hold_ms, seed);
let mut seeded_note: Option<String> = None;
// Row 57: a trap dealt by session ledgers does not come back from a checkpoint, because a
// restore starts them empty. `FLY_TRAP_SEED_*` rebuilds them on the real palette, so both arms
// of a hunt start inside the state the live session was in.
if let Some(flavour) = config.macros.mode.palette_mode() {
let mut palette =
flybrain_gb::pokemon_red::macros::PokemonPalette::with_mode(seed, flavour);
let ms = agent.network.ms;
if let Some(seeded) =
ledgers::seed(&mut palette, &mut emulator, &adapter, ms, "FLY_TRAP_SEED")
{
eprintln!("seeded the session ledgers: {seeded}");
seeded_note = Some(seeded);
macros = Some(MacroLayer::new(Box::new(palette), hold_ms));
}
}
let began_ms = agent.network.ms;
let until = began_ms + minutes * MINUTE_MS;
@ -394,6 +424,10 @@ fn run(
battle_starts: BTreeMap::new(),
battle_pads: BTreeMap::new(),
wall_seconds: 0.0,
seeded: seeded_note,
refusals: BTreeMap::new(),
refusal_run: (None, 0),
longest_refusal_run: (0, ""),
};
if let Some(layer) = macros.as_mut() {
@ -483,6 +517,18 @@ fn run(
}
Some(outcome) => {
*trace.outcomes.entry(outcome.as_str()).or_insert(0) += 1;
if outcome.as_str() == "refused" {
*trace.refusals.entry(event.name).or_insert(0) += 1;
let key = Some((event.name, location));
trace.refusal_run = if trace.refusal_run.0 == key {
(key, trace.refusal_run.1 + 1)
} else {
(key, 1)
};
if trace.refusal_run.1 > trace.longest_refusal_run.0 {
trace.longest_refusal_run = (trace.refusal_run.1, event.name);
}
}
if let Some(run) = running.take() {
let net = match (run.from, location) {
(Some((map, x, y)), Some((at, ax, ay))) if map == at => {
@ -847,6 +893,9 @@ fn main() {
trace.wall_seconds,
Path::new(&checkpoint_path).display()
);
if let Some(seeded) = &trace.seeded {
println!("Session ledgers rebuilt before the first frame (`FLY_TRAP_SEED_*`): {seeded}.\n");
}
println!("| measure | value |");
println!("| --- | ---: |");
println!("| rung reached | {} |", trace.rungs.iter().map(|(rank, ..)| *rank).max().unwrap_or(0));
@ -856,6 +905,15 @@ fn main() {
for (outcome, count) in &trace.outcomes {
println!("| {outcome} | {count} |");
}
if !trace.refusals.is_empty() {
let by: Vec<String> =
trace.refusals.iter().map(|(name, count)| format!("`{name}` {count}")).collect();
println!("| refused, by macro | {} |", by.join(", "));
println!(
"| longest run of one macro refused on one tile | {} (`{}`) |",
trace.longest_refusal_run.0, trace.longest_refusal_run.1
);
}
println!("| recoveries | {} |", trace.recoveries);
println!("| windows examined | {windows} |");
println!("| windows flagged | {} |", traps.len());

View file

@ -2921,3 +2921,264 @@ fn the_fly_leaves_the_pewter_gym_guides_ring_from_the_rung_ten_checkpoint() {
),
}
}
fn pewter_east_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_PEWTER_EAST_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// The real palette over the real cartridge, driven one uniform choice per hold -- the brain's
/// hold, not a ranking -- so a test can seed the session ledgers a restore starts empty.
struct Palette57 {
gb: Emulator,
adapter: PokemonRedReward,
macros: flybrain_gb::pokemon_red::macros::PokemonPalette,
ms: f64,
rng: u32,
}
impl Palette57 {
const HOLD_FRAMES: u32 = 48;
fn new(rom: &[u8], checkpoint: &flysim::store::Checkpoint, from_rollback: bool) -> Self {
let mut gb = Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
.expect("binjgb should accept the cartridge");
let state = if from_rollback {
&checkpoint.runtime.ratchet_game
} else {
&checkpoint.runtime.emulator
};
gb.import_state(state).expect("the checkpoint's emulator state");
let mut adapter = PokemonRedReward::new();
adapter.import_state(&checkpoint.runtime.reward).expect("the checkpoint's reward ledger");
Self {
gb,
adapter,
macros: flybrain_gb::pokemon_red::macros::PokemonPalette::new(SEED),
ms: 0.0,
rng: 0x5eed_0057,
}
}
fn player(&mut self) -> (u8, u8, u8) {
let player = flybrain_gb::pokemon_red::state::player(&mut self.gb).expect("a loaded map");
(player.map, player.x, player.y)
}
/// One frame: observe, then step the running macro or start one of the pad's buttons.
/// Returns the pad and what a start did, when one was tried this frame.
fn frame(
&mut self,
running: &mut bool,
since: &mut u32,
want: Option<flybrain_gb::pokemon_red::macros::MacroKind>,
) -> (Vec<&'static str>, Option<flybrain_gb::Started>) {
use flybrain_gb::MacroPalette;
self.macros.clock(self.ms);
let observed = self.macros.observe(&mut self.gb, &AdapterLedger(&self.adapter));
let pad: Vec<&'static str> = observed.bindings.iter().map(|b| b.name).collect();
let mut mask = 0u8;
let mut tried = None;
if *running {
match self.macros.step(&mut self.gb, &AdapterLedger(&self.adapter)) {
Some(held) => mask = held,
None => *running = false,
}
} else if *since >= Self::HOLD_FRAMES && !observed.bindings.is_empty() {
*since = 0;
let slot = match want.and_then(|kind| {
observed.bindings.iter().find(|b| b.slot == kind.slot()).map(|b| b.slot)
}) {
Some(slot) => slot,
None => {
self.rng ^= self.rng << 13;
self.rng ^= self.rng >> 17;
self.rng ^= self.rng << 5;
observed.bindings[self.rng as usize % observed.bindings.len()].slot
}
};
let started = self.macros.start(slot, &mut self.gb, &AdapterLedger(&self.adapter));
if let flybrain_gb::Started::Running(_) = started {
*running = true;
match self.macros.step(&mut self.gb, &AdapterLedger(&self.adapter)) {
Some(held) => mask = held,
None => *running = false,
}
}
tried = Some(started);
}
let _ = self.macros.take_finished();
*since += 1;
self.gb.set_buttons(mask);
self.gb.run_frame().expect("a frame should complete");
self.ms += MS_PER_FRAME;
self.adapter.sample(&mut self.gb, self.ms);
(pad, tried)
}
}
/// From the live rung-10 checkpoint in Pewter City: the pad is never one dead button.
///
/// **What was live** (2026-09-23, v0.5.3, `infra/docs/macros-traps.md` row 57): map 2, scene
/// `overworld`, the pad `GO ROUTE` and nothing else, `GO ROUTE refused` about 740 times per ten
/// brain minutes for more than two hours, `GO ROUTE start` / `blocked` once per ten brain minutes,
/// no button pressed, the exploration count frozen. The watchdog read one start and one name.
///
/// **The mechanism, measured with `examples/scene_probe.rs`'s `FLY_PROBE_CATCH=route`:**
///
/// 1. Pewter City's youngster takes the joypad on four tiles by the road east and walks the fly to
/// the gym. `GO ROUTE` aims east (Route 3 is the one connection the run has not crossed), so it
/// is escorted every time -- and the pushed ledger, which has no window, walled the tile each
/// escorted walk **set out from**: one walk from the south entrance walled the south entrance,
/// twenty-six tiles from the script. Walks start wherever the last one ended, so the walls
/// accumulate until the fly stands in a pocket no route leaves.
/// 2. In the pocket every walk refuses `no route`. `GO FRONTIER`'s refusal marks the map
/// exhausted (12.14, no window, cleared only by new ground); `GO OBJECTIVE`'s only goal is the
/// gym's door, now excluded for the window.
/// 3. With nothing else on the map, `ways`'s **last resort** deals `GO ROUTE` at that door, and a
/// last resort ignores the blocked ledger by design -- so its `no route` refusal, which writes
/// the door to that ledger, cannot take the button off the pad, and re-stamps the door's window
/// every hold, so `GO OBJECTIVE` never returns. Once per window the road east lapses, `GO ROUTE`
/// walks it, and the road is excluded again.
///
/// The session ledgers are what dealt that pad and a restore starts them empty, so part two seeds
/// the pocket: the tiles every walk has stood on, the map's frontier mark, every sign and person
/// talked to, the road east resting, and three pushed tiles sealing the strip by the road from the
/// town. That is a reconstruction of state the checkpoint cannot carry, said as one. In it the last
/// resort's preferred way out, the gym's door, is beyond the fence, while the road east is three
/// tiles away and resting in its window: `start` now takes the way it can reach.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb \
/// FLY_PEWTER_EAST_CHECKPOINT=.local/checkpoints/release-rank10-pewter-east.checkpoint \
/// cargo test --release -p flysim --test rom_macros_mode -- pewter_east --nocapture
/// ```
#[test]
fn the_pewter_east_pad_is_never_one_dead_button_from_the_rung_ten_checkpoint() {
use flybrain_gb::pokemon_red::macros::cartridge::{Edge, ExitId, PushedLedger, TargetKey};
use flybrain_gb::pokemon_red::macros::{MacroKind, Tile};
let rom = skip_without_rom!();
let Some(checkpoint) = pewter_east_checkpoint() else {
eprintln!("skipped: no FLY_PEWTER_EAST_CHECKPOINT");
return;
};
let pewter = PEWTER_CITY as u8;
// `PewterCityPlayerLeavingEastCoords`.
let escort = [Tile::new(35, 17), Tile::new(36, 17), Tile::new(37, 18), Tile::new(37, 19)];
// Part one: an escorted walk walls where the script took over, not where the walk began.
// From the ratchet's snapshot -- where a "Stuck" rollback puts the fly, the town's south
// entrance -- `GO ROUTE` whenever it is dealt, `NEXT` through the youngster's text.
let mut run = Palette57::new(&rom, &checkpoint, true);
let start = run.player();
assert_eq!(start.0, pewter, "the rollback snapshot is in Pewter City: {start:?}");
let (mut running, mut since) = (false, Palette57::HOLD_FRAMES);
let mut escorted = false;
for _ in 0..4_000 {
let want = if escorted { MacroKind::Next } else { MacroKind::GoRoute };
let (pad, _) = run.frame(&mut running, &mut since, Some(want));
escorted |= pad.contains(&"NEXT");
if escorted && !run.macros.fences().0.is_empty() && !running && pad.contains(&"GO ROUTE") {
break;
}
}
assert!(escorted, "the walk east met the youngster");
let pushed = run.macros.fences().0;
eprintln!("part one: from {start:?}, pushed {pushed:?}");
assert!(!pushed.is_empty(), "the escort is a push-back and is recorded");
assert!(
!pushed.pushed(pewter, Tile::new(start.1, start.2)),
"the tile the walk set out from is not walled: {pushed:?}"
);
assert!(
escort.iter().any(|tile| pushed.pushed(pewter, *tile)),
"the tile the script fired on is: {pushed:?}"
);
// Part two: the pocket, rebuilt, and twelve brain minutes in it.
let mut run = Palette57::new(&rom, &checkpoint, false);
let here = run.player();
assert_eq!(here, (pewter, 36, 18), "the live checkpoint stands by the road east");
{
use flybrain_gb::MacroPalette;
run.macros.clock(0.0);
let (_, targets, stood, pushed, frontiers) = run.macros.ledgers_mut();
targets.clock(0.0);
for y in 0..36 {
for x in 0..40 {
stood.record(pewter, Tile::new(x, y));
}
}
frontiers.record(pewter);
for tile in [Tile::new(33, 16), Tile::new(33, 17), Tile::new(33, 18)] {
pushed.record(pewter, tile);
}
targets.record_blocked(pewter, TargetKey::Exit(ExitId::Edge(Edge::East)));
}
// Every sign and person on the map, talked to.
let things = run.macros.inspect(
&mut run.gb,
&AdapterLedger(&run.adapter),
|state: &mut dyn flybrain_gb::pokemon_red::macros::MacroState| {
let mut all = flybrain_gb::pokemon_red::macros::path::person_targets(state);
all.extend(flybrain_gb::pokemon_red::macros::path::interactable_targets(state));
all
},
);
for (_, target) in things {
run.macros.ledgers_mut().0.record(pewter, target);
}
let (mut running, mut since) = (false, Palette57::HOLD_FRAMES);
let mut refusals = 0u32;
let mut in_the_pocket = 0u32;
let mut run_of_refusals = 0u32;
let mut longest = 0u32;
let mut last_refused: Option<(&'static str, (u8, u8, u8))> = None;
let mut left: Option<u32> = None;
let mut first_pad: Option<Vec<&'static str>> = None;
let budget = (12.0 * 60_000.0 / MS_PER_FRAME) as u32;
for frame in 0..budget {
let at = run.player();
let (pad, tried) = run.frame(&mut running, &mut since, None);
first_pad.get_or_insert(pad.clone());
if let Some(flybrain_gb::Started::Refused { name: Some(name), reason }) = tried {
refusals += 1;
if at.0 == pewter && at.1 >= 34 {
in_the_pocket += 1;
}
let key = (name, at);
run_of_refusals = if last_refused == Some(key) { run_of_refusals + 1 } else { 1 };
last_refused = Some(key);
longest = longest.max(run_of_refusals);
eprintln!("frame {frame}: {name} refused ({reason}) at {at:?}, pad {pad:?}");
} else if tried.is_some() {
last_refused = None;
run_of_refusals = 0;
}
let now = run.player();
if left.is_none() && (now.0 != pewter || now.1 < 33) {
left = Some(frame);
}
}
eprintln!(
"part two: first pad {first_pad:?}, {refusals} refusals ({in_the_pocket} in the pocket), \
longest run on one tile {longest}, left the pocket at {left:?}"
);
// The live pad: `GO ROUTE` refused every hold, 740 times in ten brain minutes.
assert!(
longest <= 1,
"a button was refused {longest} holds running on one tile: the pad kept dealing it"
);
assert!(in_the_pocket <= 2, "{in_the_pocket} refusals in the pocket");
let Some(left) = left else { panic!("the fly never left the pocket in twelve brain minutes") };
let minutes = f64::from(left) * MS_PER_FRAME / 60_000.0;
eprintln!("left the pocket on frame {left} ({minutes:.2} brain minutes)");
// The road east is three tiles away and resting in its window; the last resort preferred the
// gym's door beyond the fence. On the base the fly leaves only when the road's window lapses,
// ten brain minutes in.
assert!(minutes < 1.0, "the fly waited {minutes:.2} brain minutes for a window to lapse");
}