Compare commits

..

9 commits
v0.6.1 ... main

Author SHA1 Message Date
acamilo
862e343e1c docs: v0.6.2 status
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
2026-09-23 23:48:20 +00:00
acamilo
d0ce10e3c9 Merge fix/loop-row61: a trainer's challenge is the cartridge's until its battle is over 2026-09-23 23:48:20 +00:00
acamilo
9b117dc4b2 docs: 12.25 and the row 61 audit on top of row 59, the bubble, game.scene in macros mode 2026-09-23 23:47:36 +00:00
acamilo
cff15804fb state, tests: the challenge covers the "!" bubble too, the doc block back above its impl, and the ROM test asserts no pad inside a challenge 2026-09-23 23:47:36 +00:00
acamilo
d6597b3e98 docs: the row 61 audit, a trainer's challenge text walled the forest's corridor 2026-09-23 23:28:17 +00:00
acamilo
0117a85354 docs: macros.md 12.24, a trainer's challenge is the cartridge's until its battle is over 2026-09-23 23:28:12 +00:00
acamilo
c78ca352f6 tests: the ROM proof from the forest's south gate checkpoint 2026-09-23 23:28:05 +00:00
acamilo
0e366cd4e4 state: a trainer's challenge is the cartridge's until its battle is over, in the macros' own scene 2026-09-23 23:27:54 +00:00
acamilo
16bc65b5a8 survey: FLY_PROBE_CATCH_FRAME reads the first free frame from a given one, FLY_PROBE_WHOLE prints a big map whole 2026-09-23 23:27:54 +00:00
8 changed files with 387 additions and 3 deletions

View file

@ -1608,6 +1608,48 @@ split map has the pieces its ground has, and a frame that was the cartridge's is
fly's. The decoder, the reward catalog, the adapter version, the roles and the compatibility
string are untouched.
### 12.25 A trainer's challenge is the cartridge's until its battle is over (2026-09-23, row 61)
Live on v0.5.5, rung 9, for twenty minutes: `GO OBJECTIVE` into Viridian Forest's south gate (map
50, `$32`), `GO OUT` straight back onto Route 2, `GO WARP` back from the forest, `GO OBJECTIVE
blocked` in the forest, no reward. Reproduced with the route survey from the live checkpoint
(uniform choice per hold, xorshift seed 7), which walks the same ring for twenty brain minutes.
- **The ring's cause was a wall, not the gate.** The forest's only road to its north gate is a
two-wide corridor at x = 1-2; a Bug Catcher stands on (2, 18) facing west. A walk up the
corridor steps onto (1, 18), the trainer takes the joypad, and row 58's held push-back waits for
the joypad to come back. `DisplayEnemyTrainerTextAndStartBattle` clears `wJoyIgnore` before the
challenge text and `StartTrainerBattle` writes `wCurOpponent` only after that text's close-down:
**five frames** with no box, no script bit and `wCurOpponent` zero, which the seam read as the
fly's overworld. The push was written there; (1, 18) went into the pushed ledger, which has no
window, and from then on every walk to the north gate had no road. `GO OBJECTIVE` walked to the
nearest reachable tile, a dead end at (6, 1), and was blocked; `GO WARP`'s last tier took the
south gate, whose `GO OUT` is Route 2, whose `GO OBJECTIVE` is the gate.
- **The fact is `wStatusFlags7` bit 3, `BIT_TRAINER_BATTLE`**: set by `CheckFightingMapTrainers`
on the "!", cleared at `.battleOccurred` after every battle (before the blackout check). It
covers more than the five frames: the "!" bubble runs about sixty frames before `wJoyIgnore` is
set, and they read as the fly's overworld too -- about sixty-six free-looking frames per
engagement, measured. An overworld frame with the bit set is `Unknown` in the macros' own
scene, with no text box, so the pad is empty, no ground is recorded and no held entry is
decided on it. A trainer talked to by the fly never sets it; its `wCurOpponent` is written
inside the text.
- **On main the five frames are already row 59's** (12.24: a held push-back is written only after
thirty frames of overworld), and that alone keeps (1, 18) clear. This row is the cartridge-fact
layer under it: the pad is empty through the bubble as well, no ground is recorded, and it does
not depend on the gap staying under thirty frames.
- **The macros' reading only.** `controllable` and `scene::detect` are shared with the reward
adapter and do not change; `PokeState`'s `scene` and `scripted` read the bit beside them. In
macros mode the feed's `game.scene` is the palette's, so it reads `unknown` on those frames,
which is what the contract says of a frame the cartridge is driving.
- **The gates were modelled right.** Both forest gates are on the graph and `next_hop` answers
the forest from the south gate and Route 2 from the north one. The south gate's `GO OUT` is the
"a room has to be leavable" tier, a way back that is the fly's choice: with the corridor open,
the survey seed that walked into the gate twelve times still earned the badge, on both arms.
Nothing is ranked or pressed for the fly: 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
## 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

@ -866,3 +866,15 @@ median; a two-fly transition near 10 to 12 ms at the median in every execution m
disassembly. The trap hunt was not run at this load (recorded deviation). Also shipped: FND-01,
one LegacyFrame for the service and every harness (no live behaviour change), FLY_TRACE and the
sugar journal. Ethos check held.
- 2026-09-24 (v0.6.2, loop review, auto): row 61, the Viridian Forest corridor. In the fresh run the
fly rang between the forest, its south gate and Route 2 (GO OBJECTIVE / GO OUT / GO ROUTE) for
twenty minutes at rung 9. A Bug Catcher stands on the forest's only corridor north; when he saw
the fly, the frames of his "!" bubble and the five frames between his text and the battle read as
the fly's own overworld, and a held push-back walled (1,18) for good, so GO OBJECTIVE had no road
north. The macros now read the cartridge's BIT_TRAINER_BATTLE (wStatusFlags7 bit 3, set at the
"!", cleared after every battle, a lost one too) and treat those frames as the cartridge's: no
pad, no ground recorded, no push decided. Row 59's 30-frame debounce already closed the five-frame
gap; this is the cartridge-fact layer under it. ROM test: base walls (1,18) and stays at rung 9;
branch reaches Pewter on frame 23,755, and offers no button on any of the 67 challenge frames
(67 of 67 before). Survey seed 7: rung 9 -> BOULDER BADGE. The trap hunt's stub cannot see this
trap; the ROM test and survey are the proof (recorded deviation). Ethos check held.

View file

@ -2967,3 +2967,87 @@ survey and the three ROM tests, which is a deviation from the ethos check's lett
- `flysim --print-compatibility`: byte-identical to the base on both bases this branch has had:
648 bytes, `4929f340...9ebd9` on v0.5.5 (`7784a9d`), and 648 bytes, `8ce67b97...a8f68` on
`4d82f7d` (v7). Decoder, reward catalog, adapter version and roles untouched.
## 2026-09-23, row 61: the forest's south gate, in and out
### What was live
Map 50 (`VIRIDIAN_FOREST_SOUTH_GATE`, `$32`) and Route 2's south half, rung 9, v0.5.5, about 20:45
to 21:05 UTC: `GO OBJECTIVE` into the gate (0.3 s), `GO OUT` back onto Route 2 (0.2 s), `GO WARP`
back out of the forest, `GO OBJECTIVE blocked` in the forest, `GO ROUTE` refused now and then; no
reward and about eight new tiles in twenty minutes. The checkpoint is the fly at (5, 1) in the
gate, under the forest's doorway, objective Pewter City; `next_hop` answers the forest, correctly.
### The survey: what walled the road
The route survey from the checkpoint (`FLY_PROBE_CATCH=route`, uniform choice per hold,
`FLY_PROBE_RNG=7`, 72,000 frames) walks the live ring and never leaves rung 9. Caught at the
refusal (`FLY_PROBE_CATCH_FRAME`, `FLY_PROBE_WHOLE=1`): the forest's only road to the north gate
is the corridor at x = 1-2 from row 22 up to row 0; the Bug Catcher of
`EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2` stands on (2, 18) facing west, and **(1, 18) was in the
pushed ledger**. It was written at frame 16,389: a `GO ITEM` walk stepped onto (1, 18), the trainer
took the joypad, and his text closed onto five frames with no box, no script bit and
`wCurOpponent` zero (`StartTrainerBattle` runs after `DisplayTextID`'s close-down). Row 58's held
entry was decided on the first of them. The "!" bubble before it, about sixty frames before
`wJoyIgnore` is set, reads the same way: about sixty-six free-looking frames per engagement. The pushed ledger has no window, so the north gate had no
road for the session; `GO OBJECTIVE` walked to the nearest reachable tile, (6, 1), a dead end, and
was blocked, and the last tiers walked the fly back to the south gate and Route 2. Every base
survey arm walls (1, 18); only a fly that has to come back up the corridor is trapped by it.
| # | trap | trigger | test | fix, or why it is left |
| --- | --- | --- | --- | --- |
| 61 | the "!" bubble and the frames between a sighted trainer's challenge text and `StartTrainerBattle` (about 66 per engagement) read as the fly's overworld: a pad is dealt, ground recorded, and row 58's held push-back written, walling the tile the trainer fired on for the session | any trainer whose line of sight a walk crosses; Viridian Forest's (1, 18), the one free tile of the corridor to the north gate | `a_trainers_challenge_is_the_cartridges_until_its_battle_is_over`, `the_frames_between_a_trainers_text_and_its_battle_deal_no_pad_and_record_no_ground`, `a_trainers_challenge_does_not_wall_the_road_to_the_forests_north_gate` (ROM) | **fixed** in the macro seam: `state::trainer_engaged` reads `wStatusFlags7` bit 3 (`BIT_TRAINER_BATTLE`, set by `CheckFightingMapTrainers`, cleared at `.battleOccurred`); `PokeState`'s `scene` is `Unknown` on an overworld frame with it set and `scripted` is true. `controllable` and `scene::detect` unchanged; in macros mode `game.scene` reads `unknown` on those frames. `docs/design/macros.md` 12.25 |
| 61b | the south gate deals `GO OUT` (back to Route 2) beside the forest door the objective's road takes | the "a room has to be leavable" tier in a gate whose way on is a passage | -- | **left**: a way back is the fly's choice; with the corridor open no survey stays on it |
### Before and after
The route survey, 72,000 frames (20.1 brain minutes), base `main` vs branch:
| driver | base | branch |
| --- | --- | --- |
| uniform, seed 7 (the live ring) | rung 9, 1,202 tiles, north gate never, (1, 18) walled | **BOULDER BADGE**, 1,647 tiles, north gate f20273, Pewter f23756 |
| uniform, default seed | rung 10, 1,356 | rung 11, 1,476 |
| `GO OBJECTIVE` preferred | rung 11, 1,529 | rung 12, 1,492 |
| `GO OUT`, `GO OBJECTIVE` preferred | rung 11, 1,376 | rung 11, 1,446 |
| uniform, seed 12345 | rung 11, 1,765 | identical |
(1, 18) is walled on every base arm and on no branch arm. The ROM-gated run (the seed-7 driver,
40,000 frames): base walled at frame 16,389, rung 9, **fails**; branch: north gate f20272, Route 2
f21209, Pewter f23755, passes.
The stub-readout trap hunt (30 brain minutes, service frame since FND-01; `main` `4d82f7d` vs
branch): both arms rung 10, 362 distinct tiles, 147 macros all done, 11 of 113 windows flagged.
**The stub does not walk the ring**: it passes the corridor northward and never comes back, so
the only difference is 134 overworld frames the branch reads `Unknown`, the trainer gaps. The
survey and the ROM test are the reproduction.
### Other gates
The north gate's `GO OUT` is toward Pewter (tier 2) and its forest door is withheld; the Route 2
gate, Diglett's Cave's Route 2 house and the Route 22 gate are off the graph (every door `LAST_MAP`
but Diglett's passage). The Route 22 gate is the one building with `LAST_MAP` on both sides of two
different maps (its script sets `wLastMap` by row: under 4 is Route 23); `outdoor_of` cannot name
both and will need a per-door answer when a rung routes through it. The trainer gap is not a gate
fact: every sighted trainer has it.
### Overlap
Row 59 (59f, merged in v0.6.1) found the same five frames on Route 3 and holds the push until
thirty frames of overworld; on main that alone keeps (1, 18) clear. This row is the cartridge-fact
layer under it: the pad is empty through the bubble too, no ground is recorded, and it does not
depend on the gap staying under thirty frames. The ROM test asserts no pad on an overworld frame
with the bit set, which row 59 alone does not meet.
### Gates
On `main` `510727c` (v0.6.1, row 59 merged):
- `cargo test --release -p flybrain-gb` with `FLY_ROM`: 461 passed, 0 failed.
- `rom_macros_mode` with the row-61 and row-59 checkpoints: row 61's test and row 59's three pass.
Row 61's test on the branch with `trainer_engaged` neutered (main's behaviour, row 59's settle
alone): the wall stays clear, and a pad is dealt on 67 of 67 challenge frames -- **fails**; on
the branch 0 of 67 -- passes.
- `cargo clippy --workspace --all-targets -- -D warnings`: clean.
- `npm test` 663 passed; `npm run typecheck` clean; `infra/tests/lint.sh` ALL CHECKS PASSED.
- `flysim --print-compatibility`, raw and macros: 648 bytes, sha256 `8ce67b97...a8f68`, the same
as `main`. Decoder, reward catalog, adapter version and roles untouched.

View file

@ -616,6 +616,28 @@ mod tests {
assert_eq!(palette.observe(&mut wram, &NoLedger).scene, SceneId::Unknown);
}
#[test]
fn the_frames_between_a_trainers_text_and_its_battle_deal_no_pad_and_record_no_ground() {
// Row 61, Viridian Forest. A trainer who saw the fly: its "!" bubble and the five frames
// after its challenge text read as an ordinary overworld -- no box, no script bit,
// `wJoyIgnore` and `wCurOpponent` zero. The pad was dealt there and the push-back the
// fly's walk had earned when the trainer took the joypad was written there, walling the
// one free tile of the corridor to the north gate for the session.
let mut wram = Wram::overworld();
wram.set(crate::pokemon_red::symbols::ram::wStatusFlags7, 1 << 3);
let mut palette = PokemonPalette::new(7);
let engaged = palette.observe(&mut wram, &NoLedger);
assert_eq!(engaged.scene, SceneId::Unknown, "the cartridge's, inside the challenge");
assert!(engaged.bindings.is_empty(), "nothing to press: {:?}", engaged.bindings);
assert_eq!(palette.stood(), 0, "and no ground recorded from it");
// `.battleOccurred` clears the bit and the overworld is the fly's again.
wram.set(crate::pokemon_red::symbols::ram::wStatusFlags7, 0);
let own = palette.observe(&mut wram, &NoLedger);
assert_eq!(own.scene, SceneId::Overworld);
assert_eq!(palette.stood(), 1);
}
#[test]
fn a_teleport_pad_is_not_a_tear() {
// Saffron Gym and two Silph Co. floors warp to themselves. Standing on a pad whose

View file

@ -145,6 +145,11 @@ pub mod poke {
/// 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/ram_constants.asm`: `wStatusFlags7` bit 3, `BIT_TRAINER_BATTLE` (row 61). Set by
/// `CheckFightingMapTrainers` (`home/trainers.asm`) on the frame a trainer sees the player,
/// cleared at `.battleOccurred` (`home/overworld.asm`) once the battle is over -- before the
/// blackout check, so a lost battle clears it too. Nothing else writes it.
pub const TRAINER_BATTLE_STATUS7: u8 = 1 << 3;
/// `constants/battle_constants.asm`: the non-volatile status byte.
pub const SLP_MASK: u8 = 0b111;
@ -1841,8 +1846,14 @@ impl<'a> PokeState<'a> {
}
impl GameState for PokeState<'_> {
/// [`super::scene::detect`], except that an overworld frame inside a trainer's challenge is
/// the cartridge's ([`trainer_engaged`], row 61): section 12.13's `Unknown` with no text box,
/// an empty pad the fly waits out, and no frame a held push-back is decided on.
fn scene(&mut self) -> Scene {
super::scene::detect(self.memory)
match super::scene::detect(self.memory) {
Scene::Overworld if trainer_engaged(self.memory) => Scene::Unknown,
scene => scene,
}
}
fn player(&mut self) -> Option<Player> {
@ -1910,6 +1921,27 @@ impl GameState for PokeState<'_> {
}
}
/// Whether a trainer who saw the player is between its "!" and the end of its battle (row 61).
///
/// Two stretches of that window read as an overworld the fly owned, both measured in Viridian
/// Forest: the "!" bubble, about sixty frames, drawn before `CheckFightingMapTrainers` sets
/// `wJoyIgnore`; and five frames after the challenge text, because
/// `DisplayEnemyTrainerTextAndStartBattle` (`home/trainers.asm`) clears `wJoyIgnore` before the
/// text and calls `StartTrainerBattle`, which writes `wCurOpponent`, only after the text's
/// close-down has redrawn the map. About sixty-six frames per engagement, every bit
/// [`controllable`] reads clear. The push-back a walk earned when the trainer took the joypad (row
/// 58's held entry) was written on the first frame after the text: the one free tile beside the
/// trainer, in the only corridor to the forest's north gate, walled for the session.
///
/// **The macros' reading only.** [`controllable`] and [`super::scene::detect`] are shared with
/// the reward adapter (the talk payout's "ready" test) and do not change; [`PokeState`]'s own
/// `scene` and `scripted` read this beside them. In macros mode the feed's `game.scene` is the
/// palette's, so it reads `unknown` on these frames, as the contract has it for a frame the
/// cartridge is driving.
pub fn trainer_engaged(memory: &mut dyn MemoryReader) -> bool {
read(memory, ram::wStatusFlags7) & poke::TRAINER_BATTLE_STATUS7 != 0
}
/// The cartridge tables on their defaults, and the exploration ledger wired through.
///
/// `pokemon_red/macros/cartridge.rs` defaults every [`MacroState`] method and every default
@ -1923,7 +1955,7 @@ impl GameState for PokeState<'_> {
/// taken rather than at the nearest door (`docs/design/macros.md` section 3).
impl MacroState for PokeState<'_> {
fn scripted(&mut self) -> bool {
!controllable(self.memory)
!controllable(self.memory) || trainer_engaged(self.memory)
}
fn text_open(&mut self) -> bool {

View file

@ -1222,3 +1222,34 @@ fn a_refusal_that_cannot_be_read_is_not_reported() {
read.set(ram::wEnemyMonStatMods + 1, 1);
assert!(PokeState::new(&mut read).move_without_effect(TAIL_WHIP.0));
}
#[test]
fn a_trainers_challenge_is_the_cartridges_until_its_battle_is_over() {
use crate::pokemon_red::macros::state::GameState;
// Row 61. A trainer who saw the fly: its "!" bubble (before `wJoyIgnore` is set) and the five
// frames after its text (before `wCurOpponent` is) have every bit `controllable` reads clear.
// The macros read them as the cartridge's; the shared readings the reward adapter uses do not
// move.
let mut wram = Wram::overworld();
assert_eq!(PokeState::new(&mut wram).scene(), Scene::Overworld);
assert!(!PokeState::new(&mut wram).scripted());
wram.set(ram::wStatusFlags7, poke::TRAINER_BATTLE_STATUS7);
assert!(trainer_engaged(&mut wram));
assert_eq!(PokeState::new(&mut wram).scene(), Scene::Unknown, "inside the challenge");
assert!(PokeState::new(&mut wram).scripted(), "and the fly is not its own master");
assert!(controllable(&mut wram), "the adapter's gate is unchanged");
assert_eq!(crate::pokemon_red::scene::detect(&mut wram), Scene::Overworld, "and the shared scene");
// The challenge's own text is still a conversation to advance.
wram.dialogue_box();
assert_eq!(PokeState::new(&mut wram).scene(), Scene::Dialog);
// `.battleOccurred` clears the bit; the other bits of the byte are not a challenge:
// `BIT_NO_MAP_MUSIC` after a rival, `BIT_USE_CUR_MAP_SCRIPT` from a trainer talked to.
let mut after = Wram::overworld();
after.set(ram::wStatusFlags7, (1 << 1) | (1 << 4) | (1 << 7));
assert!(!trainer_engaged(&mut after));
assert_eq!(PokeState::new(&mut after).scene(), Scene::Overworld);
assert!(!PokeState::new(&mut after).scripted());
}

View file

@ -1389,6 +1389,10 @@ fn route_survey(
let save_rank: Option<u32> =
std::env::var("FLY_PROBE_SAVE_RANK").ok().and_then(|value| value.parse().ok());
let mut saved = false;
// `FLY_PROBE_CATCH_FRAME=41600` reads the first frame at or after that one on which the fly
// has the buttons and no macro is running (row 61: the frame a walk in the forest refused).
let catch_frame: Option<usize> =
std::env::var("FLY_PROBE_CATCH_FRAME").ok().and_then(|value| value.parse().ok());
// `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.
@ -1543,6 +1547,14 @@ fn route_survey(
caught_at = Some(frame);
break;
}
if catch_frame.is_some_and(|at| frame >= at)
&& !running
&& matches!(observed.scene, flybrain_gb::SceneId::Overworld)
&& !observed.bindings.is_empty()
{
caught_at = Some(frame);
break;
}
if let (Some(want), Some(player)) = (catch_map, player) {
if player.map == want && last_map != Some(want) {
entries += 1;
@ -1581,6 +1593,8 @@ fn route_survey(
frame as f64 * MS_PER_FRAME / 60_000.0,
if single_refusals >= catch_after {
"one button, refused twenty holds running".to_string()
} else if catch_frame.is_some_and(|at| frame >= at) {
format!("the first free frame at or after {catch_frame:?}")
} else {
format!("arrival {entries} on map {catch_map:?}")
}
@ -1634,7 +1648,9 @@ fn route_survey(
// A room small enough to print whole is printed whole, with its people on it (row 58:
// the gym's leader is twelve rows from the door).
let size = state.map_size().expect("a loaded map");
let whole = size.width <= 24 && size.height <= 24;
// `FLY_PROBE_WHOLE=1` prints a bigger map whole too (row 61: the forest is 34 by 48).
let whole = (size.width <= 24 && size.height <= 24)
|| std::env::var("FLY_PROBE_WHOLE").is_ok_and(|value| value == "1");
let people: Vec<(Tile, TalkTarget)> = path::person_targets(state)
.into_iter()
.chain(path::offscreen_person_targets(state))

View file

@ -3661,3 +3661,148 @@ fn the_fly_goes_into_mt_moon_from_the_live_route_4_checkpoint() {
assert!(crossings < 25, "{crossings} crossings of Route 4's west doors: {:?}", run.started);
assert!(in_mt_moon.is_some(), "the fly never went into Mt. Moon: {:?}", run.route);
}
fn row61_checkpoint() -> Option<flysim::store::Checkpoint> {
std::env::var_os("FLY_ROW61_CHECKPOINT").map(|path| {
flysim::store::load(std::path::Path::new(&path))
.expect("the checkpoint should be a FLYSIM01 envelope")
})
}
/// Viridian Forest's south gate, from the checkpoint pulled during the ring.
///
/// **What was live** (2026-09-23, v0.5.5, rung 9 after the reset to milestone 1): for twenty
/// minutes `GO OBJECTIVE` into the forest's south gate and `GO OUT` straight back onto Route 2,
/// `GO WARP` back from the forest, `GO OBJECTIVE blocked` in the forest, no reward
/// (`infra/docs/macros-traps.md` row 61). The forest's only road to its north gate is a two-wide
/// corridor at x = 1-2 with a Bug Catcher standing on (2, 18) facing west. A walk up the corridor
/// steps onto (1, 18) and the trainer takes the joypad. His "!" bubble (about sixty frames, before
/// `wJoyIgnore` is set) and the five frames after his text (before `wCurOpponent` is) read as the
/// fly's own overworld: the push-back the walk had earned was written on the first frame after the
/// text, (1, 18) was walled for the session, and every later walk to the north gate had no road.
/// Row 59's thirty-frame settle alone keeps the wall out; this row reads `BIT_TRAINER_BATTLE`.
///
/// The driver is the route survey's: the real palette, one uniform choice per hold, xorshift
/// seeded 7 -- a harness choice, not the fly's. Before rows 59 and 61 it never reached the north
/// gate in 72,000 frames. The claims:
///
/// - **no button is offered on an overworld frame inside a trainer's challenge**
/// (`wStatusFlags7` bit 3 set), and the run does reach such frames;
/// - **no tile of the forest is walled by a trainer's challenge**: (1, 18) never enters the pushed
/// ledger;
/// - **the fly goes through the north gate onto Route 2 and into Pewter City** inside the budget.
///
/// ```sh
/// FLY_ROM=/path/to/pokemon-red.gb FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 \
/// FLY_ROW61_CHECKPOINT=<the rank-9 checkpoint pulled during the ring, under .local/checkpoints> \
/// cargo test --release -p flysim --test rom_macros_mode -- --nocapture forests_north_gate
/// ```
#[test]
fn a_trainers_challenge_does_not_wall_the_road_to_the_forests_north_gate() {
use flybrain_gb::pokemon_red::macros::PokemonPalette;
use flybrain_gb::pokemon_red::macros::cartridge::{PushedLedger, Tile};
use flybrain_gb::pokemon_red::state;
use flybrain_gb::{MacroPalette, MemoryReader, Started};
const FOREST: u8 = 0x33;
const NORTH_GATE: u8 = 0x2f;
const ROUTE_2: u8 = 0x0d;
const PEWTER: u8 = 0x02;
let rom = skip_without_rom!();
let Some(checkpoint) = row61_checkpoint() else {
eprintln!("skipped: no FLY_ROW61_CHECKPOINT");
return;
};
let mut run = Run::resume(&rom, MacroMode::Macros, &checkpoint);
assert_eq!(run.map(), 0x32, "the checkpoint is the forest's south gate");
let budget = 40_000u32;
let hold_frames = 48u32;
let mut palette = PokemonPalette::new(SEED);
let mut rng = 7u32;
let mut running = false;
let mut since_decision = hold_frames;
let mut ms = run.ms;
let mut arrivals: Vec<(u32, u8)> = Vec::new();
let mut last = None;
let mut walled_at: Option<u32> = None;
// Overworld frames (the shared reading) inside a trainer's challenge, and those that dealt a pad.
let mut engaged = 0u32;
let mut engaged_dealt = 0u32;
for frame in 0..budget {
palette.clock(ms);
let observed = {
let ledger = AdapterLedger(&run.adapter);
palette.observe(&mut run.gb, &ledger)
};
// `wStatusFlags7` bit 3, `BIT_TRAINER_BATTLE`, read here rather than through the seam.
if run.gb.read8(flybrain_gb::pokemon_red::symbols::ram::wStatusFlags7) & (1 << 3) != 0
&& flybrain_gb::pokemon_red::scene::detect(&mut run.gb)
== flybrain_gb::pokemon_red::macros::state::Scene::Overworld
{
engaged += 1;
if !observed.bindings.is_empty() {
engaged_dealt += 1;
}
}
let mut mask = 0u8;
{
let ledger = AdapterLedger(&run.adapter);
if running {
match palette.step(&mut run.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;
let binding = &observed.bindings[rng as usize % observed.bindings.len()];
if let Started::Running(_) = palette.start(binding.slot, &mut run.gb, &ledger) {
running = true;
match palette.step(&mut run.gb, &ledger) {
Some(held) => mask = held,
None => running = false,
}
}
}
}
let _ = palette.take_finished();
since_decision += 1;
run.gb.set_buttons(mask);
run.gb.run_frame().expect("a frame should complete");
ms += MS_PER_FRAME;
run.adapter.sample(&mut run.gb, ms);
if walled_at.is_none() && palette.fences().0.pushed(FOREST, Tile::new(1, 18)) {
walled_at = Some(frame);
}
if let Some(player) = state::player(&mut run.gb)
&& last != Some(player.map)
{
last = Some(player.map);
arrivals.push((frame, player.map));
}
}
let first = |map: u8| arrivals.iter().find(|(_, at)| *at == map).map(|(frame, _)| *frame);
let north_gate = first(NORTH_GATE);
let route_2_north = north_gate
.and_then(|gate| arrivals.iter().find(|(frame, map)| *frame > gate && *map == ROUTE_2))
.map(|(frame, _)| *frame);
let pewter = first(PEWTER);
eprintln!(
"{:.1} brain minutes: challenge overworld frames {engaged}, a pad dealt on {engaged_dealt}; \
(1, 18) walled at {walled_at:?}; north gate {north_gate:?}, Route 2 \
after it {route_2_north:?}, Pewter City {pewter:?}; pushed {:?}; rank {}; arrivals {}",
(ms - run.ms) / 60_000.0,
palette.fences().0,
run.adapter.progress().rank,
arrivals.len(),
);
assert!(engaged > 0, "the run never reached a trainer's challenge");
assert_eq!(engaged_dealt, 0, "a pad was dealt inside a trainer's challenge");
assert_eq!(walled_at, None, "a trainer's challenge walled the forest's corridor");
assert!(north_gate.is_some(), "the fly never reached the forest's north gate: {arrivals:?}");
assert!(route_2_north.is_some(), "nor Route 2 through it: {arrivals:?}");
assert!(pewter.is_some(), "nor Pewter City: {arrivals:?}");
}