Three conflicts, all of them two reviews appending in the same place, all resolved by keeping both sides: `macros.md` (row 50 keeps section 12.18, row 55 becomes 12.19), `macros-traps.md` (both trap rows, both residual pairs, both arm sections) and `scene_probe.rs` (both survey modes, `accept` and `shop`). Every source file auto-merged. The two rows compose on the cartridge: from the mart checkpoint the fly leaves the counter, walks the town, and no `MOVE n` blocks any more, so the longest chain of one macro blocked on an unchanged frame is 1 in any scene.
1571 lines
70 KiB
Rust
1571 lines
70 KiB
Rust
//! Drive macros mode from a checkpoint until the scene detector sticks, and dump every input of
|
|
//! the branch it stuck on.
|
|
//!
|
|
//! `infra/docs/macros-traps.md` (2026-09-17): with the walk fixes in, the fly reaches the mart and
|
|
//! then spends the rest of the run in `Scene::Dialog` — 41,012 frames of 71,673 — while the reward
|
|
//! adapter's own mode reads `OVERWORLD`. Two readings fit that: a text box whose text one A press
|
|
//! and one B press cannot advance, or a `dialog` reading that is wrong. They are told apart by the
|
|
//! bytes, so this prints the bytes.
|
|
//!
|
|
//! The readout is `tests/rom_macros_mode.rs`'s stub — one macro population hot per hold, rotating
|
|
//! — so this needs no connectome and runs in seconds. What it measures is the detector, and the
|
|
//! detector cannot tell what is driving it.
|
|
//!
|
|
//! **What it found, and its limit.** Driven by the stub the scene never holds one non-overworld
|
|
//! reading for 600 frames in 200,000, so the residual needed the real connectome to reproduce and
|
|
//! `examples/trap_hunt.rs`'s per-frame counters are the measurement that settled it (0 frames the
|
|
//! corner test would have called `dialog` without a border, 315 frames where the map drew all four
|
|
//! corners with the font flag clear). This is the tool for dumping every byte of the branch at one
|
|
//! spot; the hunt is the tool for counting.
|
|
//!
|
|
//! ```sh
|
|
//! FLY_ROM="$HOME/fly-plays-pokemon/Pokemon Red (U) [S][BF].gb" \
|
|
//! FLY_TRAP_CHECKPOINT=.local/checkpoints/release-viridian-timeouts.checkpoint \
|
|
//! cargo run --release -p flysim --example scene_probe
|
|
//! ```
|
|
//!
|
|
//! | env | default | meaning |
|
|
//! | --- | --- | --- |
|
|
//! | `FLY_ROM` | — | the cartridge; without it this prints instructions |
|
|
//! | `FLY_TRAP_CHECKPOINT` | `FLY_MACRO_CHECKPOINT` | the `FLYSIM01` checkpoint to start from |
|
|
//! | `FLY_PROBE_FRAMES` | 200000 | frames to drive before giving up |
|
|
//! | `FLY_PROBE_STUCK` | 600 | consecutive frames in one non-overworld scene that count as stuck |
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use flybrain_core::decoder::PopulationDecoder;
|
|
use flybrain_core::decoder::gameboy::gameboy_decoder_config_with_macros;
|
|
use flybrain_core::ordered::NumberMap;
|
|
use flybrain_gb::pokemon_red::symbols::ram;
|
|
use flybrain_gb::pokemon_red::{PokemonRedReward, scene, state};
|
|
use flybrain_gb::{
|
|
AdapterLedger, DEFAULT_AUDIO_FRAMES, DEFAULT_AUDIO_FREQUENCY, Emulator, GameAdapter,
|
|
MemoryReader,
|
|
};
|
|
use flysim::config::Config;
|
|
use flysim::macros::macro_layer;
|
|
use flysim::snapshot::MacroMode;
|
|
|
|
const MS_PER_FRAME: f64 = 1000.0 / 59.7275;
|
|
const BURST_MS: f64 = 100.0;
|
|
const HOLDS_PER_SLOT: usize = 3;
|
|
const HOT: f64 = 16.0;
|
|
const REST: f64 = 10.0;
|
|
const SEED: u32 = 20_260_916;
|
|
|
|
fn rates(hot: Option<&str>) -> NumberMap {
|
|
let mut rates = NumberMap::new();
|
|
for channel in flybrain_gb::macro_channels("pokemon-red") {
|
|
rates.set(channel, REST);
|
|
}
|
|
for bucket in 0..8 {
|
|
rates.set(&format!("motor_{bucket}"), REST);
|
|
}
|
|
if let Some(hot) = hot {
|
|
rates.set(hot, HOT);
|
|
}
|
|
rates
|
|
}
|
|
|
|
fn env_usize(name: &str, default: usize) -> usize {
|
|
std::env::var(name).ok().and_then(|value| value.parse().ok()).unwrap_or(default)
|
|
}
|
|
|
|
/// Every byte the dialog branch of [`scene::detect`] rests on, plus the tiles it reads.
|
|
fn dump(gb: &mut Emulator, label: &str) {
|
|
let text = state::text_box(gb);
|
|
let corners: Vec<String> = [(0u16, 12u16), (19, 12), (0, 17), (19, 17)]
|
|
.into_iter()
|
|
.map(|(x, y)| {
|
|
format!("({x},{y})={:#04x}", gb.read8(ram::wTileMap + y * 20 + x))
|
|
})
|
|
.collect();
|
|
println!("\n## {label}");
|
|
println!("- scene: `{:?}`", scene::detect(gb));
|
|
println!("- text box: open={} waiting={}", text.open, text.waiting);
|
|
println!("- box corners (the `waiting` test): {}", corners.join(" "));
|
|
println!("- `{}`", scene::why_unknown(gb));
|
|
println!("- start menu: {:?}", state::start_menu(gb).is_some());
|
|
println!("- submenu: {:?}", state::submenu(gb));
|
|
println!("- pc: {:?} shop: {:?}", state::pc(gb).is_some(), state::shop(gb).is_some());
|
|
println!("- controllable: {}", state::controllable(gb));
|
|
println!("- player: {:?}", state::player(gb));
|
|
println!("- sprites the macros can see: {:?}", state::npcs(gb));
|
|
println!("\n```");
|
|
for y in 10..18u16 {
|
|
let row: Vec<String> = (0..20u16)
|
|
.map(|x| format!("{:02x}", gb.read8(ram::wTileMap + y * 20 + x)))
|
|
.collect();
|
|
println!("row {y:2} {}", row.join(" "));
|
|
}
|
|
println!("```");
|
|
}
|
|
|
|
/// What the save says about the plot, and who is standing on this map.
|
|
///
|
|
/// The question row 28 of `infra/docs/macros-traps.md` asks is "what unlocks the road north", and
|
|
/// the cartridge answers it in two places: the event bitsets, and the map's own object list. Both
|
|
/// are read here rather than recalled — the macros never learn any of it, but the *investigation*
|
|
/// has to know which script is talking.
|
|
fn cartridge(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
|
|
use flybrain_gb::pokemon_red::macros::cartridge::MacroState;
|
|
let progress = adapter.progress();
|
|
println!(
|
|
"\n## {label}\n\n- rank {} ({}), badges {}, unique tiles {}",
|
|
progress.rank, progress.rank_label, progress.counter, progress.unique_locations
|
|
);
|
|
let set: Vec<&str> = flybrain_gb::pokemon_red::symbols::EVENTS
|
|
.iter()
|
|
.filter(|(_, bit)| {
|
|
gb.read8(ram::wEventFlags + (bit >> 3)) & (1 << (bit & 7)) != 0
|
|
})
|
|
.map(|(name, _)| *name)
|
|
.collect();
|
|
println!("- {} named events set", set.len());
|
|
for name in &set {
|
|
println!(" - {name}");
|
|
}
|
|
let ledger = AdapterLedger(adapter);
|
|
let mut state = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
|
|
let state: &mut dyn MacroState = &mut state;
|
|
println!("\n- objective: {:?}", state.objective());
|
|
println!("- map size: {:?}", state.map_size());
|
|
println!("- warps: {:?}", state.warps());
|
|
println!("- connections: {:?}", state.connections());
|
|
println!("- signs: {:?}", state.signs());
|
|
println!("- people and objects on this map:");
|
|
for npc in state.npcs() {
|
|
println!(
|
|
" - slot {:2} picture {:#04x} at ({:2}, {:2}) facing {:?}{}",
|
|
npc.slot,
|
|
npc.picture,
|
|
npc.x,
|
|
npc.y,
|
|
npc.facing,
|
|
if npc.person() { "" } else { " (an object, not a person)" }
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Everything the overworld pad rests on, for the map the checkpoint is standing on.
|
|
///
|
|
/// The 2026-09-17 rank-9 stall had a pad of one button — `GO FRONTIER` — in a ten-by-eight gate
|
|
/// house, so the question is which candidate list is empty and which is not. Each list is printed
|
|
/// whole rather than summarised: the frontier tiles with the ground they border, the exits with
|
|
/// the way they are classified and the map each resolves to, and the first hop the map graph
|
|
/// answers toward the objective.
|
|
fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) {
|
|
use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, TargetKey};
|
|
use flybrain_gb::pokemon_red::macros::{geography, palette, path, plan};
|
|
use flybrain_gb::pokemon_red::macros::state::Walkable;
|
|
|
|
// Why the grid could not be decoded, read before the state borrows the emulator: it is the
|
|
// same call `MacroState::map_grid` makes, and the only reading that can say *which* of section
|
|
// 15's refusals a frame is.
|
|
let refusal = flybrain_gb::pokemon_red::state::map_grid(gb).err();
|
|
// Which tile the decode and the screen disagree about, when that is the refusal. The label
|
|
// alone cannot tell "the grid is wrong on this map" from "this frame was mid-warp", and the
|
|
// two want opposite fixes (`docs/design/macros.md` section 15). Read here, beside the refusal
|
|
// itself, because everything below holds a borrow of the emulator.
|
|
let disagreement = match refusal {
|
|
Some(flybrain_gb::pokemon_red::state::GridRefusal::ScreenDisagrees) => {
|
|
flybrain_gb::pokemon_red::state::grid_disagreement(gb)
|
|
}
|
|
_ => Vec::new(),
|
|
};
|
|
let ledger = AdapterLedger(adapter);
|
|
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
|
|
let state: &mut dyn MacroState = &mut poke;
|
|
let scene = state.scene();
|
|
let Some(player) = state.player() else {
|
|
println!("\n## {label}\n\n- no loaded map");
|
|
return;
|
|
};
|
|
let size = state.map_size().expect("a loaded map has a size");
|
|
println!("\n## {label}\n");
|
|
println!("- scene `{scene:?}`, player {player:?}, map {}x{}", size.width, size.height);
|
|
println!("- objective: {:?}", state.objective());
|
|
if let Some(objective) = state.objective() {
|
|
println!(
|
|
"- `next_hop({:?}, {:#04x})` = {:?}, neighbours {:?}",
|
|
geography::region_at(player.map, player.y),
|
|
objective.map,
|
|
geography::next_hop(geography::region_at(player.map, player.y), objective.map),
|
|
geography::neighbours(player.map)
|
|
);
|
|
}
|
|
let plan = plan::plan_for(scene, state);
|
|
let names: Vec<&str> =
|
|
plan.slots.iter().flatten().map(|spec| spec.name).collect();
|
|
println!("- the pad: {names:?}");
|
|
// The whole-map grid (`docs/design/macros.md` section 15), which is what the walks plan over
|
|
// now. Three numbers read a stalled walk: how much of the map is ground, how much of that the
|
|
// fly can actually get to from where it stands, and how much of *that* it has never stood on.
|
|
// A fly with 600 walkable tiles and 4 reachable ones is fenced in and no re-plan will help.
|
|
match state.map_grid() {
|
|
None => println!(
|
|
"- the map grid: none ({})",
|
|
refusal.map_or("unknown", |refusal| refusal.label())
|
|
),
|
|
Some(grid) => {
|
|
let unstood = grid
|
|
.walkable_tiles()
|
|
.into_iter()
|
|
.filter(|(x, y)| !state.tile_visited(*x, *y))
|
|
.count();
|
|
println!(
|
|
"- the map grid: {}x{} walkable {} reachable {} unstood {} unknown {}",
|
|
grid.width(),
|
|
grid.height(),
|
|
grid.walkable_count(),
|
|
grid.reachable_from(player.x, player.y),
|
|
unstood,
|
|
grid.unknown_count()
|
|
);
|
|
}
|
|
}
|
|
// Which tile the decode and the screen disagree about, when that is the refusal. The label
|
|
// alone cannot tell "the grid is wrong on this map" from "this frame was mid-warp", and the
|
|
// two want opposite fixes (`docs/design/macros.md` section 15).
|
|
if !disagreement.is_empty() {
|
|
println!("- the decode against the screen, tile by tile:");
|
|
for (x, y, decoded, screen) in disagreement {
|
|
println!(
|
|
" - ({x:2}, {y:2}) decoded {decoded:?} screen {screen:?}{}",
|
|
if decoded.is_some() && screen.is_some() && decoded != screen {
|
|
" <- the disagreement"
|
|
} else {
|
|
""
|
|
}
|
|
);
|
|
}
|
|
}
|
|
// The `v` column is the *adapter's* lifetime exploration ledger and nothing else. The
|
|
// session's own stood ledger (`docs/design/macros.md` section 12.7) is owned by the driver's
|
|
// palette, which this probe does not reach into, so a doormat the running fly has already
|
|
// marked still prints as unrecorded here. That is the point of the column: it shows what the
|
|
// reward ledger can and cannot answer.
|
|
// The grid's reading of the ground where there is one, the window's otherwise, said out loud
|
|
// so the map below cannot be mistaken for the other reading.
|
|
let grid = state.map_grid();
|
|
println!(
|
|
"\n### The ground, as the {} reads it (`v` = the adapter's lifetime ledger only)\n\n```",
|
|
if grid.is_some() { "map grid" } else { "ten-by-nine window" }
|
|
);
|
|
for y in 0..size.height {
|
|
let row: Vec<String> = (0..size.width)
|
|
.map(|x| {
|
|
let answer = match grid.as_deref() {
|
|
Some(grid) => grid.walkable(x, y),
|
|
None => state.walkable(x, y),
|
|
};
|
|
let walk = match answer {
|
|
Walkable::Yes => '.',
|
|
Walkable::No => '#',
|
|
Walkable::Unknown => '?',
|
|
};
|
|
let seen = if state.tile_visited(x, y) { 'v' } else { '-' };
|
|
let here = player.x == x && player.y == y;
|
|
format!("{}{}{}", walk, seen, if here { '@' } else { ' ' })
|
|
})
|
|
.collect();
|
|
println!("y{y:2} {}", row.join(""));
|
|
}
|
|
println!("```\n");
|
|
println!("- walkable and never stood on: {}", {
|
|
let mut n = 0;
|
|
for y in 0..size.height {
|
|
for x in 0..size.width {
|
|
let answer = match grid.as_deref() {
|
|
Some(grid) => grid.walkable(x, y),
|
|
None => state.walkable(x, y),
|
|
};
|
|
if answer == Walkable::Yes && !state.tile_visited(x, y) {
|
|
n += 1;
|
|
}
|
|
}
|
|
}
|
|
n
|
|
});
|
|
println!("- `path::frontier`: {:?}", path::frontier(state));
|
|
println!("- `frontier_aims`: {:?}", palette::frontier_aims(state));
|
|
println!("\n### The ways out\n");
|
|
for exit in path::exits(state) {
|
|
println!(
|
|
"- {:?} at ({:2},{:2}) press {:?} way {:?} into {:?} -> destination {:?} \
|
|
(map_visited {:?}, exit_visited {}, blocked {})",
|
|
exit.id,
|
|
exit.tile.x,
|
|
exit.tile.y,
|
|
exit.press,
|
|
exit.way,
|
|
exit.into,
|
|
exit.destination(player.map),
|
|
exit.destination(player.map).map(|map| state.map_visited(map)),
|
|
state.exit_visited(exit.id),
|
|
state.blocked(TargetKey::Exit(exit.id)),
|
|
);
|
|
}
|
|
for way in [path::Way::Exit, path::Way::Passage, path::Way::Route] {
|
|
let ways = palette::ways(state, way);
|
|
println!(
|
|
"- `ways({way:?})` = {:?}",
|
|
ways.iter().map(|exit| exit.id).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
// Section 13's two errands, which is what row 54's `GO HEAL` cycle turns on: which building
|
|
// the errand names, whether the ledgers have paid it, and what the walk would aim at. An aim
|
|
// with no press settles where it stands, so an aim on the fly's own tile is a macro that
|
|
// completes without moving.
|
|
println!("\n### The errands\n");
|
|
println!("- `area_here` = {:?}", palette::area_here(state));
|
|
for kind in [geography::Amenity::Mart, geography::Amenity::Center] {
|
|
let at = geography::amenity_of(palette::area_here(state).unwrap_or(0), kind);
|
|
let paid = at.is_some_and(|map| state.map_visited(map));
|
|
println!(
|
|
"- {kind:?}: building {:?} (map_visited {paid}), `errand` = {:?}, `amenity_goals` = {:?}",
|
|
at,
|
|
palette::errand(state, kind),
|
|
palette::amenity_goals(state, kind),
|
|
);
|
|
}
|
|
println!("- `counter_pending` = {}", palette::counter_pending(state));
|
|
println!("- `heal_goals` = {:?}", palette::heal_goals(state));
|
|
println!("- `errand_place` = {:?}", palette::errand_place(state));
|
|
println!("- `stranded` = {}", palette::stranded(state));
|
|
println!();
|
|
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));
|
|
}
|
|
|
|
/// The battle the fly cannot get out of: every move with its PP, the party, and the pad.
|
|
///
|
|
/// `FLY_PROBE_CATCH=noattack` drives until the fly's own turn has had `ATTACK` *off* the pad for a
|
|
/// run of frames and then prints this. `ATTACK`'s precondition over the top-level menu is
|
|
/// `best_move(..).is_some()` and `best_move` skips a move with no PP, so a turn with nothing left
|
|
/// to attack with takes FIGHT off the pad — and the move list `ATTACK` would confirm Struggle from
|
|
/// is only ever reached *through* FIGHT.
|
|
fn battle_dump(gb: &mut Emulator, adapter: &PokemonRedReward, pad: &[String], label: &str) {
|
|
use flybrain_gb::pokemon_red::macros::cartridge::MacroState;
|
|
|
|
let ledger = AdapterLedger(adapter);
|
|
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
|
|
let state: &mut dyn MacroState = &mut poke;
|
|
println!("\n## {label}\n");
|
|
println!("- scene: `{:?}`", state.scene());
|
|
println!("- the pad: {pad:?}");
|
|
println!("- player: {:?}", state.player());
|
|
let battle = state.battle();
|
|
println!("- battle: {:?}", battle.as_ref().map(|battle| (battle.kind, battle.own_turn, battle.forced_switch, battle.menu)));
|
|
println!("- enemy: {:?}", battle.as_ref().and_then(|battle| battle.enemy));
|
|
if let Some(mon) = battle.as_ref().and_then(|battle| battle.own) {
|
|
println!("- the Pokémon that is out: slot {} species {:#04x} level {} hp {}/{} status {:?}", mon.slot, mon.species, mon.level, mon.hp, mon.max_hp, mon.status);
|
|
for (index, slot) in mon.moves.iter().enumerate() {
|
|
println!(" - move {index}: {slot:?}");
|
|
}
|
|
}
|
|
let party = state.party();
|
|
println!("- party of {}:", party.mons.len());
|
|
for mon in &party.mons {
|
|
println!(
|
|
" - slot {} species {:#04x} level {} hp {}/{} moves {:?}",
|
|
mon.slot, mon.species, mon.level, mon.hp, mon.max_hp, mon.moves
|
|
);
|
|
}
|
|
println!("- bag: {:?}", state.bag());
|
|
println!("- money: {}", state.money());
|
|
}
|
|
|
|
/// One line of the dialogue box, decoded through `constants/charmap.asm`.
|
|
///
|
|
/// The survey below is about *which box* is open, and the only thing on screen that says so is the
|
|
/// text in it: `wTextBoxID` is `$01` for every ordinary `TX_FAR` box the nurse draws, so the id
|
|
/// cannot tell the welcome from the prompt from the closing line. The tiles can.
|
|
fn box_line(gb: &mut Emulator, y: u16) -> String {
|
|
(1..19u16)
|
|
.map(|x| match gb.read8(ram::wTileMap + y * 20 + x) {
|
|
0x7f => ' ',
|
|
byte @ 0x80..=0x99 => (b'A' + (byte - 0x80)) as char,
|
|
byte @ 0xa0..=0xb9 => (b'a' + (byte - 0xa0)) as char,
|
|
0xba => 'e',
|
|
0xe3 => '-',
|
|
0xe6 => '?',
|
|
0xe7 => '!',
|
|
0xe8 => '.',
|
|
0xef => 'M',
|
|
0xee => '\u{25bc}',
|
|
byte @ 0xf6..=0xff => (b'0' + (byte - 0xf6)) as char,
|
|
_ => '.',
|
|
})
|
|
.collect::<String>()
|
|
.trim_end()
|
|
.to_string()
|
|
}
|
|
|
|
/// The top-right corner of the screen, where a two-option menu's own little box is drawn: the
|
|
/// tiles at (11..20, 6..11) reduced to which of them hold a text-box frame tile.
|
|
fn corner_box(gb: &mut Emulator) -> String {
|
|
let mut out = String::new();
|
|
for y in 6..12u16 {
|
|
for x in 11..20u16 {
|
|
let byte = gb.read8(ram::wTileMap + y * 20 + x);
|
|
out.push(match byte {
|
|
0x79 | 0x7b | 0x7d | 0x7e => '+',
|
|
0x7a | 0x7c => '|',
|
|
0x7f => '_',
|
|
_ => '.',
|
|
});
|
|
}
|
|
out.push('/');
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Everything that tells one of the nurse's boxes from another, on one line.
|
|
fn nurse_frame(gb: &mut Emulator) -> String {
|
|
let text = state::text_box(gb);
|
|
format!(
|
|
"{:?} open={} waiting={} cursor=({},{},{},{},{:#04x}) yesno={} | {} | {}",
|
|
scene::detect(gb),
|
|
text.open,
|
|
text.waiting,
|
|
gb.read8(ram::wTopMenuItemY),
|
|
gb.read8(ram::wTopMenuItemX),
|
|
gb.read8(ram::wCurrentMenuItem),
|
|
gb.read8(ram::wMaxMenuItem),
|
|
gb.read8(ram::wMenuWatchedKeys),
|
|
corner_box(gb),
|
|
box_line(gb, 14),
|
|
box_line(gb, 16),
|
|
)
|
|
}
|
|
|
|
/// The Pokémon Center nurse's whole conversation, box by box, with raw presses (row 41).
|
|
///
|
|
/// The rung-10 loop of 2026-09-22 was `YES` 2,142 macro starts on one tile of map `0x3a`, so the
|
|
/// question the fix turns on is **which box each A press answers**. `wTextBoxID` cannot say --
|
|
/// every box the nurse draws is `$01` -- and `docs/design/macros-wram.md` says outright that there
|
|
/// is no "a choice is open" flag, so the reading has to be surveyed: leave the box with B, then
|
|
/// pulse A and print every state the conversation passes through, with the two-option menu's own
|
|
/// geometry beside it. The party is printed first because `HEAL`'s precondition is the party and
|
|
/// the loop's premise is that it is already full.
|
|
fn nurse_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) {
|
|
use flybrain_gb::pokemon_red::macros::cartridge::MacroState;
|
|
|
|
println!("\n## The party at the checkpoint\n");
|
|
{
|
|
let ledger = AdapterLedger(adapter);
|
|
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
|
|
let state: &mut dyn MacroState = &mut poke;
|
|
for mon in &state.party().mons {
|
|
println!(
|
|
"- slot {} species {:#04x} level {} hp {}/{} status {:?}",
|
|
mon.slot, mon.species, mon.level, mon.hp, mon.max_hp, mon.status
|
|
);
|
|
}
|
|
println!(
|
|
"- `party_needs_rest` = {}, `party_rested` = {}",
|
|
flybrain_gb::pokemon_red::macros::palette::party_needs_rest(state),
|
|
flybrain_gb::pokemon_red::macros::palette::party_rested(state),
|
|
);
|
|
}
|
|
|
|
let pulse = |gb: &mut Emulator, adapter: &mut PokemonRedReward, mask: u8, ms: &mut f64| {
|
|
for phase in 0..16 {
|
|
gb.set_buttons(if phase < 8 { mask } else { 0 });
|
|
gb.run_frame().expect("a frame should complete");
|
|
*ms += MS_PER_FRAME;
|
|
adapter.sample(gb, *ms);
|
|
}
|
|
};
|
|
|
|
println!("\n## The nurse's conversation, one raw A pulse at a time\n");
|
|
println!("- at the checkpoint: {}", nurse_frame(gb));
|
|
for _ in 0..20 {
|
|
if scene::detect(gb) == scene::Scene::Overworld {
|
|
break;
|
|
}
|
|
pulse(gb, adapter, flybrain_gb::buttons::B, ms);
|
|
}
|
|
println!("- after B until the box closes: {}", nurse_frame(gb));
|
|
println!("\n```");
|
|
let mut last = String::new();
|
|
for index in 0..env_usize("FLY_PROBE_PULSES", 120) {
|
|
pulse(gb, adapter, flybrain_gb::buttons::A, ms);
|
|
let now = nurse_frame(gb);
|
|
if now != last {
|
|
println!("A#{index:<3} {now}");
|
|
last = now;
|
|
}
|
|
}
|
|
println!("```");
|
|
}
|
|
|
|
/// The survey the whole-map grid's mid-step refusal turns on (`infra/docs/macros-traps.md` row 54).
|
|
///
|
|
/// Section 15 checks the decode against the screen buffer over the fly's own tile and its four
|
|
/// neighbours, and the residual of 2026-09-22 measured that check refusing on **every frame the
|
|
/// fly is mid-step**: standing still Pewter City decoded on 118 of 120 frames, and the one frame
|
|
/// the survey caught disagreed by exactly one tile row in the direction of travel. A walk planned
|
|
/// on such a frame is planned over the ten-by-nine window, which is the oscillation of row 23.
|
|
///
|
|
/// Two things have to be measured before that can be fixed honestly, and neither can be argued
|
|
/// from the disassembly alone:
|
|
///
|
|
/// 1. **when `wXCoord` / `wYCoord` change** — at the start of a step or at the end of it. That
|
|
/// decides whether the screen is behind the coordinates or the coordinates ahead of the screen.
|
|
/// 2. **which byte says "a step is in progress"**. `docs/design/macros-wram.md` says the reviewed
|
|
/// symbol list carries none, so every plausible candidate is dumped across a whole step and the
|
|
/// one that tracks it is the reading.
|
|
///
|
|
/// It holds one direction from the checkpoint and prints a line per frame: the coordinates, the
|
|
/// grid's verdict, the tiles the cross-check disagreed on, and the candidates.
|
|
fn step_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) {
|
|
use flybrain_gb::pokemon_red::macros::state::Walkable;
|
|
|
|
let frames = env_usize("FLY_PROBE_STEP_FRAMES", 96);
|
|
let step = |gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64, mask: u8| {
|
|
gb.set_buttons(mask);
|
|
gb.run_frame().expect("a frame should complete");
|
|
*ms += MS_PER_FRAME;
|
|
adapter.sample(gb, *ms);
|
|
};
|
|
|
|
// Somewhere the fly is its own master and the map is on screen, so that a refusal below is
|
|
// about the step and not about a text box.
|
|
for _ in 0..600 {
|
|
if scene::detect(gb) == scene::Scene::Overworld && state::controllable(gb) {
|
|
break;
|
|
}
|
|
step(gb, adapter, ms, flybrain_gb::buttons::B);
|
|
}
|
|
|
|
let Some(here) = state::player(gb) else {
|
|
println!("\nNo player at the checkpoint, so there is no step to survey.");
|
|
return;
|
|
};
|
|
println!("\n## The mid-step survey, on map {:#04x} from ({}, {})\n", here.map, here.x, here.y);
|
|
|
|
// A direction with walkable ground on the other side of it, so the hold is a step rather than
|
|
// a turn into a wall.
|
|
let facings = [
|
|
(flybrain_gb::buttons::DOWN, 0i16, 1i16, "DOWN"),
|
|
(flybrain_gb::buttons::UP, 0, -1, "UP"),
|
|
(flybrain_gb::buttons::LEFT, -1, 0, "LEFT"),
|
|
(flybrain_gb::buttons::RIGHT, 1, 0, "RIGHT"),
|
|
];
|
|
let mut chosen = None;
|
|
for (mask, dx, dy, name) in facings {
|
|
let (Ok(x), Ok(y)) =
|
|
(u8::try_from(i16::from(here.x) + dx), u8::try_from(i16::from(here.y) + dy))
|
|
else {
|
|
continue;
|
|
};
|
|
if state::walkable(gb, x, y) == Walkable::Yes {
|
|
chosen = Some((mask, name));
|
|
break;
|
|
}
|
|
}
|
|
let Some((mask, name)) = chosen else {
|
|
println!("Every neighbour of the fly is a wall, so there is no step to survey.");
|
|
return;
|
|
};
|
|
println!("Holding {name} for {frames} frames.\n");
|
|
println!("```");
|
|
println!(
|
|
"frame coords grid s1+1,+3,+5,+7,+8,+9 scy scx cfc5 d730 d736"
|
|
);
|
|
for frame in 0..frames {
|
|
let sprite: Vec<String> = [1u16, 3, 5, 7, 8, 9]
|
|
.into_iter()
|
|
.map(|offset| format!("{:02x}", gb.read8(ram::wSpriteStateData1 + offset)))
|
|
.collect();
|
|
let scy = gb.read8(0xff42);
|
|
let scx = gb.read8(0xff43);
|
|
let cfc5 = gb.read8(0xcfc5);
|
|
let d730 = gb.read8(ram::wStatusFlags5);
|
|
let d736 = gb.read8(ram::wMovementFlags);
|
|
let verdict = match state::map_grid(gb) {
|
|
Ok(_) => "ok".to_string(),
|
|
Err(refusal) => {
|
|
let shown: Vec<String> = state::grid_disagreement(gb)
|
|
.into_iter()
|
|
.filter(|(_, _, decoded, screen)| decoded != screen)
|
|
.map(|(x, y, decoded, screen)| format!("({x},{y}){decoded:?}/{screen:?}"))
|
|
.collect();
|
|
format!("{} {}", refusal.label(), shown.join(" "))
|
|
}
|
|
};
|
|
let coords = state::player(gb)
|
|
.map(|player| format!("({:>2},{:>2})", player.x, player.y))
|
|
.unwrap_or_else(|| " none ".to_string());
|
|
println!(
|
|
"{frame:>5} {coords} {verdict:<30} {} {scy:>3} {scx:>3} {cfc5:02x} {d730:02x} {d736:02x}",
|
|
sprite.join(",")
|
|
);
|
|
step(gb, adapter, ms, mask);
|
|
}
|
|
println!("```");
|
|
}
|
|
|
|
/// Ground truth for "this menu is accepting input", measured rather than read off a flag.
|
|
///
|
|
/// The emulator exports its own state, one directional pulse is issued into it, and
|
|
/// `wCurrentMenuItem` is read: `HandleMenuInput` moves the cursor on UP and DOWN before it even
|
|
/// looks at `wMenuWatchedKeys`, so a cursor that moves is a menu that is running its input loop and
|
|
/// a cursor that does not is a menu nobody is reading. The state goes straight back afterwards, so
|
|
/// the run this is measured inside is not perturbed by the measurement.
|
|
fn press_honoured(gb: &mut Emulator) -> bool {
|
|
let save = gb.export_state().expect("the emulator should export its own state");
|
|
let before = gb.read8(ram::wCurrentMenuItem);
|
|
let mask = if before < gb.read8(ram::wMaxMenuItem) {
|
|
flybrain_gb::buttons::DOWN
|
|
} else {
|
|
flybrain_gb::buttons::UP
|
|
};
|
|
// Released first, and that is not cosmetic. `JoypadLowSensitivity` acts on a key's *edge*, so a
|
|
// direction the fly is already holding when this pulse begins produces no press at all and the
|
|
// frame reads as refused for a reason that is the measurement's and not the cartridge's. The
|
|
// first survey of row 50 measured 187 such frames before this line existed.
|
|
for phase in 0..ACCEPT_PULSE {
|
|
gb.set_buttons(if (8..22).contains(&phase) { mask } else { 0 });
|
|
gb.run_frame().expect("a frame should complete");
|
|
}
|
|
let moved = gb.read8(ram::wCurrentMenuItem) != before;
|
|
gb.import_state(&save).expect("the emulator should take its own state back");
|
|
moved
|
|
}
|
|
|
|
/// Frames of the rollback pulse [`press_honoured`] issues: released, held, released.
|
|
const ACCEPT_PULSE: usize = 30;
|
|
|
|
/// Whether the cursor bytes say "the move list", which is all the seam read before row 50.
|
|
fn move_cursor_geometry(gb: &mut Emulator) -> bool {
|
|
gb.read8(ram::wTopMenuItemY) == 12 && gb.read8(ram::wTopMenuItemX) == 5
|
|
}
|
|
|
|
/// Every byte of WRAM and HRAM, as two sets of values per address.
|
|
///
|
|
/// The question row 50 asks is "which byte flips exactly when a press is honoured", and the honest
|
|
/// way to answer it is not to nominate candidates but to let every address answer: an address whose
|
|
/// values on accepting frames never once overlap its values on refusing frames *is* the reading,
|
|
/// and one that overlaps is not, however plausible its name.
|
|
struct Separator {
|
|
seen: BTreeMap<u16, [[u64; 4]; 2]>,
|
|
counts: [usize; 2],
|
|
}
|
|
|
|
impl Separator {
|
|
fn new() -> Self {
|
|
Self { seen: BTreeMap::new(), counts: [0, 0] }
|
|
}
|
|
|
|
fn observe(&mut self, gb: &mut Emulator, honoured: bool) {
|
|
let class = usize::from(honoured);
|
|
self.counts[class] += 1;
|
|
for address in (0xc000u16..0xe000).chain(0xff80u16..0xffff) {
|
|
let value = gb.read8(address);
|
|
let bits = self.seen.entry(address).or_insert([[0; 4]; 2]);
|
|
bits[class][usize::from(value) / 64] |= 1u64 << (u32::from(value) % 64);
|
|
}
|
|
}
|
|
|
|
/// The addresses whose two value sets never overlap, smallest sets first.
|
|
fn disjoint(&self) -> Vec<(u16, Vec<u8>, Vec<u8>)> {
|
|
let mut out: Vec<(u16, Vec<u8>, Vec<u8>)> = self
|
|
.seen
|
|
.iter()
|
|
.filter(|(_, bits)| {
|
|
(0..4).all(|word| bits[0][word] & bits[1][word] == 0)
|
|
&& bits[0].iter().any(|word| *word != 0)
|
|
&& bits[1].iter().any(|word| *word != 0)
|
|
})
|
|
.map(|(address, bits)| (*address, values(&bits[0]), values(&bits[1])))
|
|
.collect();
|
|
out.sort_by_key(|(address, refused, honoured)| {
|
|
(refused.len() + honoured.len(), *address)
|
|
});
|
|
out
|
|
}
|
|
}
|
|
|
|
/// A 256-bit set back as the byte values in it, capped so a report line stays a line.
|
|
fn values(bits: &[u64; 4]) -> Vec<u8> {
|
|
let mut out = Vec::new();
|
|
for value in 0..=255u16 {
|
|
if bits[usize::from(value) / 64] & (1u64 << (u32::from(value) % 64)) != 0 {
|
|
out.push(value as u8);
|
|
}
|
|
if out.len() >= 9 {
|
|
break;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// What the seam makes of this battle frame, in the shape the pad is dealt from.
|
|
fn battle_reading(gb: &mut Emulator, adapter: &PokemonRedReward) -> Option<(String, bool, bool)> {
|
|
use flybrain_gb::pokemon_red::macros::state::BattleMenu;
|
|
|
|
let ledger = AdapterLedger(adapter);
|
|
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
|
|
let battle = flybrain_gb::pokemon_red::macros::state::GameState::battle(&mut poke)?;
|
|
let name = match battle.menu {
|
|
BattleMenu::None => "none".to_string(),
|
|
BattleMenu::Main { cursor } => format!("main[{cursor}]"),
|
|
BattleMenu::Moves { cursor: Some(slot), count } => format!("moves[{slot}/{count}]"),
|
|
BattleMenu::Moves { cursor: None, count } => format!("moves[?/{count}]"),
|
|
BattleMenu::Party { cursor } => format!("party[{cursor}]"),
|
|
BattleMenu::Bag { cursor, count } => format!("bag[{cursor}/{count}]"),
|
|
};
|
|
Some((name, battle.own_turn, battle.forced_switch))
|
|
}
|
|
|
|
/// Whether the move list's own box is on screen, by the two tiles only it draws.
|
|
///
|
|
/// `MoveSelectionMenu`'s regular menu is a `TextBoxBorder` at (4, 12) fourteen wide, with the
|
|
/// junction tile written over (10, 12) afterwards. A plain battle text box is the full width of the
|
|
/// screen, so (10, 12) is a horizontal run and (4, 13) is inside it; the top-level battle menu's
|
|
/// own box starts at column 8. Either mark alone is ambiguous; together they are the move list.
|
|
fn move_box_drawn(gb: &mut Emulator) -> bool {
|
|
let corner = gb.read8(ram::wTileMap + 12 * 20 + 10);
|
|
let wall = gb.read8(ram::wTileMap + 13 * 20 + 4);
|
|
matches!(corner, 0x79 | 0x7b | 0x7d | 0x7e) && wall == 0x7c
|
|
}
|
|
|
|
/// The whole screen as border tiles, the menu cursor and "some text", one row per line.
|
|
fn screen_rows(gb: &mut Emulator) -> String {
|
|
(0..18u16)
|
|
.map(|y| {
|
|
let row: String = (0..20u16)
|
|
.map(|x| match gb.read8(ram::wTileMap + y * 20 + x) {
|
|
0x7f => '.',
|
|
0x79 | 0x7b | 0x7d | 0x7e => '+',
|
|
0x7a => '-',
|
|
0x7c => '|',
|
|
0xed => '>',
|
|
_ => 'x',
|
|
})
|
|
.collect();
|
|
format!(" {y:>2} {row}")
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
/// The tiles of the six rows a battle's bottom boxes are drawn in, as one line.
|
|
fn box_rows(gb: &mut Emulator) -> String {
|
|
(12..18u16)
|
|
.map(|y| {
|
|
(0..20u16)
|
|
.map(|x| match gb.read8(ram::wTileMap + y * 20 + x) {
|
|
0x7f => '.',
|
|
0x79 | 0x7b | 0x7d | 0x7e => '+',
|
|
0x7a => '-',
|
|
0x7c => '|',
|
|
0xed => '>',
|
|
_ => 'x',
|
|
})
|
|
.collect::<String>()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("/")
|
|
}
|
|
|
|
/// Row 50's survey: which reading says a battle menu is accepting input, and which only says it is
|
|
/// drawn.
|
|
///
|
|
/// `infra/docs/macros-traps.md` row 50: `MOVE n` reports `blocked` 890 times in 1,431 macros, every
|
|
/// one of them on a move list the seam could place a cursor in. Two readings fit that -- a list
|
|
/// that is up and busy, or cursor bytes that outlive the list they were written for -- and they are
|
|
/// told apart by pressing at it, so this presses at it: every battle frame is classified by whether
|
|
/// a real directional press moves the cursor, and every byte of WRAM and HRAM is asked whether it
|
|
/// separates the two classes.
|
|
fn accept_survey(
|
|
gb: &mut Emulator,
|
|
adapter: &mut PokemonRedReward,
|
|
ms: &mut f64,
|
|
layer: &mut flysim::macros::MacroLayer,
|
|
decoder: &mut PopulationDecoder,
|
|
channels: &[String],
|
|
hold_ms: f64,
|
|
) {
|
|
let budget = env_usize("FLY_PROBE_FRAMES", 200_000);
|
|
let samples = env_usize("FLY_PROBE_SAMPLES", 3_000);
|
|
let trace = env_usize("FLY_PROBE_TRACE", 160);
|
|
let mut next_burst = *ms;
|
|
let mut burst = 0usize;
|
|
let mut separators: BTreeMap<&'static str, Separator> = BTreeMap::new();
|
|
let mut tally: BTreeMap<(String, bool), [usize; 2]> = BTreeMap::new();
|
|
let mut shown: BTreeMap<(String, bool), String> = BTreeMap::new();
|
|
let mut traced = 0usize;
|
|
let mut tested = 0usize;
|
|
// [box not drawn, box drawn] x [press refused, press honoured], over every frame whose cursor
|
|
// bytes say "the move list" -- which is the whole of what the seam read before row 50.
|
|
let mut readings = [[0usize; 2]; 2];
|
|
|
|
println!("\n## Row 50: every battle frame, pressed at\n");
|
|
println!("```");
|
|
println!(
|
|
"frame seam turn honoured ccyx/cur/max/keys d125 cf94 cd6c cfc4 boxes"
|
|
);
|
|
for _ in 0..budget {
|
|
let bursting = *ms < next_burst + BURST_MS;
|
|
let hot = bursting.then(|| channels[(burst / HOLDS_PER_SLOT) % channels.len()].as_str());
|
|
if *ms >= next_burst + hold_ms {
|
|
next_burst = *ms;
|
|
burst += 1;
|
|
}
|
|
let bound = layer.bound_channels();
|
|
let active = decoder.decode_bound(&rates(hot), *ms, false, None, Some(&bound));
|
|
let mask = {
|
|
let ledger = AdapterLedger(adapter);
|
|
layer.decide(&active, 0, *ms, gb, &ledger).mask
|
|
};
|
|
gb.set_buttons(mask as u8);
|
|
gb.run_frame().expect("a frame should complete");
|
|
*ms += MS_PER_FRAME;
|
|
adapter.sample(gb, *ms);
|
|
{
|
|
let ledger = AdapterLedger(adapter);
|
|
let _ = layer.observe(gb, &ledger, *ms);
|
|
}
|
|
|
|
let Some((name, own_turn, forced)) = battle_reading(gb, adapter) else { continue };
|
|
let geom = move_cursor_geometry(gb);
|
|
if (name == "none" && !geom) || forced {
|
|
continue;
|
|
}
|
|
if tested >= samples {
|
|
break;
|
|
}
|
|
tested += 1;
|
|
let honoured = press_honoured(gb);
|
|
let drawn = move_box_drawn(gb);
|
|
if geom {
|
|
readings[usize::from(drawn)][usize::from(honoured)] += 1;
|
|
}
|
|
let key = (format!("{name} drawn={drawn}"), own_turn);
|
|
tally.entry(key.clone()).or_insert([0, 0])[usize::from(honoured)] += 1;
|
|
let kind = if name.starts_with("moves") {
|
|
"the move list"
|
|
} else if name.starts_with("main") {
|
|
"the top-level menu"
|
|
} else if name.starts_with("bag") {
|
|
"the bag"
|
|
} else {
|
|
"the party list"
|
|
};
|
|
separators.entry(kind).or_insert_with(Separator::new).observe(gb, honoured);
|
|
let boxes = box_rows(gb);
|
|
shown.entry((name.clone(), honoured)).or_insert_with(|| screen_rows(gb));
|
|
if traced < trace {
|
|
traced += 1;
|
|
println!(
|
|
"{tested:>5} {name:<18} {:<4} {:<8} {:>2},{:>2},{:>2},{:>2},{:#04x} \
|
|
{:02x} {:02x} {:02x} {:02x} {boxes}",
|
|
own_turn,
|
|
honoured,
|
|
gb.read8(ram::wTopMenuItemY),
|
|
gb.read8(ram::wTopMenuItemX),
|
|
gb.read8(ram::wCurrentMenuItem),
|
|
gb.read8(ram::wMaxMenuItem),
|
|
gb.read8(ram::wMenuWatchedKeys),
|
|
gb.read8(ram::wTextBoxID),
|
|
gb.read8(ram::wListMenuID),
|
|
gb.read8(ram::wNumMovesMinusOne),
|
|
gb.read8(ram::wFontLoaded),
|
|
);
|
|
}
|
|
}
|
|
println!("```");
|
|
|
|
let (stale, live) = (readings[0], readings[1]);
|
|
println!("\n## The move list, by which reading says it is up\n");
|
|
println!("| the reading | press refused | press honoured |");
|
|
println!("| --- | ---: | ---: |");
|
|
println!(
|
|
"| the cursor bytes alone (what the seam read before row 50) | {} | {} |",
|
|
stale[0] + live[0],
|
|
stale[1] + live[1],
|
|
);
|
|
println!("| the cursor bytes **and** the box on screen | {} | {} |", live[0], live[1]);
|
|
println!("| the cursor bytes with no box drawn | {} | {} |", stale[0], stale[1]);
|
|
|
|
println!("\n## What the seam reads against what the cartridge honours\n");
|
|
println!("| the seam's menu | `own_turn` | press refused | press honoured |");
|
|
println!("| --- | --- | ---: | ---: |");
|
|
for ((name, own_turn), counts) in &tally {
|
|
println!("| `{name}` | {own_turn} | {} | {} |", counts[0], counts[1]);
|
|
}
|
|
|
|
for (kind, separator) in &separators {
|
|
println!(
|
|
"\n## The bytes that separate a honoured press from a refused one, on {kind}\n\n\
|
|
{} refusing frames, {} accepting.\n",
|
|
separator.counts[0], separator.counts[1]
|
|
);
|
|
let disjoint = separator.disjoint();
|
|
if disjoint.is_empty() {
|
|
println!("No single byte of WRAM or HRAM separates the two classes here.");
|
|
continue;
|
|
}
|
|
println!("| address | when refused | when honoured |");
|
|
println!("| ---: | --- | --- |");
|
|
for (address, refused, honoured) in disjoint.iter().take(40) {
|
|
println!(
|
|
"| `{address:#06x}` | {} | {} |",
|
|
refused.iter().map(|v| format!("{v:02x}")).collect::<Vec<_>>().join(" "),
|
|
honoured.iter().map(|v| format!("{v:02x}")).collect::<Vec<_>>().join(" "),
|
|
);
|
|
}
|
|
println!("\n{} addresses separate in all.", disjoint.len());
|
|
}
|
|
|
|
println!("\n## One screen of each class\n\n```");
|
|
for ((name, honoured), boxes) in &shown {
|
|
println!("{name} honoured={honoured}\n{boxes}");
|
|
}
|
|
println!("```");
|
|
}
|
|
|
|
/// The screen as text, for a survey that has to read what the cartridge actually drew.
|
|
///
|
|
/// Red's charmap: `$80`-`$99` are `A`-`Z`, `$a0`-`$b9` are `a`-`z`, `$f6`-`$ff` are `0`-`9`, and
|
|
/// `$7f` is a space. Everything else prints as `.`, which is enough to tell a clerk's text box
|
|
/// from an item list.
|
|
fn screen_text(gb: &mut Emulator) -> Vec<String> {
|
|
(0..18u16)
|
|
.map(|y| {
|
|
(0..20u16)
|
|
.map(|x| match gb.read8(ram::wTileMap + y * 20 + x) {
|
|
0x7f => ' ',
|
|
byte @ 0x80..=0x99 => (b'A' + (byte - 0x80)) as char,
|
|
byte @ 0xa0..=0xb9 => (b'a' + (byte - 0xa0)) as char,
|
|
byte @ 0xf6..=0xff => (b'0' + (byte - 0xf6)) as char,
|
|
0xe7 => '!',
|
|
0xe8 => '?',
|
|
0xf3 => '$',
|
|
_ => '.',
|
|
})
|
|
.collect()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Every byte the mart's reading rests on, as one line.
|
|
///
|
|
/// The raw half is read first, because the seam borrows the emulator: `wListMenuID` and
|
|
/// `wTextBoxID` are the two bytes [`state::shop`] decides on, and `wCurrentMenuItem` /
|
|
/// `wMaxMenuItem` are the cursor a purchase navigates by.
|
|
fn counter_line(gb: &mut Emulator, adapter: &PokemonRedReward) -> String {
|
|
use flybrain_gb::pokemon_red::macros::cartridge::MacroState;
|
|
let raw = format!(
|
|
"list={:#04x} textbox={:#04x} cur={} max={} font={:#04x} watched={:#04x} \
|
|
top=({},{}) joy={:#04x}",
|
|
gb.read8(ram::wListMenuID),
|
|
gb.read8(ram::wTextBoxID),
|
|
gb.read8(ram::wCurrentMenuItem),
|
|
gb.read8(ram::wMaxMenuItem),
|
|
gb.read8(ram::wFontLoaded),
|
|
gb.read8(ram::wMenuWatchedKeys),
|
|
gb.read8(ram::wTopMenuItemY),
|
|
gb.read8(ram::wTopMenuItemX),
|
|
gb.read8(ram::wJoyIgnore),
|
|
);
|
|
let text = flybrain_gb::pokemon_red::state::text_box(gb);
|
|
let yes_no = flybrain_gb::pokemon_red::state::yes_no_prompt(gb);
|
|
let ledger = AdapterLedger(adapter);
|
|
let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
|
|
let state: &mut dyn MacroState = &mut poke;
|
|
format!(
|
|
"{:?} shop={:?} box(open={} waiting={}) yes_no={} money={} | {raw}",
|
|
state.scene(),
|
|
state.shop().map(|shop| (shop.screen, shop.cursor.current, shop.cursor.max)),
|
|
text.open,
|
|
text.waiting,
|
|
yes_no,
|
|
state.money()
|
|
)
|
|
}
|
|
|
|
/// Row 55's counter survey: what the mart's seam reads, what the pad offers, and what a real
|
|
/// `BUY …` does frame by frame.
|
|
///
|
|
/// `FLY_PROBE_CATCH=shop`. The live loop was `BUY ANTIDOTE start` / `BUY ANTIDOTE blocked` every
|
|
/// 0.8 s for ten brain minutes in the Pewter mart, with nothing else starting, so the three
|
|
/// questions are which reading puts the button on the pad, which step of the script gives up, and
|
|
/// what the counter's own cursor reports while it does. All three are printed rather than
|
|
/// reasoned about: the script navigates by reading the cursor, so the cursor is the evidence.
|
|
///
|
|
/// `FLY_PROBE_SHOP_ITEM` names the item by its `BUY …` button (`antidote` by default) so the same
|
|
/// survey can be pointed at whichever purchase the loop is on.
|
|
fn shop_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) {
|
|
use flybrain_gb::pokemon_red::macros::cartridge::MacroState;
|
|
use flybrain_gb::pokemon_red::macros::palette;
|
|
use flybrain_gb::pokemon_red::macros::{MacroKind, MacroMachine, Palette};
|
|
use flybrain_gb::pokemon_red::state::PokeState;
|
|
|
|
let want = std::env::var("FLY_PROBE_SHOP_ITEM").unwrap_or_else(|_| "antidote".to_string());
|
|
let wanted = match want.as_str() {
|
|
"potion" => MacroKind::BuyPotion,
|
|
"ball" => MacroKind::BuyBall,
|
|
"repel" => MacroKind::BuyRepel,
|
|
_ => MacroKind::BuyAntidote,
|
|
};
|
|
|
|
println!("\n## The counter as the seam reads it, sixty frames with nothing pressed\n");
|
|
let mut last = String::new();
|
|
for frame in 0..60 {
|
|
let line = counter_line(gb, adapter);
|
|
if line != last {
|
|
println!("- frame {frame:3}: {line}");
|
|
last = line;
|
|
}
|
|
gb.set_buttons(flybrain_gb::buttons::NONE);
|
|
gb.run_frame().expect("a frame should complete");
|
|
*ms += MS_PER_FRAME;
|
|
adapter.sample(gb, *ms);
|
|
}
|
|
|
|
println!("\n## Every button the shop scene deals, and the halves of each precondition\n");
|
|
{
|
|
let ledger = AdapterLedger(adapter);
|
|
let mut poke = PokeState::with_ledger(gb, &ledger);
|
|
let state: &mut dyn MacroState = &mut poke;
|
|
let scene = state.scene();
|
|
let palette = Palette::for_scene(scene, state);
|
|
let names: Vec<&str> = palette.slots.iter().flatten().map(|spec| spec.name).collect();
|
|
println!("- scene `{scene:?}`, the pad: {names:?}");
|
|
println!("- `shop_screen` = {:?}", palette::shop_screen(state));
|
|
println!("- `listing` = {:?}", palette::listing(state));
|
|
println!("- `inside_mart` = {}", palette::inside_mart(state));
|
|
for kind in [
|
|
MacroKind::BuyPotion,
|
|
MacroKind::BuyBall,
|
|
MacroKind::BuyAntidote,
|
|
MacroKind::BuyRepel,
|
|
] {
|
|
let purchase = kind.purchase();
|
|
let stocked = purchase.map(|(id, _)| state.shop_stock().iter().position(|s| *s == id));
|
|
let rich = purchase.map(|(_, cost)| state.money() >= cost);
|
|
println!(
|
|
"- {:<12} item {:?}, its index in the stock {stocked:?}, money allows {rich:?}, \
|
|
precondition {}",
|
|
kind.name(),
|
|
purchase,
|
|
palette::precondition(kind, state),
|
|
);
|
|
}
|
|
}
|
|
|
|
println!("\n## `{}`, frame by frame, three times over\n", wanted.name());
|
|
for attempt in 1..=3 {
|
|
let slot = {
|
|
let ledger = AdapterLedger(adapter);
|
|
let mut poke = PokeState::with_ledger(gb, &ledger);
|
|
let state: &mut dyn MacroState = &mut poke;
|
|
let scene = state.scene();
|
|
let palette = Palette::for_scene(scene, state);
|
|
palette.slot(flybrain_gb::pokemon_red::macros::MacroId(wanted.slot())).map(|_| {
|
|
(palette, flybrain_gb::pokemon_red::macros::MacroId(wanted.slot()))
|
|
})
|
|
};
|
|
let Some((palette, slot)) = slot else {
|
|
println!("- attempt {attempt}: the button is not on the pad, so nothing is pressed.");
|
|
return;
|
|
};
|
|
let mut machine = MacroMachine::new(SEED);
|
|
let started = {
|
|
let ledger = AdapterLedger(adapter);
|
|
let mut poke = PokeState::with_ledger(gb, &ledger);
|
|
machine.start(&palette, slot, &mut poke)
|
|
};
|
|
println!("\n### Attempt {attempt}: `start` = {started:?}");
|
|
println!("- frame 0: {}", counter_line(gb, adapter));
|
|
let mut frame = 0u32;
|
|
loop {
|
|
let mask = {
|
|
let ledger = AdapterLedger(adapter);
|
|
let mut poke = PokeState::with_ledger(gb, &ledger);
|
|
machine.step(&mut poke)
|
|
};
|
|
let Some(mask) = mask else { break };
|
|
gb.set_buttons(mask);
|
|
gb.run_frame().expect("a frame should complete");
|
|
*ms += MS_PER_FRAME;
|
|
adapter.sample(gb, *ms);
|
|
frame += 1;
|
|
let line = counter_line(gb, adapter);
|
|
if line != last || frame.is_multiple_of(20) {
|
|
println!("- frame {frame:3}: mask {mask:#06x} {line}");
|
|
last = line;
|
|
}
|
|
if frame > 700 {
|
|
println!("- (over seven hundred frames, which the cap forbids)");
|
|
break;
|
|
}
|
|
}
|
|
println!("- outcome after {frame} frames: {:?}", machine.outcome());
|
|
let mut entries = Vec::new();
|
|
while let Some(entry) = machine.take_blocked() {
|
|
entries.push(entry);
|
|
}
|
|
println!("- the blocked ledger entries it earned: {entries:?}");
|
|
}
|
|
|
|
println!("\n## What an A press at the counter really opens, pulsed by hand\n");
|
|
let pulse = |gb: &mut Emulator, adapter: &mut PokemonRedReward, mask: u8, ms: &mut f64| {
|
|
for phase in 0..16 {
|
|
gb.set_buttons(if phase < 8 { mask } else { 0 });
|
|
gb.run_frame().expect("a frame should complete");
|
|
*ms += MS_PER_FRAME;
|
|
adapter.sample(gb, *ms);
|
|
}
|
|
};
|
|
for step in 1..=40 {
|
|
pulse(gb, adapter, flybrain_gb::buttons::A, ms);
|
|
println!("- A pulse {step:2}: {}", counter_line(gb, adapter));
|
|
}
|
|
println!("\n## And backing out of whatever that left, with B\n");
|
|
for step in 1..=10 {
|
|
pulse(gb, adapter, flybrain_gb::buttons::B, ms);
|
|
println!("- B pulse {step:2}: {}", counter_line(gb, adapter));
|
|
}
|
|
println!("\n## The live buy list: BUY chosen from the counter menu, then read\n");
|
|
// Get to the BUY / SELL / QUIT menu -- the one screen in the mart whose cursor is accepting
|
|
// input with no dialogue box drawn -- put the cursor on BUY, confirm, and read what opens.
|
|
// This is the list `shop_plan` navigates, and what `wMaxMenuItem` reports on it is the whole
|
|
// question row 55 turns on.
|
|
for _ in 0..40 {
|
|
let menu = {
|
|
let text = flybrain_gb::pokemon_red::state::text_box(gb);
|
|
!text.waiting && gb.read8(ram::wMaxMenuItem) == 2 && gb.read8(ram::wTopMenuItemY) == 4
|
|
};
|
|
if menu {
|
|
break;
|
|
}
|
|
pulse(gb, adapter, flybrain_gb::buttons::A, ms);
|
|
}
|
|
println!("- at the counter menu: {}", counter_line(gb, adapter));
|
|
for row in screen_text(gb) {
|
|
println!(" |{row}|");
|
|
}
|
|
for _ in 0..4 {
|
|
if gb.read8(ram::wCurrentMenuItem) == 0 {
|
|
break;
|
|
}
|
|
pulse(gb, adapter, flybrain_gb::buttons::UP, ms);
|
|
}
|
|
println!("- cursor on BUY: {}", counter_line(gb, adapter));
|
|
pulse(gb, adapter, flybrain_gb::buttons::A, ms);
|
|
let mut seen = String::new();
|
|
for frame in 0..90 {
|
|
let line = counter_line(gb, adapter);
|
|
if line != seen {
|
|
println!("- {frame:3} frames after confirming BUY: {line}");
|
|
for row in screen_text(gb) {
|
|
println!(" |{row}|");
|
|
}
|
|
seen = line;
|
|
}
|
|
gb.set_buttons(flybrain_gb::buttons::NONE);
|
|
gb.run_frame().expect("a frame should complete");
|
|
*ms += MS_PER_FRAME;
|
|
adapter.sample(gb, *ms);
|
|
}
|
|
println!("\n### Walking that list down, one pulse at a time\n");
|
|
for step in 1..=9 {
|
|
pulse(gb, adapter, flybrain_gb::buttons::DOWN, ms);
|
|
println!("- DOWN {step}: {}", counter_line(gb, adapter));
|
|
for row in screen_text(gb) {
|
|
println!(" |{row}|");
|
|
}
|
|
}
|
|
|
|
println!("\n## The counter reopened from the overworld: every screen the mart draws\n");
|
|
// Out of the counter, face the clerk again and press A: the one sequence that shows what
|
|
// `wMaxMenuItem` really reports on the BUY / SELL / QUIT menu and on the *live* buy list,
|
|
// which is the question the fix turns on.
|
|
for step in 1..=30 {
|
|
pulse(gb, adapter, flybrain_gb::buttons::A, ms);
|
|
let line = counter_line(gb, adapter);
|
|
println!("- A pulse {step:2}: {line}");
|
|
if step % 10 == 0 {
|
|
for down in 1..=3 {
|
|
pulse(gb, adapter, flybrain_gb::buttons::DOWN, ms);
|
|
println!(" - DOWN {down}: {}", counter_line(gb, adapter));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
let Some(path) = std::env::var_os("FLY_ROM") else {
|
|
println!("FLY_ROM is not set, so there is nothing to probe.");
|
|
return;
|
|
};
|
|
let rom = std::fs::read(&path).expect("FLY_ROM should be readable");
|
|
let Some(checkpoint_path) = std::env::var_os("FLY_TRAP_CHECKPOINT")
|
|
.or_else(|| std::env::var_os("FLY_MACRO_CHECKPOINT"))
|
|
else {
|
|
println!("FLY_TRAP_CHECKPOINT is not set: a probe is about a state the stream was in.");
|
|
return;
|
|
};
|
|
let checkpoint = flysim::store::load(std::path::Path::new(&checkpoint_path))
|
|
.expect("FLY_TRAP_CHECKPOINT should be a FLYSIM01 envelope");
|
|
|
|
let mut gb = Emulator::new(&rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES)
|
|
.expect("binjgb should accept the cartridge");
|
|
let mut adapter = PokemonRedReward::new();
|
|
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");
|
|
let preset = gameboy_decoder_config_with_macros(&channels);
|
|
let hold_ms = preset.macros.as_ref().expect("the preset has a macro group").hold_ms;
|
|
let mut decoder = PopulationDecoder::new(preset).expect("the preset is well formed");
|
|
decoder.calibrate(&rates(None));
|
|
let mut config = Config::default();
|
|
config.loop_.game = "pokemon-red".to_string();
|
|
config.macros.mode = MacroMode::Macros;
|
|
config.validate().expect("pokemon-red has a palette in macros mode");
|
|
let mut layer = macro_layer(&config, hold_ms, SEED).expect("a layer in macros mode");
|
|
let mut ms = 0.0;
|
|
let _ = layer.observe(&mut gb, &AdapterLedger(&adapter), ms);
|
|
|
|
println!("# Scene probe\n\nFrom `{}`.", std::path::Path::new(&checkpoint_path).display());
|
|
cartridge(&mut gb, &adapter, "The save at the checkpoint");
|
|
dump(&mut gb, "At the checkpoint");
|
|
pad(&mut gb, &adapter, "The pad at the checkpoint");
|
|
|
|
let catch_nurse = std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "nurse");
|
|
if catch_nurse {
|
|
nurse_survey(&mut gb, &mut adapter, &mut ms);
|
|
return;
|
|
}
|
|
|
|
// Row 54's mid-step survey: hold one direction and watch the grid's cross-check, the
|
|
// coordinates and every candidate for "a step is in progress" across a whole step.
|
|
if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "step") {
|
|
step_survey(&mut gb, &mut adapter, &mut ms);
|
|
return;
|
|
}
|
|
|
|
// Row 50's survey: drive real battles and press at every battle menu the seam reads, to tell a
|
|
// list that is accepting input from cursor bytes that outlived their list.
|
|
if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "accept") {
|
|
let channels: Vec<String> = channels.iter().map(|name| (*name).to_string()).collect();
|
|
accept_survey(
|
|
&mut gb,
|
|
&mut adapter,
|
|
&mut ms,
|
|
&mut layer,
|
|
&mut decoder,
|
|
&channels,
|
|
hold_ms,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Row 55's counter survey: what the mart's seam reads while a `BUY ...` runs, and what an A
|
|
// press at the counter really opens.
|
|
if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "shop") {
|
|
shop_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;
|
|
let mut burst = 0usize;
|
|
let mut run: (&'static str, usize) = ("", 0);
|
|
let mut stuck_at = None;
|
|
// The push-back's own signature: `wSimulatedJoypadStatesIndex` non-zero means the cartridge is
|
|
// walking the player with joypad states of its own (`infra/docs/macros-traps.md` row 28).
|
|
let catch_script = std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "script");
|
|
// The survey method (`docs/design/macros-wram.md`): drive until the fly is standing on a named
|
|
// map and print that map's own tables, because a warp's destination byte is ground truth about
|
|
// which map id is on the other side of which door.
|
|
let survey: Option<u8> = std::env::var("FLY_PROBE_MAP")
|
|
.ok()
|
|
.and_then(|value| value.trim().parse().ok());
|
|
// A turn the fly cannot attack on: `ATTACK` off the pad while a battle menu is accepting
|
|
// input. `FLY_PROBE_STUCK` frames in a row of it and everything gets dumped.
|
|
let catch_noattack =
|
|
std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "noattack");
|
|
// The same turn asked about without reference to the pad: the fly's own turn with no move that
|
|
// has PP. `noattack` is row 34's *symptom* and goes quiet once the row is fixed, which is what
|
|
// makes it the after-measurement; this is the *state*, and it is what a checkpoint is made from.
|
|
let catch_nopp = std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "nopp");
|
|
let mut noattack = 0usize;
|
|
let mut before = (0u8, 0u8, 0u8);
|
|
let mut surveyed = 0usize;
|
|
for frame in 0..budget {
|
|
let bursting = ms < next_burst + BURST_MS;
|
|
let hot = bursting.then(|| channels[(burst / HOLDS_PER_SLOT) % channels.len()]);
|
|
if ms >= next_burst + hold_ms {
|
|
next_burst = ms;
|
|
burst += 1;
|
|
}
|
|
let bound = layer.bound_channels();
|
|
let active = decoder.decode_bound(&rates(hot), ms, false, None, Some(&bound));
|
|
let mask = {
|
|
let ledger = AdapterLedger(&adapter);
|
|
layer.decide(&active, 0, ms, &mut gb, &ledger).mask
|
|
};
|
|
gb.set_buttons(mask as u8);
|
|
gb.run_frame().expect("a frame should complete");
|
|
ms += MS_PER_FRAME;
|
|
adapter.sample(&mut gb, ms);
|
|
{
|
|
let ledger = AdapterLedger(&adapter);
|
|
let _ = layer.observe(&mut gb, &ledger, ms);
|
|
}
|
|
if catch_script
|
|
&& gb.read8(ram::wSimulatedJoypadStatesIndex) != 0
|
|
&& gb.read8(ram::wCurMap) == 1
|
|
{
|
|
println!(
|
|
"\nThe cartridge took the joypad at frame {frame} ({:.2} brain minutes). \
|
|
The frame before, the player was on map {:#04x} at ({}, {}).",
|
|
ms / 60_000.0,
|
|
before.0,
|
|
before.1,
|
|
before.2
|
|
);
|
|
cartridge(&mut gb, &adapter, "The save when the script fired");
|
|
dump(&mut gb, "The frame the script took over");
|
|
// Ride it out and see where the player ends up, pressing nothing.
|
|
for _ in 0..240 {
|
|
gb.set_buttons(0);
|
|
gb.run_frame().expect("a frame should complete");
|
|
ms += MS_PER_FRAME;
|
|
adapter.sample(&mut gb, ms);
|
|
}
|
|
dump(&mut gb, "240 frames later, with nothing pressed");
|
|
return;
|
|
}
|
|
if catch_noattack || catch_nopp {
|
|
let own_turn = matches!(
|
|
scene::detect(&mut gb),
|
|
scene::Scene::Battle { own_turn: true, forced_switch: false }
|
|
);
|
|
let pad = layer.bound_channels();
|
|
let caught = if catch_nopp {
|
|
use flybrain_gb::pokemon_red::macros::cartridge::MacroState;
|
|
let ledger = AdapterLedger(&adapter);
|
|
let mut poke =
|
|
flybrain_gb::pokemon_red::state::PokeState::with_ledger(&mut gb, &ledger);
|
|
let state: &mut dyn MacroState = &mut poke;
|
|
// Not `best_move(..).is_none()`: that is also true on a battle's opening frames,
|
|
// before the engine has copied the active Pokemon into `wBattleMon*` (row 30b),
|
|
// and this catch saved that frame the first time it was run. The condition is the
|
|
// state itself -- a Pokemon that *is* out, with every move it has at 0 PP.
|
|
own_turn
|
|
&& state.battle().and_then(|battle| battle.own).is_some_and(|mon| {
|
|
let mut moves = mon.moves.iter().flatten().filter(|entry| entry.id != 0);
|
|
moves.clone().next().is_some() && moves.all(|entry| entry.pp == 0)
|
|
})
|
|
} else {
|
|
own_turn && !pad.iter().any(|channel| channel.ends_with("attack"))
|
|
};
|
|
if caught {
|
|
noattack += 1;
|
|
} else {
|
|
noattack = 0;
|
|
}
|
|
if noattack >= stuck_after {
|
|
println!(
|
|
"\nThe fly's own turn matched for {stuck_after} consecutive frames, at frame \
|
|
{frame} ({:.2} brain minutes). Catch: {}.",
|
|
ms / 60_000.0,
|
|
if catch_nopp { "no move with PP" } else { "no `ATTACK` on the pad" }
|
|
);
|
|
battle_dump(&mut gb, &adapter, &pad, "The turn it could not attack on");
|
|
// `FLY_PROBE_SAVE` writes this exact state as a `FLYSIM01` checkpoint, so the ROM
|
|
// test for row 34 can resume into the battle instead of playing 99 brain minutes
|
|
// to reach it. The agent half is the source checkpoint's, unchanged -- this is the
|
|
// release box's own run carried forward by the stub, not a synthesised save -- and
|
|
// `.local/` is not tracked, exactly as every other checkpoint in this workspace.
|
|
if let Some(save) = std::env::var_os("FLY_PROBE_SAVE") {
|
|
let mut runtime = checkpoint.runtime.clone();
|
|
runtime.emulator = gb.export_state().expect("the emulator should export");
|
|
runtime.reward = adapter.export_state();
|
|
runtime.framebuffer = gb.framebuffer().to_vec();
|
|
runtime.emulator_frame = frame as u64;
|
|
let bytes = flysim::store::encode(&checkpoint.agent, &runtime)
|
|
.expect("the envelope should encode");
|
|
flysim::store::write_atomic(std::path::Path::new(&save), &bytes)
|
|
.expect("the checkpoint should be writable");
|
|
println!(
|
|
"\nWrote this state to `{}` ({} bytes).",
|
|
std::path::Path::new(&save).display(),
|
|
bytes.len()
|
|
);
|
|
}
|
|
// The survey method on the one question the fix turns on: **what does the
|
|
// cartridge do when FIGHT is chosen and no move has PP?** Back out to the
|
|
// top-level menu with B, put its cursor on FIGHT, confirm, and watch whether the
|
|
// move list opens at all. `CheckPlayerHasUsableMoves` is claimed to skip it and
|
|
// set Struggle; this is that claim, measured through the seam the macros read.
|
|
println!("\n## Choosing FIGHT with no PP, with raw presses\n");
|
|
let menu = |gb: &mut Emulator, adapter: &PokemonRedReward| {
|
|
use flybrain_gb::pokemon_red::macros::cartridge::MacroState;
|
|
let ledger = AdapterLedger(adapter);
|
|
let mut poke =
|
|
flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger);
|
|
let state: &mut dyn MacroState = &mut poke;
|
|
(
|
|
state.battle().map(|battle| battle.menu),
|
|
state.battle().and_then(|battle| battle.own).map(|mon| mon.hp),
|
|
state.battle().and_then(|battle| battle.enemy).map(|mon| mon.hp),
|
|
)
|
|
};
|
|
let pulse = |gb: &mut Emulator, adapter: &mut PokemonRedReward, mask: u8, ms: &mut f64| {
|
|
for phase in 0..16 {
|
|
gb.set_buttons(if phase < 8 { mask } else { 0 });
|
|
gb.run_frame().expect("a frame should complete");
|
|
*ms += MS_PER_FRAME;
|
|
adapter.sample(gb, *ms);
|
|
}
|
|
};
|
|
for _ in 0..12 {
|
|
if matches!(menu(&mut gb, &adapter).0, Some(flybrain_gb::pokemon_red::macros::state::BattleMenu::Main { .. })) {
|
|
break;
|
|
}
|
|
pulse(&mut gb, &mut adapter, flybrain_gb::buttons::B, &mut ms);
|
|
}
|
|
println!("- after backing out with B: {:?}", menu(&mut gb, &adapter).0);
|
|
for _ in 0..6 {
|
|
if matches!(
|
|
menu(&mut gb, &adapter).0,
|
|
Some(flybrain_gb::pokemon_red::macros::state::BattleMenu::Main { cursor: 0 })
|
|
) {
|
|
break;
|
|
}
|
|
pulse(&mut gb, &mut adapter, flybrain_gb::buttons::UP, &mut ms);
|
|
pulse(&mut gb, &mut adapter, flybrain_gb::buttons::LEFT, &mut ms);
|
|
}
|
|
let before = menu(&mut gb, &adapter);
|
|
println!("- cursor on FIGHT: {:?}, own hp {:?}, enemy hp {:?}", before.0, before.1, before.2);
|
|
pulse(&mut gb, &mut adapter, flybrain_gb::buttons::A, &mut ms);
|
|
let mut seen: Vec<String> = Vec::new();
|
|
let mut move_list_frames = 0usize;
|
|
for _ in 0..30 {
|
|
let now = menu(&mut gb, &adapter);
|
|
let label = format!("{:?}", now.0);
|
|
if matches!(now.0, Some(flybrain_gb::pokemon_red::macros::state::BattleMenu::Moves { .. })) {
|
|
move_list_frames += 1;
|
|
}
|
|
if seen.last() != Some(&label) {
|
|
seen.push(label);
|
|
}
|
|
pulse(&mut gb, &mut adapter, flybrain_gb::buttons::NONE, &mut ms);
|
|
}
|
|
let after = menu(&mut gb, &adapter);
|
|
println!("- menus after confirming FIGHT, in order: {seen:?}");
|
|
println!("- pulses with the move list open: {move_list_frames}");
|
|
println!(
|
|
"- own hp {:?} -> {:?}, enemy hp {:?} -> {:?} (Struggle recoils on its user)",
|
|
before.1, after.1, before.2, after.2
|
|
);
|
|
battle_dump(&mut gb, &adapter, &layer.bound_channels(), "After confirming FIGHT");
|
|
return;
|
|
}
|
|
}
|
|
if survey == Some(gb.read8(ram::wCurMap)) && state::controllable(&mut gb) {
|
|
surveyed += 1;
|
|
} else {
|
|
surveyed = 0;
|
|
}
|
|
// Sixty frames on the map, because `wCurMap` changes several frames before the map header
|
|
// it names is loaded: a dump taken on the first frame prints the *previous* map's size and
|
|
// warp table under the new map's id.
|
|
if let Some(map) = survey
|
|
&& surveyed >= 60
|
|
{
|
|
println!("\nThe fly reached map {map:#04x} at frame {frame}.");
|
|
// Stand still for a while and count how many of those frames the whole-map grid can be
|
|
// decoded on. A walking fly is mid-step on most frames -- `wYCoord` is the tile it is
|
|
// walking *to* while the background is still scrolling -- and the screen buffer the
|
|
// cross-check reads is the one that is a tile behind, so "is the grid refused on this
|
|
// map" and "is the grid refused while the fly is moving" are different questions with
|
|
// different fixes (`docs/design/macros.md` section 15).
|
|
let mut refusals: BTreeMap<&'static str, usize> = BTreeMap::new();
|
|
for _ in 0..env_usize("FLY_PROBE_SETTLE", 120) {
|
|
gb.set_buttons(flybrain_gb::buttons::NONE);
|
|
gb.run_frame().expect("a frame should complete");
|
|
ms += MS_PER_FRAME;
|
|
adapter.sample(&mut gb, ms);
|
|
let label = match flybrain_gb::pokemon_red::state::map_grid(&mut gb) {
|
|
Ok(_) => "decoded",
|
|
Err(refusal) => refusal.label(),
|
|
};
|
|
*refusals.entry(label).or_insert(0) += 1;
|
|
}
|
|
println!("\nStanding still on it, frame by frame: {refusals:?}");
|
|
cartridge(&mut gb, &adapter, "The save on the surveyed map");
|
|
pad(&mut gb, &adapter, "The pad on the surveyed map");
|
|
return;
|
|
}
|
|
before = (
|
|
gb.read8(ram::wCurMap),
|
|
gb.read8(ram::wXCoord),
|
|
gb.read8(ram::wYCoord),
|
|
);
|
|
let name = layer.scene_name();
|
|
if name == run.0 {
|
|
run.1 += 1;
|
|
} else {
|
|
run = (name, 1);
|
|
}
|
|
// The scene-run detector is the default catch; a named `FLY_PROBE_CATCH` is looking for
|
|
// something else and a battle's own text would end the run before it got there.
|
|
if !catch_noattack && !catch_nopp && !catch_script && name != "overworld"
|
|
&& run.1 >= stuck_after
|
|
{
|
|
stuck_at = Some((frame, name));
|
|
break;
|
|
}
|
|
}
|
|
|
|
match stuck_at {
|
|
Some((frame, name)) => {
|
|
println!(
|
|
"\nStuck in `{name}` for {stuck_after} consecutive frames, at frame {frame} \
|
|
({:.1} brain minutes). Adapter mode `{}`.",
|
|
ms / 60_000.0,
|
|
adapter.mode()
|
|
);
|
|
dump(&mut gb, "Where it stuck");
|
|
// Does anything move it? The pad's own two presses, then the ones it has no button
|
|
// for, each pulsed the way every script in this workspace pulses.
|
|
for (label, mask) in [
|
|
("after 20 A pulses", flybrain_gb::buttons::A),
|
|
("after 20 B pulses", flybrain_gb::buttons::B),
|
|
("after 20 START pulses", flybrain_gb::buttons::START),
|
|
] {
|
|
for pulse in 0..20 {
|
|
for phase in 0..16 {
|
|
gb.set_buttons(if phase < 8 { mask } else { 0 });
|
|
gb.run_frame().expect("a frame should complete");
|
|
ms += MS_PER_FRAME;
|
|
adapter.sample(&mut gb, ms);
|
|
}
|
|
let _ = pulse;
|
|
}
|
|
dump(&mut gb, label);
|
|
if scene::detect(&mut gb) == scene::Scene::Overworld {
|
|
println!("\n**{label} left it.**");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
None => println!(
|
|
"\nNever stuck in one non-overworld scene for {stuck_after} frames in {budget}."
|
|
),
|
|
}
|
|
}
|