From 320b05ecee1cc122ffd24616c2caa641232f695c Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 03:17:20 +0000 Subject: [PATCH 01/13] symbols: the four WRAM names the whole-map grid reads resolve_wram.py is a second reading of the disassembly beside gen_symbols.py: it walks ram/wram.asm with a cursor that is only ever live while anchored on an address symbols.rs already pins, re-derives 40 of the 63 it carries with no disagreement, and emits an address only when a pinned one after it agrees too. wOverworldMap, wCurMapTileset, wTilesetBank and wTilesetBlocksPtr come out of that pass; no address is hand-written and nothing else in the table moves. --- .../flybrain-gb/src/pokemon_red/symbols.rs | 4 + services/flysim/tools/gen_symbols.py | 18 + services/flysim/tools/resolve_wram.py | 402 ++++++++++++++++++ 3 files changed, 424 insertions(+) create mode 100644 services/flysim/tools/resolve_wram.py diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs index 3427f62..30119fa 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/symbols.rs @@ -16,6 +16,7 @@ pub mod ram { pub const wSpriteStateData1: u16 = 0xc100; // 49408 pub const wSpriteStateData2: u16 = 0xc200; // 49664 pub const wTileMap: u16 = 0xc3a0; // 50080 + pub const wOverworldMap: u16 = 0xc6e8; // 50920 pub const wTopMenuItemY: u16 = 0xcc24; // 52260 pub const wTopMenuItemX: u16 = 0xcc25; // 52261 pub const wCurrentMenuItem: u16 = 0xcc26; // 52262 @@ -59,6 +60,7 @@ pub mod ram { pub const wCurMap: u16 = 0xd35e; // 54110 pub const wYCoord: u16 = 0xd361; // 54113 pub const wXCoord: u16 = 0xd362; // 54114 + pub const wCurMapTileset: u16 = 0xd367; // 54119 pub const wCurMapHeight: u16 = 0xd368; // 54120 pub const wCurMapWidth: u16 = 0xd369; // 54121 pub const wCurMapConnections: u16 = 0xd370; // 54128 @@ -68,6 +70,8 @@ pub mod ram { pub const wSignCoords: u16 = 0xd4b1; // 54449 pub const wSignTextIDs: u16 = 0xd4d1; // 54481 pub const wNumSprites: u16 = 0xd4e1; // 54497 + pub const wTilesetBank: u16 = 0xd52b; // 54571 + pub const wTilesetBlocksPtr: u16 = 0xd52c; // 54572 pub const wTilesetCollisionPtr: u16 = 0xd530; // 54576 pub const wTilesetTalkingOverTiles: u16 = 0xd532; // 54578 pub const wNumHoFTeams: u16 = 0xd5a2; // 54690 diff --git a/services/flysim/tools/gen_symbols.py b/services/flysim/tools/gen_symbols.py index 60e5ab5..71ce56c 100644 --- a/services/flysim/tools/gen_symbols.py +++ b/services/flysim/tools/gen_symbols.py @@ -182,6 +182,24 @@ EXTRA_RAM = ( # standing behind a desk can be talked to at all: none of the four tiles around either of # them is walkable. 'wTilesetTalkingOverTiles', + # The whole-map walkability grid (docs/design/macros.md section 15). + # + # LoadTileBlockMap copies the loaded map out of its ROM bank into wOverworldMap + # as one byte per 4x4-tile block, in rows of wCurMapWidth + MAP_BORDER * 2 with + # the map itself three rows and three columns in, so the blocks of the current + # map are a WRAM read rather than a ROM one. wCurMapTileset keys the tile-pair + # collision lists (CheckForTilePairCollisions). wTilesetBank and + # wTilesetBlocksPtr are the tileset header's blockset: 16 bytes per block id, + # four rows of four tile ids, which DrawTileBlock indexes exactly that way -- + # and it is not in bank 0, which is why the memory seam grew a bank-aware ROM + # read for it. All four are resolved the same way every other name here is; + # services/flysim/tools/resolve_wram.py is the second reading of them, from + # ram/wram.asm at this commit, and it re-derives 40 of the addresses this table + # already carries before it emits one of these four. + 'wOverworldMap', + 'wCurMapTileset', + 'wTilesetBank', + 'wTilesetBlocksPtr', ) diff --git a/services/flysim/tools/resolve_wram.py b/services/flysim/tools/resolve_wram.py new file mode 100644 index 0000000..4e67b06 --- /dev/null +++ b/services/flysim/tools/resolve_wram.py @@ -0,0 +1,402 @@ +"""Resolve WRAM symbol addresses out of a pret/pokered checkout, and verify the pinned ones. + +`gen_symbols.py` owns `symbols.rs` and takes its addresses from the prototype's +already-generated table; it refuses a hand-written address. Section 15 of +`docs/design/macros.md` needs four symbols that table does not carry +(`wOverworldMap`, `wCurMapTileset`, `wTilesetBank`, `wTilesetBlocksPtr`), and a +hand-computed address is exactly what neither script will take. So this tool +does the same job the disassembly's own `.sym` would, from `ram/wram.asm`: + +* it walks the file in order, keeping a byte cursor; +* the cursor is **only ever live while it is anchored**: it is set from a symbol + `symbols.rs` already pins, and any declaration form this tool cannot evaluate + exactly kills it until the next pinned symbol revives it. An unanchored region + can therefore not produce a number at all, rather than producing a wrong one; +* every pinned symbol it reaches while live is checked against `symbols.rs`, and + a single disagreement is a failure with no output. A resolved symbol is only + reported when the run also re-derived the *next* pinned symbol after it, so + each answer is bracketed by two addresses the table already carries. + +Usage (read-only; `--emit` rewrites the `ram` block of symbols.rs in place): + + python3 services/flysim/tools/resolve_wram.py --pokered [--emit] +""" + +from __future__ import annotations + +import argparse +import math +import re +from pathlib import Path + +SYMBOLS = ( + Path(__file__).resolve().parents[1] / 'crates/flybrain-gb/src/pokemon_red/symbols.rs' +) + +#: The symbols this run is for, with why the macro layer needs each one. Every +#: one is checked to be bracketed by two pinned addresses before it is emitted. +WANTED = { + # The current map's block ids, as LoadTileBlockMap copies them out of the + # map's ROM bank: rows of (width + MAP_BORDER * 2) bytes, the map itself + # offset by three rows and three columns. This is what makes a whole-map + # walkability grid a WRAM read rather than a ROM one. + 'wOverworldMap': 'the loaded map, one byte per 4x4-tile block', + # Which tileset the loaded map uses: the tile-pair collision lists are keyed + # by it (CheckForTilePairCollisions). + 'wCurMapTileset': 'the loaded map tileset id', + # The tileset header's blockset: a bank byte and a little-endian pointer at + # 16 bytes per block, four rows of four tile ids (DrawTileBlock). The bank is + # not bank 0, so this is the read the memory seam grew a bank for. + 'wTilesetBank': 'the ROM bank the blockset lives in', + 'wTilesetBlocksPtr': 'blocks to tiles, 16 bytes per block', +} + + +def pinned(text: str) -> dict[str, int]: + """Every address `symbols.rs` carries today, by symbol name.""" + return { + name: int(value, 16) + for name, value in re.findall(r'pub const (w\w+): u16 = 0x([0-9a-f]{4});', text) + } + + +def constants(root: Path) -> dict[str, int]: + """Every `DEF NAME EQU ` the declarations below need. + + Resolved by repeated passes rather than in one, because the decomp defines + constants in terms of each other (`SURROUNDING_WIDTH EQU SCREEN_BLOCK_WIDTH * + BLOCK_WIDTH`). A name whose expression never becomes evaluable is simply left + out, which kills the cursor at any declaration that uses it. + """ + pending: dict[str, str] = {} + sources = sorted((root / 'constants').glob('*.asm')) + sorted( + (root / 'constants').glob('*.inc') + ) + for path in sources: + for name, value in re.findall( + r'^\s*(?:DEF|def)\s+(\w+)\s+(?:EQU|equ)\s+([^;\n]+)', path.read_text(), re.M + ): + pending.setdefault(name, value.strip()) + out: dict[str, int] = {} + while pending: + progressed = False + for name in list(pending): + try: + out[name] = size_of(pending[name], out) + except Unevaluable: + continue + del pending[name] + progressed = True + if not progressed: + break + return out + + +def number(token: str) -> int: + token = token.strip() + if token.startswith('$'): + return int(token[1:], 16) + if token.startswith('%'): + return int(token[1:], 2) + return int(token, 10) + + +class Unevaluable(Exception): + """A declaration this tool will not guess the size of.""" + + +def size_of(expression: str, known: dict[str, int]) -> int: + """Bytes in a `ds`/`EQU` expression: the decomp's own arithmetic, nothing else. + + `$`/`%` literals and the constants resolved so far are substituted, the + `tiles` unit is a factor of sixteen, and what is left must be plain + arithmetic over integers -- so a name this tool has not resolved, a function + call or anything else raises [`Unevaluable`] instead of becoming a guess. + """ + expression = expression.split(';')[0].strip() + if not expression: + raise Unevaluable('empty') + scale = 1 + if expression.endswith('tiles'): + expression = expression[: -len('tiles')].strip() + scale = 16 # one 8x8 2bpp tile is 16 bytes + def substitute(match: re.Match[str]) -> str: + token = match.group(0) + if token[0] in '$%': + return str(number(token)) + if token in known: + return str(known[token]) + raise Unevaluable(token) + substituted = re.sub(r'\$[0-9A-Fa-f_]+|%[01_]+|[A-Za-z_]\w*', substitute, expression) + if not re.fullmatch(r'[\d\s()+\-*/]+', substituted): + raise Unevaluable(expression) + try: + value = eval(substituted, {'__builtins__': {}}, {}) # arithmetic only, checked above + except (SyntaxError, ZeroDivisionError, TypeError) as error: + raise Unevaluable(expression) from error + if not isinstance(value, int): + raise Unevaluable(expression) + return value * scale + + +def macro_sizes(root: Path, known: dict[str, int]) -> dict[str, int]: + """Sizes of the RAM struct macros, counted from their own declarations.""" + out: dict[str, int] = {} + for path in sorted((root / 'macros').glob('*.asm')): + text = path.read_text() + for match in re.finditer(r'^MACRO\??\s+(\w+)\n(.*?)^ENDM', text, re.M | re.S): + name, body = match.group(1), match.group(2) + total = 0 + for line in body.splitlines(): + line = line.split(';')[0].strip() + # A struct macro labels each field with its argument + # (`\\1YCoord:: db`), so the label is stripped and the + # declaration after it is what reserves the bytes. + line = re.sub(r'^[\\\w{}:.\d]+::\s*', '', line) + if not line or line.startswith(('IF', 'ELSE', 'ENDC', 'ASSERT')): + continue + if re.match(r'^\w+::?$', line): + continue + if line.startswith('db'): + total += max(1, len([part for part in line[2:].split(',') if part.strip()])) + elif line.startswith('dw'): + total += 2 * max(1, len([p for p in line[2:].split(',') if p.strip()])) + elif line.startswith('ds '): + try: + total += size_of(line[3:], known) + except Unevaluable: + total = None + break + else: + total = None + break + if total is not None: + out[name] = total + return out + + +def walk( + root: Path, table: dict[str, int], known: dict[str, int], verbose: bool = False +) -> tuple[dict[str, int], list[str], int]: + """Resolve every symbol of wram.asm the anchored cursor can reach exactly.""" + macros = macro_sizes(root, known) + lines = (root / 'ram/wram.asm').read_text().splitlines() + cursor: int | None = None + resolved: dict[str, int] = {} + # Symbols counted since the last pinned anchor, held back until a pinned + # address after them agrees. + pending_run: dict[str, int] = {} + order: list[str] = [] + checked = 0 + problems: list[str] = [] + lost: list[str] = [] + # UNION frames: (start address, widest branch so far, whether a branch was + # unevaluable). A frame whose start is unknown, or any one of whose branches this + # tool could not size, poisons the whole union: what the section advances by is + # the widest branch, so one branch it cannot measure means it cannot measure any. + unions: list[tuple[int | None, int, bool]] = [] + index = 0 + while index < len(lines): + raw = lines[index] + index += 1 + line = raw.split(';')[0].strip() + if not line: + continue + if line.startswith('SECTION'): + # A section's address comes from the linker, not the source. WRAM0 + # sections are packed in declaration order, so the cursor carries on + # -- and the next address `symbols.rs` pins is what tests that: any + # padding the linker inserted would land as a MISMATCH and this tool + # would emit nothing. + continue + if line == 'UNION': + unions.append((cursor, 0, False)) + continue + if line == 'NEXTU': + if not unions: + cursor = None + continue + start, widest, poisoned = unions.pop() + poisoned = poisoned or start is None or cursor is None + if not poisoned: + widest = max(widest, cursor - start) + unions.append((start, widest, poisoned)) + cursor = start + continue + if line == 'ENDU': + if not unions: + cursor = None + continue + start, widest, poisoned = unions.pop() + if poisoned or start is None or cursor is None: + cursor = None + continue + cursor = start + max(widest, cursor - start) + continue + if line.startswith(('FOR ', 'REPT ')): + # Evaluate the body only when every line of it has a known size. + head = line.split(None, 1)[1] + # `REPT n`, `FOR v, stop` and `FOR v, start, stop`: rgbasm's own + # three forms, and the third iterates stop - start times. + arguments = [part.strip() for part in head.split(',')] + count_token = arguments[-1] + start_token = arguments[-2] if line.startswith('FOR ') and len(arguments) == 3 else None + body: list[str] = [] + depth = 1 + while index < len(lines): + inner = lines[index].split(';')[0].strip() + index += 1 + if inner.startswith(('FOR ', 'REPT ')): + depth += 1 + if inner == 'ENDR': + depth -= 1 + if depth == 0: + break + body.append(inner) + try: + count = size_of(count_token, known) + if start_token is not None: + count -= size_of(start_token, known) + per = 0 + for inner in body: + per += declaration_size(inner, known, macros) + if cursor is not None: + cursor += count * per + except Unevaluable: + if cursor is not None: + lost.append(f'line {index}: {line}') + cursor = None + pending_run.clear() + continue + label = re.match(r'^(w\w+)::', line) + if label is not None: + name = label.group(1) + if name in table: + if cursor is not None and cursor != table[name]: + problems.append( + f'{name}: wram.asm gives ${cursor:04x}, symbols.rs pins ${table[name]:04x}' + ) + pending_run.clear() + elif cursor is not None: + checked += 1 + # Everything counted since the last anchor is now bracketed + # by two addresses the table already carries. + resolved.update(pending_run) + order.extend(pending_run) + pending_run.clear() + else: + if verbose: + lost.append(f'cold anchor at {name} (line {index})') + pending_run.clear() + cursor = table[name] + elif cursor is not None: + pending_run[name] = cursor + line = line[label.end() :].strip() + if not line: + continue + if line.endswith('::') or re.fullmatch(r'\.\w+', line): + continue + try: + if cursor is not None: + cursor += declaration_size(line, known, macros) + except Unevaluable: + if cursor is not None: + lost.append(f'line {index}: {line}') + cursor = None + pending_run.clear() + if verbose: + for entry in lost: + print(f'UNEVALUABLE {entry}') + return resolved, problems, checked + + +def declaration_size(line: str, known: dict[str, int], macros: dict[str, int]) -> int: + """Bytes one declaration line reserves, or [`Unevaluable`].""" + line = line.split(';')[0].strip() + if not line or line.endswith('::') or line.startswith(('ENDSECTION', 'ASSERT', 'ENDR')): + return 0 + # Any label, including the `{02d:n}` interpolations a FOR body labels its + # iterations with; what reserves the bytes is the declaration after it. + line = re.sub(r'^[\\\w{}:.\d]+::\s*', '', line) + if not line: + return 0 + if line == 'db': + return 1 + if line == 'dw': + return 2 + if line.startswith('db '): + return max(1, len([part for part in line[3:].split(',') if part.strip()])) + if line.startswith('dw '): + return 2 * max(1, len([part for part in line[3:].split(',') if part.strip()])) + if line.startswith('ds '): + return size_of(line[3:], known) + if line.startswith('flag_array '): + return math.ceil(size_of(line[len('flag_array ') :], known) / 8) + head = line.split(None, 1)[0] + if head in macros: + return macros[head] + raise Unevaluable(line) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--pokered', type=Path, required=True) + parser.add_argument('--verbose', action='store_true', help='name every declaration it will not size') + parser.add_argument('--emit', action='store_true', help='write the new addresses into symbols.rs') + args = parser.parse_args() + + text = SYMBOLS.read_text() + commit = re.search(r'POKERED_COMMIT: &str = "([0-9a-f]{40})"', text) + if commit is None: + raise SystemExit('symbols.rs carries no POKERED_COMMIT') + head = (args.pokered / '.git/HEAD').read_text().strip() + if head.startswith('ref:'): + head = (args.pokered / '.git' / head.split()[1]).read_text().strip() + if head != commit.group(1): + raise SystemExit( + f'checkout is at {head}, symbols.rs pins {commit.group(1)}: two revisions of the ' + 'disassembly renumber RAM relative to each other' + ) + + table = pinned(text) + known = constants(args.pokered) + resolved, problems, checked = walk(args.pokered, table, known, args.verbose) + if problems: + for problem in problems: + print(f'MISMATCH {problem}') + raise SystemExit('the walk disagrees with symbols.rs; nothing emitted') + print(f'{checked} of {len(table)} pinned addresses re-derived from wram.asm, no disagreement') + + missing = [name for name in WANTED if name not in resolved] + if missing: + raise SystemExit(f'unanchored, so not resolved: {", ".join(missing)}') + for name in WANTED: + print(f'{name} = ${resolved[name]:04x} ({WANTED[name]})') + + if not args.emit: + return + block = re.search(r'(pub mod ram \{\n)(.*?)(\n\}\n)', text, re.S) + if block is None: + raise SystemExit('no ram block in symbols.rs') + rows = [] + for row in block.group(2).splitlines(): + name = re.match(r'\s*pub const (w\w+): u16 = 0x([0-9a-f]{4});', row) + if name is None: + continue + rows.append((int(name.group(2), 16), name.group(1))) + for name in WANTED: + if name not in table: + rows.append((resolved[name], name)) + rows = sorted(set(rows)) + width = max(len(name) for _, name in rows) + body = '\n'.join( + f' pub const {name}: u16 = 0x{address:04x};'.ljust(width + 31) + + f'// {address}' + for address, name in rows + ) + SYMBOLS.write_text(text[: block.start(2)] + body + text[block.end(2) :]) + print(f'symbols.rs: {len(rows)} addresses written') + + +if __name__ == '__main__': + main() From d75b7320ea30e8c815e8f5c877c17c911635e413 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 03:32:19 +0000 Subject: [PATCH 02/13] seam: a bank-aware ROM read beside the bus read MemoryReader::read8 reads the CPU bus, where banks 1 and up are whichever bank the cartridge last switched to, so a table in another bank could only be read by writing the mapper register -- and the joypad is the only write this workspace makes into a running game. read_rom takes a bank and a CPU address and reads the cartridge image the process already holds instead. Defaulted to None, so every reader without a cartridge behind it narrows rather than guesses. --- .../flysim/crates/flybrain-gb/src/adapter.rs | 25 +++++++++++++ .../flysim/crates/flybrain-gb/src/emulator.rs | 35 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/services/flysim/crates/flybrain-gb/src/adapter.rs b/services/flysim/crates/flybrain-gb/src/adapter.rs index fb3f28a..15d8456 100644 --- a/services/flysim/crates/flybrain-gb/src/adapter.rs +++ b/services/flysim/crates/flybrain-gb/src/adapter.rs @@ -20,12 +20,37 @@ use crate::ratchet::RecoveryPolicy; /// equivalent of the prototype's `MemoryReader` interface. pub trait MemoryReader { fn read8(&mut self, address: u16) -> u8; + + /// One byte of a ROM bank, by bank number rather than off the CPU bus. + /// + /// [`MemoryReader::read8`] reads the bus, where banks 1 and up are whichever + /// bank the cartridge's last switch left mapped -- so a table in bank 3 is + /// unreadable through it, and the only way to make it readable would be to + /// *write* the mapper's bank register. The joypad is the only write this + /// workspace makes into a running game (`docs/design/macros.md` section 12), + /// so this reads the cartridge image the process already holds instead: the + /// same bytes, addressed the way the disassembly addresses them. + /// + /// `address` is a CPU address: below `$4000` it is bank 0 whatever `bank` + /// says, and `$4000..$8000` is the banked window. Anything else, and any + /// offset past the end of the image, is `None`. + /// + /// The default is `None`: a reader with no cartridge behind it cannot answer, + /// and every caller of this is written to narrow rather than guess when it + /// does not (`docs/design/macros-wram.md`, the whole-map grid). + fn read_rom(&mut self, _bank: u8, _address: u16) -> Option { + None + } } impl MemoryReader for &mut dyn MemoryReader { fn read8(&mut self, address: u16) -> u8 { (**self).read8(address) } + + fn read_rom(&mut self, bank: u8, address: u16) -> Option { + (**self).read_rom(bank, address) + } } /// One reward payout in one frame. diff --git a/services/flysim/crates/flybrain-gb/src/emulator.rs b/services/flysim/crates/flybrain-gb/src/emulator.rs index 1b6f7eb..dd9b3e3 100644 --- a/services/flysim/crates/flybrain-gb/src/emulator.rs +++ b/services/flysim/crates/flybrain-gb/src/emulator.rs @@ -26,6 +26,10 @@ pub const CPU_TICKS_PER_SECOND: u64 = 4_194_304; /// so there is no `NEW_FRAME` event to wait for yet. const MAX_FRAME_ATTEMPTS: u32 = 120; +/// Bytes in one ROM bank (`$4000`), which is how [`Emulator::read_rom_bank`] +/// turns a bank number and a CPU address into an offset in the cartridge image. +const ROM_BANK_BYTES: usize = 0x4000; + /// Audio frequency flysim runs the emulator at. binjgb resamples internally to /// whatever is requested, so this is only a default. pub const DEFAULT_AUDIO_FREQUENCY: u32 = 48_000; @@ -135,6 +139,13 @@ impl FrameCache { /// thread. It is deliberately not `Sync`. pub struct Emulator { gb: *mut ffi::FlyGb, + /// The cartridge image, for [`MemoryReader::read_rom`]. + /// + /// The shim owns its own padded copy behind the handle and does not hand it + /// back, so this is a second one. It is read-only from here: nothing in this + /// workspace writes a ROM byte, and the bank-addressed read is the only + /// reason it is kept. + rom: std::sync::Arc<[u8]>, rom_sha256: [u8; 32], audio_frequency: u32, /// Raw binjgb samples drained since the last [`Emulator::take_audio`]. @@ -167,6 +178,7 @@ impl Emulator { debug_assert_eq!(unsafe { ffi::fly_gb_frame_buffer_size() }, FRAMEBUFFER_LEN); Ok(Self { gb, + rom: rom.into(), rom_sha256: Sha256::digest(rom).into(), audio_frequency, pending_audio: Vec::new(), @@ -242,6 +254,25 @@ impl Emulator { unsafe { ffi::fly_gb_read_mem(self.gb, address) } } + /// One byte of ROM bank `bank`, from the cartridge image rather than the bus. + /// + /// `address` is a CPU address: `$0000..$4000` is bank 0 whatever `bank` says + /// (that is what "always mapped" means) and `$4000..$8000` is the banked + /// window. `None` for any other address and for an offset past the end of the + /// image, which is what a bank a smaller cartridge does not have reads as. + /// No bank register is written and the emulator's state does not move: this + /// is a read of bytes the process already owns. + pub fn read_rom_bank(&self, bank: u8, address: u16) -> Option { + let offset = match address { + 0x0000..=0x3fff => usize::from(address), + 0x4000..=0x7fff => { + usize::from(bank) * ROM_BANK_BYTES + usize::from(address) - ROM_BANK_BYTES + } + _ => return None, + }; + self.rom.get(offset).copied() + } + /// Sample rate of the raw buffer, as binjgb configured it. pub fn audio_frequency(&self) -> u32 { self.audio_frequency @@ -364,6 +395,10 @@ impl MemoryReader for Emulator { fn read8(&mut self, address: u16) -> u8 { self.read_wram(address) } + + fn read_rom(&mut self, bank: u8, address: u16) -> Option { + self.read_rom_bank(bank, address) + } } #[cfg(test)] From 68228ef2a1b96494dcb6d192923a50d43a40083a Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 03:32:35 +0000 Subject: [PATCH 03/13] gb: decode the whole current map into a walkability grid The walkable predicate answered for the ten-by-nine screen window and Unknown everywhere else. MapGrid is the same rule over every tile of the loaded map: block ids out of wOverworldMap, a blocks-to-tiles read of the tileset header s blockset through the new bank-aware ROM read, the tileset s collision list as before, and the TilePairCollisionsLand values as directed walls both ways. The reader checks itself before it answers: the decode is compared against the window predicate over the player s own neighbourhood, and a frame where the window can answer for none of it -- a battle, a text box, a frame mid-warp -- is refused, because wOverworldMap shares its bytes with the picture buffer. Every refusal is named (GridRefusal) and leaves the window predicate in charge. Cached per map id and size, so a map is decoded once on arrival rather than once per question, and owned beside the session ledgers: never checkpointed. --- .../flybrain-gb/src/pokemon_red/fake_wram.rs | 82 +++++- .../src/pokemon_red/macros/cartridge.rs | 19 +- .../src/pokemon_red/macros/driver.rs | 24 +- .../src/pokemon_red/macros/state.rs | 173 ++++++++++++ .../flybrain-gb/src/pokemon_red/mapgrid.rs | 246 ++++++++++++++++++ .../src/pokemon_red/mapgrid/tests.rs | 161 ++++++++++++ .../crates/flybrain-gb/src/pokemon_red/mod.rs | 8 + .../flybrain-gb/src/pokemon_red/state.rs | 214 ++++++++++++++- .../src/pokemon_red/state/tests.rs | 141 +++++++++- 9 files changed, 1051 insertions(+), 17 deletions(-) create mode 100644 services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs create mode 100644 services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs index 20daad3..02e58ac 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs @@ -32,12 +32,22 @@ pub const WALL_TILE: u8 = 0x60; pub struct Wram { bytes: Vec, + /// Fake cartridge banks, for the one read that needs one. + /// + /// A bank nothing has written answers `None`, which is what a seam with no cartridge behind + /// it answers and what the whole-map grid has to narrow on + /// (`docs/design/macros.md` section 15). + rom: std::collections::HashMap<(u8, u16), u8>, } impl MemoryReader for Wram { fn read8(&mut self, address: u16) -> u8 { self.bytes[address as usize] } + + fn read_rom(&mut self, bank: u8, address: u16) -> Option { + self.rom.get(&(bank, address)).copied() + } } impl Default for Wram { @@ -49,7 +59,7 @@ impl Default for Wram { impl Wram { /// All zero: the title screen, since nothing has set the game-timer bit. pub fn new() -> Self { - Self { bytes: vec![0; 0x1_0000] } + Self { bytes: vec![0; 0x1_0000], rom: std::collections::HashMap::new() } } pub fn set(&mut self, address: u16, value: u8) -> &mut Self { @@ -331,6 +341,76 @@ impl Wram { self } + + /// The ROM bank and address this fake keeps a tileset's blockset at. + /// + /// Any non-zero bank: the point of the number is that it is *not* bank 0, because a bank the + /// CPU bus does not have mapped is the whole reason the seam grew + /// [`MemoryReader::read_rom`] (`docs/design/macros.md` section 15). + pub const BLOCKSET_BANK: u8 = 0x11; + pub const BLOCKSET_BASE: u16 = 0x4000; + + /// Which tileset the loaded map uses, for the tile-pair collision lists. + pub fn tileset(&mut self, id: u8) -> &mut Self { + self.set(ram::wCurMapTileset, id) + } + + /// A tileset header's blockset, written where a cartridge keeps one: sixteen tile ids per + /// block, in block-id order, in a ROM bank that is not bank 0. + pub fn blockset(&mut self, blocks: &[[u8; 16]]) -> &mut Self { + for (id, block) in blocks.iter().enumerate() { + for (offset, tile) in block.iter().enumerate() { + let address = Self::BLOCKSET_BASE + (id * 16 + offset) as u16; + self.rom.insert((Self::BLOCKSET_BANK, address), *tile); + } + } + self.set(ram::wTilesetBank, Self::BLOCKSET_BANK) + .set(ram::wTilesetBlocksPtr, (Self::BLOCKSET_BASE & 0xff) as u8) + .set(ram::wTilesetBlocksPtr + 1, (Self::BLOCKSET_BASE >> 8) as u8) + } + + /// The loaded map's block ids, as `LoadTileBlockMap` leaves them in `wOverworldMap`: rows of + /// `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself three rows and three columns in. + /// + /// `blocks` is row-major and `wCurMapWidth * wCurMapHeight` long; the border is left as + /// whatever it was, exactly as a map with no connections leaves it. + pub fn map_blocks(&mut self, blocks: &[u8]) -> &mut Self { + let width = u16::from(self.peek(ram::wCurMapWidth)); + let height = u16::from(self.peek(ram::wCurMapHeight)); + let border = crate::pokemon_red::mapgrid::MAP_BORDER as u16; + let stride = width + border * 2; + for row in 0..height { + for column in 0..width { + let index = usize::from(row * width + column); + let Some(block) = blocks.get(index) else { continue }; + self.set(ram::wOverworldMap + (row + border) * stride + column + border, *block); + } + } + self + } + + /// Write the screen buffer so that it agrees with the block data, tile for tile. + /// + /// The grid reader cross-checks its decode against the window predicate before it trusts it + /// ([`crate::pokemon_red::state::map_grid`]), and on a cartridge the two agree because they + /// are two readings of one map. This is that agreement in a fake: every map tile inside the + /// ten-by-nine window gets the tile id the blockset gives it, and the tiles outside it keep + /// whatever the screen held, which is what makes them `Unknown` to the window and answerable + /// only by the grid. + pub fn screen_from_blocks(&mut self, blocks: &[u8], blockset: &[[u8; 16]]) -> &mut Self { + let width = usize::from(self.peek(ram::wCurMapWidth)); + let height = usize::from(self.peek(ram::wCurMapHeight)); + for y in 0..height * 2 { + for x in 0..width * 2 { + let Some(block) = blocks.get((y / 2) * width + (x / 2)) else { continue }; + let Some(tiles) = blockset.get(usize::from(*block)) else { continue }; + let tile = tiles[(y % 2) * 2 * 4 + (x % 2) * 2]; + self.map_tile(x as u8, y as u8, tile); + } + } + self + } + /// A playable overworld frame: Red's ground floor, the fly standing where a cold boot's walk /// out of the bedroom lands it, every tile a wall until a test opens one. pub fn overworld() -> Self { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs index 4f3f8a1..96e49cd 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/cartridge.rs @@ -24,7 +24,7 @@ use crate::adapter::PlaceKind; use super::geography::Amenity; -use super::state::{Facing, GameState}; +use super::state::{Facing, GameState, MapGrid}; /// `constants/pokemon_data_constants.asm`: `PARTY_LENGTH`, how many Pokémon fit in the party. /// @@ -315,6 +315,23 @@ pub trait MacroState: GameState { false } + /// The whole loaded map's walkability, when the cartridge's tables can be decoded. + /// + /// `docs/design/macros.md` section 15, the operator 2026-09-22: "the frontier and warp macros + /// need to be map aware: A* over walkable tiles." [`GameState::walkable`] answers for the + /// ten-by-nine window of the screen buffer and [`super::state::Walkable::Unknown`] for + /// everything else, so before this every walk planned through guesses, re-planned at every + /// window edge, and `GO FRONTIER` aimed at whatever unstood ground happened to be on screen. + /// + /// The default is `None`, which is this trait's usual narrowing and here it is also the + /// documented fallback: [`super::path::route`] and [`super::path::frontier`] use the window + /// predicate when the grid is absent, exactly as they did before, and + /// [`crate::pokemon_red::state::GridRefusal`] is what says why it is absent on a frame the + /// reader could not decode. + fn map_grid(&mut self) -> Option> { + None + } + /// Whether this run has already been into `area`'s mart or Pokémon Center. /// /// Section 13's `areaVisited(kind, area)`: **one visit per area per run**, so the errand is diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs index 926afdb..6eca02f 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/driver.rs @@ -16,6 +16,7 @@ use crate::macros::{ MacroPalette, Observed, Outcome, PaletteMode, RunLedger, SLOTS, SceneId, SlotBinding, Started, }; +use super::super::mapgrid::MapGrids; use super::super::state::PokeState; use super::cartridge::{Areas, MacroState, Pushed, Stood, Talked, Targets, Tile}; use super::geography; @@ -70,6 +71,14 @@ pub struct PokemonPalette { /// and walks the fly down on every frame it stands there until the Pokédex exists, and a map /// does not stop being like that ten brain minutes later. pushed: Pushed, + /// The decoded walkability of the map the fly is on (`docs/design/macros.md` section 15). + /// + /// Owned here beside the session ledgers because it is the same shape of thing: built from + /// the cartridge, valid for as long as the map is loaded, dropped on arrival somewhere else, + /// and never checkpointed -- a restored run decodes the map again on its first overworld + /// frame. It is a *cache* rather than a ledger: nothing about the run is in it, only what the + /// cartridge's own tables say about the ground. + grids: MapGrids, /// The brain clock of the frame being decided, from [`MacroPalette::clock`]. /// /// The blocked ledger is a *window*, so it needs the same clock the loop publishes rather @@ -93,6 +102,7 @@ impl PokemonPalette { stood: Stood::default(), areas: Areas::default(), pushed: Pushed::default(), + grids: MapGrids::default(), now_ms: 0.0, } } @@ -158,11 +168,13 @@ impl MacroPalette for PokemonPalette { fn observe(&mut self, memory: &mut dyn MemoryReader, ledger: &dyn RunLedger) -> Observed { let (scene, bindings, standing) = { - let Self { machine, mode, palette: cached, talked, targets, stood, areas, pushed, .. } = - self; + let Self { + machine, mode, palette: cached, talked, targets, stood, areas, pushed, grids, .. + } = self; let mut state = PokeState::with_ledgers( memory, ledger, talked, targets, &*stood, &*areas, &*pushed, - ); + ) + .caching_grid(grids); // `GameState::scene` is `pokemon_red::scene::detect` over the same reader, so the // palette and the scene the feed reports cannot disagree about which frame they are // for. @@ -227,7 +239,8 @@ impl MacroPalette for PokemonPalette { &self.stood, &self.areas, &self.pushed, - ); + ) + .caching_grid(&mut self.grids); self.machine.start(&palette, slot, &mut state) }; let started = match begun { @@ -267,7 +280,8 @@ impl MacroPalette for PokemonPalette { &self.stood, &self.areas, &self.pushed, - ); + ) + .caching_grid(&mut self.grids); self.machine.step(&mut state) }; // A macro that just finished may have been the press that talked to something; the ledger diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs index 02b5695..a624d03 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/state.rs @@ -392,6 +392,179 @@ impl Walkable { } } +/// Every tile of one map, answered: `docs/design/macros.md` section 15. +/// +/// [`GameState::walkable`] answers about the ten-by-nine window of the screen buffer and +/// [`Walkable::Unknown`] elsewhere. This is the same predicate over the whole loaded map, decoded +/// from the block and collision tables the cartridge has loaded +/// ([`crate::pokemon_red::mapgrid`]), so a walk can be planned once instead of guessed at and +/// re-planned at every window edge. +/// +/// It carries three things and no policy at all: +/// +/// - the walkability of every tile, in map-tile coordinates -- the same unit the player's +/// coordinates, the warp table and the sign table are in; +/// - the tile id each answer came from, which is what the cross-check against the window +/// predicate compares and what the tile-pair rules are keyed on; +/// - **directed walls**: one step out of one tile in one direction that the cartridge refuses +/// although both tiles are passable. Pokered has two such rules and the tile-pair lists are the +/// one that can be read ahead of time; a ledge is already [`Walkable::No`] in the collision +/// list, and a person in the way is the sprite list's answer, not the ground's. +/// +/// Nothing here is a fact about the run: no ledger, no visit, no target. Those stay where they +/// are, session state in the executor layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MapGrid { + map: u8, + width: u8, + height: u8, + /// Row-major, `width * height`, [`Walkable::Unknown`] until [`MapGrid::set`] says otherwise. + tiles: Vec, + /// Row-major screen tile id per map tile, `None` where the tile was never decoded. + ids: Vec>, + /// Row-major bitmask of the directions a step out of this tile is refused in + /// ([`MapGrid::wall`]). + walls: Vec, +} + +impl MapGrid { + /// An all-[`Walkable::Unknown`] grid of this size, for a decoder to fill in. + pub fn new(map: u8, width: u8, height: u8) -> Self { + let cells = usize::from(width) * usize::from(height); + Self { + map, + width, + height, + tiles: vec![Walkable::Unknown; cells], + ids: vec![None; cells], + walls: vec![0; cells], + } + } + + /// Which map this grid is of. A grid is only ever valid for the loaded map. + pub fn map(&self) -> u8 { + self.map + } + + pub fn width(&self) -> u8 { + self.width + } + + pub fn height(&self) -> u8 { + self.height + } + + fn index(&self, x: u8, y: u8) -> Option { + (x < self.width && y < self.height) + .then(|| usize::from(y) * usize::from(self.width) + usize::from(x)) + } + + /// Record a decoded tile: its screen tile id and what the collision list makes of it. + pub fn set(&mut self, x: u8, y: u8, tile: u8, walkable: Walkable) { + if let Some(index) = self.index(x, y) { + self.tiles[index] = walkable; + self.ids[index] = Some(tile); + } + } + + /// Record that the cartridge refuses the step out of `(x, y)` in `facing`. + pub fn wall(&mut self, x: u8, y: u8, facing: Facing) { + if let Some(index) = self.index(x, y) { + self.walls[index] |= wall_bit(facing); + } + } + + /// Whether the player could stand on this tile. Off the map is [`Walkable::No`], which is the + /// window predicate's own answer for it. + pub fn walkable(&self, x: u8, y: u8) -> Walkable { + match self.index(x, y) { + None => Walkable::No, + Some(index) => self.tiles[index], + } + } + + /// The screen tile id this tile's answer came from, or `None` for a tile never decoded. + pub fn tile_id(&self, x: u8, y: u8) -> Option { + self.index(x, y).and_then(|index| self.ids[index]) + } + + /// Whether the step out of `(x, y)` in `facing` is one the cartridge refuses. + pub fn walled(&self, x: u8, y: u8, facing: Facing) -> bool { + self.index(x, y).is_some_and(|index| self.walls[index] & wall_bit(facing) != 0) + } + + /// How many tiles of the map the player could stand on. + pub fn walkable_count(&self) -> usize { + self.tiles.iter().filter(|tile| tile.is_walkable()).count() + } + + /// How many tiles the map has that were never decoded, i.e. [`Walkable::Unknown`]. + pub fn unknown_count(&self) -> usize { + self.tiles.iter().filter(|tile| matches!(tile, Walkable::Unknown)).count() + } + + /// Every walkable tile, in row-major order, as `(x, y)`. + pub fn walkable_tiles(&self) -> Vec<(u8, u8)> { + let mut out = Vec::with_capacity(self.walkable_count()); + for y in 0..self.height { + for x in 0..self.width { + if self.walkable(x, y).is_walkable() { + out.push((x, y)); + } + } + } + out + } + + /// How many walkable tiles a walk from `(x, y)` could reach, the directed walls respected. + /// + /// The starting tile counts whether or not it is walkable, for the same reason the route + /// search treats it as passable: the fly is standing on it. What this number is *for* is + /// reading a stalled walk at a glance -- a fly on a route with 600 walkable tiles and 4 + /// reachable ones is fenced in, and no amount of re-planning is going to help it + /// (`docs/design/macros.md` section 15, `examples/scene_probe.rs`). + pub fn reachable_from(&self, x: u8, y: u8) -> usize { + if self.index(x, y).is_none() { + return 0; + } + let mut seen = vec![false; self.tiles.len()]; + let mut queue = std::collections::VecDeque::new(); + if let Some(index) = self.index(x, y) { + seen[index] = true; + } + queue.push_back((x, y)); + let mut count = 0; + while let Some((tx, ty)) = queue.pop_front() { + count += 1; + for facing in [Facing::Up, Facing::Down, Facing::Left, Facing::Right] { + if self.walled(tx, ty, facing) { + continue; + } + let (dx, dy) = facing.delta(); + let Ok(nx) = u8::try_from(i16::from(tx) + dx) else { continue }; + let Ok(ny) = u8::try_from(i16::from(ty) + dy) else { continue }; + let Some(index) = self.index(nx, ny) else { continue }; + if seen[index] || !self.tiles[index].is_walkable() { + continue; + } + seen[index] = true; + queue.push_back((nx, ny)); + } + } + count + } +} + +/// Which bit of a [`MapGrid`] wall mask a direction is. +fn wall_bit(facing: Facing) -> u8 { + match facing { + Facing::Up => 1, + Facing::Down => 2, + Facing::Left => 4, + Facing::Right => 8, + } +} + /// One entry of the current map's warp table: a door, a staircase, a cave mouth. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Warp { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs new file mode 100644 index 0000000..a812615 --- /dev/null +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs @@ -0,0 +1,246 @@ +//! The whole current map's walkability, decoded from the tables the cartridge has loaded. +//! +//! `docs/design/macros.md` section 15, the operator 2026-09-22: "the frontier and warp macros need +//! to be map aware: A* over walkable tiles." [`super::state::walkable`] answers for the ten-by-nine +//! window of the screen buffer and [`super::macros::state::Walkable::Unknown`] for everything +//! else, which is honest and is also why every walk planned through guesses, re-planned at each +//! window edge, and called "frontier" whatever unstood ground happened to be on screen. +//! +//! This module is the same predicate over the whole map. Nothing about the *rule* changes -- a tile +//! is walkable when the current tileset's collision list holds its tile id, which is +//! `CheckTilePassable` -- what changes is where the tile id comes from: +//! +//! | what | where the cartridge keeps it | how it is read | +//! | --- | --- | --- | +//! | the map's blocks | `wOverworldMap`, one byte per 4x4-tile block, rows of `width + MAP_BORDER * 2` with the map three rows and three columns in (`LoadTileBlockMap`) | WRAM, through the ordinary reader | +//! | a block's tiles | the tileset header's blockset, 16 bytes per block id, four rows of four tile ids (`DrawTileBlock`) | ROM, through [`crate::adapter::MemoryReader::read_rom`], because the blockset is not in bank 0 | +//! | which tiles are passable | `wTilesetCollisionPtr`, a `$ff`-terminated list in bank 0 | WRAM pointer, ROM bank 0 read, exactly as the window predicate already did | +//! | which steps are refused between two passable tiles | `TilePairCollisionsLand`, keyed by `wCurMapTileset` | the table's values, quoted below | +//! +//! Two coordinate systems meet here and keeping them apart is the whole of the arithmetic. The +//! cartridge's *blocks* are 4x4 screen tiles; the player moves in *map tiles* of 2x2 screen tiles, +//! which is the unit `wXCoord`, the warp table and everything in `macros/` is in. So one block is +//! [`TILES_PER_BLOCK`] map tiles each way, and the screen tile a map tile's walkability is read +//! from is the top left of its 2x2 quadrant -- the same corner `_GetTileAndCoordsInFrontOfPlayer` +//! reads at screen `(8, 9)` for the tile the player stands on, which is what +//! [`super::state::map_grid`]'s cross-check against the window predicate proves on a cartridge. +//! +//! What it does **not** model is what it did not model before: sprites standing on ground (the +//! sprite list answers that, and [`super::macros::path::frontier`] reads it), warps that fire on +//! the step onto them, and scripts that push the fly off a tile (a session ledger answers that). + +use std::sync::Arc; + +use super::macros::state::{Facing, MapGrid, Walkable}; + +/// Screen tiles across and down one block: `BLOCK_WIDTH` and `BLOCK_HEIGHT` +/// (`constants/gfx_constants.asm`). +pub const BLOCK_TILES: usize = 4; + +/// Bytes one block takes in a tileset's blockset: its sixteen tile ids, four rows of four. +pub const BLOCK_BYTES: usize = BLOCK_TILES * BLOCK_TILES; + +/// Blocks of border `wOverworldMap` keeps on every side: `MAP_BORDER` +/// (`constants/map_data_constants.asm`), which is what lets the view centre on a map smaller than +/// the screen and what a map's own blocks are offset by. +pub const MAP_BORDER: usize = 3; + +/// Map tiles across one block. A block is four screen tiles and the player walks two at a time, +/// which is why `wCurMapWidth` in blocks is `map_size().width` in tiles divided by this. +pub const TILES_PER_BLOCK: u8 = 2; + +/// Tileset ids the tile-pair lists name (`constants/tileset_constants.asm`, counted in the order +/// that file declares them: OVERWORLD 0 … FOREST 3 … CAVERN 17). +pub mod tileset { + pub const FOREST: u8 = 3; + pub const CAVERN: u8 = 17; +} + +/// `TilePairCollisionsLand` at the pinned pokered commit, as `(tileset, one tile, the other)`. +/// +/// `data/tilesets/pair_collision_tile_ids.asm`. Values rather than an address, like the item +/// prices in [`super::macros::cartridge`]: what the cartridge keeps here is eleven triples, and +/// the file they came from is quoted so a wrong one is a review comment rather than a mystery. +/// +/// `CheckForTilePairCollisions` walks the list comparing the tile the player stands on against +/// *either* member of the pair and the tile in front against the other, so the refusal is +/// symmetric -- a forest's tree line refuses the step in both directions -- and each triple +/// becomes two directed walls. Both tiles are passable on their own, which is why nothing in the +/// collision list can predict it and why the walk used to learn it one refusal at a time +/// ([`super::macros::path::Refusal`]). +/// +/// `TilePairCollisionsWater` is deliberately absent: it is the list +/// `CheckForJumpingAndTilePairCollisions` uses while surfing, the fly has no Surf, and a rule for +/// a movement mode the palette cannot enter is not knowledge this grid should carry. +pub const TILE_PAIRS_LAND: [(u8, u8, u8); 11] = [ + (tileset::CAVERN, 0x20, 0x05), + (tileset::CAVERN, 0x41, 0x05), + (tileset::FOREST, 0x30, 0x2e), + (tileset::CAVERN, 0x2a, 0x05), + (tileset::CAVERN, 0x05, 0x21), + (tileset::FOREST, 0x52, 0x2e), + (tileset::FOREST, 0x55, 0x2e), + (tileset::FOREST, 0x56, 0x2e), + (tileset::FOREST, 0x20, 0x2e), + (tileset::FOREST, 0x5e, 0x2e), + (tileset::FOREST, 0x5f, 0x2e), +]; + +/// The tileset's two tables, as the decoder needs them. +/// +/// Gathered by [`super::state::map_grid`] from WRAM and ROM; separate from the decoding so that +/// the decoding is a pure function of bytes and can be tested against a made-up tileset. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Tileset { + /// `wCurMapTileset`: which tileset, for the tile-pair lists. + pub id: u8, + /// The blockset: sixteen tile ids per block id, in block-id order from zero. + pub blocks: Vec, + /// The `$ff`-terminated passable-tile list, terminator included or not. + pub passable: Vec, +} + +impl Tileset { + /// The screen tile id at `(column, row)` of block `block`, or `None` past the blockset. + fn tile(&self, block: u8, column: usize, row: usize) -> Option { + let offset = usize::from(block) * BLOCK_BYTES + row * BLOCK_TILES + column; + self.blocks.get(offset).copied() + } + + /// Whether the collision list holds `tile`, which is `CheckTilePassable` and nothing else. + fn passable(&self, tile: u8) -> bool { + for candidate in &self.passable { + if *candidate == TERMINATOR { + return false; + } + if *candidate == tile { + return true; + } + } + false + } +} + +/// The `$ff` that ends a collision list. +pub const TERMINATOR: u8 = 0xff; + +/// Decode the whole map into a [`MapGrid`]. +/// +/// `blocks` is the map's block ids, row-major, `width_blocks * height_blocks` of them, already +/// lifted out of `wOverworldMap`'s bordered rows. Every map tile gets one answer: +/// [`Walkable::Yes`] or [`Walkable::No`] from the collision list, and [`Walkable::Unknown`] only +/// for a tile whose block id is past the end of the blockset -- which is a table that was read +/// short rather than a tile the game is unsure about. +pub fn decode(map: u8, width_blocks: u8, height_blocks: u8, blocks: &[u8], tiles: &Tileset) -> MapGrid { + let width = width_blocks.saturating_mul(TILES_PER_BLOCK); + let height = height_blocks.saturating_mul(TILES_PER_BLOCK); + let mut grid = MapGrid::new(map, width, height); + for y in 0..height { + for x in 0..width { + let block_index = + usize::from(y / TILES_PER_BLOCK) * usize::from(width_blocks) + + usize::from(x / TILES_PER_BLOCK); + let Some(block) = blocks.get(block_index).copied() else { + continue; + }; + // The top left screen tile of the map tile's own 2x2 quadrant of the block, which is + // the corner every collision read in the game uses. + let column = usize::from(x % TILES_PER_BLOCK) * usize::from(TILES_PER_BLOCK); + let row = usize::from(y % TILES_PER_BLOCK) * usize::from(TILES_PER_BLOCK); + let Some(tile) = tiles.tile(block, column, row) else { + continue; + }; + grid.set(x, y, tile, if tiles.passable(tile) { Walkable::Yes } else { Walkable::No }); + } + } + add_pair_walls(&mut grid, tiles.id); + grid +} + +/// Turn every tile-pair collision the loaded tileset has into two directed walls. +fn add_pair_walls(grid: &mut MapGrid, tileset: u8) { + let pairs: Vec<(u8, u8)> = TILE_PAIRS_LAND + .iter() + .filter(|(id, _, _)| *id == tileset) + .map(|(_, one, other)| (*one, *other)) + .collect(); + if pairs.is_empty() { + return; + } + for y in 0..grid.height() { + for x in 0..grid.width() { + let Some(here) = grid.tile_id(x, y) else { continue }; + for facing in [Facing::Down, Facing::Right] { + let (dx, dy) = facing.delta(); + let Some(nx) = checked_step(x, dx) else { continue }; + let Some(ny) = checked_step(y, dy) else { continue }; + if nx >= grid.width() || ny >= grid.height() { + continue; + } + let Some(there) = grid.tile_id(nx, ny) else { continue }; + if pairs.iter().any(|(one, other)| { + (*one == here && *other == there) || (*one == there && *other == here) + }) { + grid.wall(x, y, facing); + grid.wall(nx, ny, opposite(facing)); + } + } + } + } +} + +fn checked_step(value: u8, delta: i16) -> Option { + u8::try_from(i16::from(value) + delta).ok() +} + +fn opposite(facing: Facing) -> Facing { + match facing { + Facing::Down => Facing::Up, + Facing::Up => Facing::Down, + Facing::Left => Facing::Right, + Facing::Right => Facing::Left, + } +} + +/// The decoded grid of the map that is loaded, kept for as long as it is the loaded one. +/// +/// One slot, keyed by map id and size: arriving on another map drops it, and so does a map whose +/// header reads a different size, because both mean the block data under it has been replaced. +/// A decode is a few thousand WRAM reads and a walk of the blockset, so it happens once per +/// arrival rather than once per plan -- and never per frame, which is what a precondition asking +/// for the frontier would otherwise cost. +/// +/// Session state like the other ledgers of `docs/design/macros.md` section 12: owned by +/// [`super::macros::driver::PokemonPalette`], never checkpointed, and rebuilt from the cartridge +/// on the first frame after a restore. +#[derive(Debug, Clone, Default)] +pub struct MapGrids { + current: Option>, +} + +impl MapGrids { + /// The cached grid for `map` at this size, or `None` when the cache is for somewhere else. + /// + /// Handed out behind an [`Arc`] so that a caller that asks once a frame -- a precondition + /// wanting to know whether the frontier is empty -- pays a refcount rather than a copy of the + /// map. + pub fn get(&self, map: u8, width: u8, height: u8) -> Option> { + self.current + .as_ref() + .filter(|grid| grid.map() == map && grid.width() == width && grid.height() == height) + .map(Arc::clone) + } + + /// Keep `grid`, dropping whatever map the cache held before. + pub fn store(&mut self, grid: MapGrid) -> Arc { + Arc::clone(self.current.insert(Arc::new(grid))) + } + + /// Which map the cache is holding, for a log line and the tests. + pub fn held(&self) -> Option { + self.current.as_ref().map(|grid| grid.map()) + } +} + +#[cfg(test)] +mod tests; diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs new file mode 100644 index 0000000..455ca32 --- /dev/null +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs @@ -0,0 +1,161 @@ +//! The decoder, against a made-up tileset. +//! +//! Every byte here is synthetic on purpose: a block table, a blockset and a collision list of this +//! module's own making, so a failure says which of the three readings is wrong rather than "the +//! cartridge disagrees". The cartridge half — that the decode matches real presses on Pallet Town +//! and Viridian Forest — is `tests/rom_map_grid.rs`. + +use super::*; +use crate::pokemon_red::macros::state::{Facing, Walkable}; + +/// A blockset with three blocks: all floor, all wall, and one whose four map-tile quadrants are +/// four different tile ids. +/// +/// A block is four screen tiles each way and a map tile is two, so the quadrants are the four +/// corners and the tile a map tile's walkability comes from is the top left of its own quadrant. +fn blockset() -> Vec { + let floor = [FLOOR; 16]; + let wall = [WALL; 16]; + let quadrants = [ + NORTH_WEST, 0x90, NORTH_EAST, 0x91, // + 0x92, 0x93, 0x94, 0x95, // + SOUTH_WEST, 0x96, SOUTH_EAST, 0x97, // + 0x98, 0x99, 0x9a, 0x9b, + ]; + let mut out = Vec::new(); + out.extend_from_slice(&floor); + out.extend_from_slice(&wall); + out.extend_from_slice(&quadrants); + out +} + +const FLOOR: u8 = 0x01; +const WALL: u8 = 0x60; +const NORTH_WEST: u8 = 0x11; +const NORTH_EAST: u8 = 0x12; +const SOUTH_WEST: u8 = 0x13; +const SOUTH_EAST: u8 = 0x14; + +/// Floor and the four quadrant tiles are passable; the wall tile is in no list. +fn tileset(id: u8) -> Tileset { + Tileset { + id, + blocks: blockset(), + passable: vec![FLOOR, NORTH_WEST, NORTH_EAST, SOUTH_WEST, SOUTH_EAST, TERMINATOR], + } +} + +#[test] +fn one_block_becomes_four_map_tiles_from_its_four_quadrants() { + let grid = decode(7, 1, 1, &[2], &tileset(0)); + assert_eq!((grid.map(), grid.width(), grid.height()), (7, 2, 2)); + assert_eq!(grid.tile_id(0, 0), Some(NORTH_WEST)); + assert_eq!(grid.tile_id(1, 0), Some(NORTH_EAST)); + assert_eq!(grid.tile_id(0, 1), Some(SOUTH_WEST)); + assert_eq!(grid.tile_id(1, 1), Some(SOUTH_EAST)); +} + +#[test] +fn the_collision_list_answers_every_tile_of_a_map_larger_than_the_window() { + // Ten blocks by nine is twenty tiles by eighteen: wider than the ten-by-nine window the + // screen buffer can answer for, which is the point of the whole grid. + let (wide, high) = (10u8, 9u8); + let mut blocks = vec![0u8; usize::from(wide) * usize::from(high)]; + // A wall down the middle column of blocks. + for row in 0..usize::from(high) { + blocks[row * usize::from(wide) + 5] = 1; + } + let grid = decode(0, wide, high, &blocks, &tileset(0)); + assert_eq!((grid.width(), grid.height()), (20, 18)); + assert_eq!(grid.walkable(0, 0), Walkable::Yes); + assert_eq!(grid.walkable(19, 17), Walkable::Yes, "the far corner, which no window reaches"); + assert_eq!(grid.walkable(10, 9), Walkable::No); + assert_eq!(grid.walkable(11, 9), Walkable::No); + assert_eq!(grid.unknown_count(), 0); + assert_eq!(grid.walkable_count(), 20 * 18 - 18 * 2); + // Off the map is not a tile to stand on, which is the window predicate's own answer for it. + assert_eq!(grid.walkable(20, 0), Walkable::No); + assert_eq!(grid.walkable(0, 18), Walkable::No); +} + +#[test] +fn a_tile_pair_collision_is_a_wall_in_both_directions_and_only_in_its_own_tileset() { + // `FOREST, $30, $2E`: two tiles that are each passable on their own and that the cartridge + // refuses the step between (`data/tilesets/pair_collision_tile_ids.asm`). + let (one, other) = (0x30, 0x2e); + let tiles = Tileset { + id: tileset::FOREST, + blocks: [[one; 16], [other; 16]].concat(), + passable: vec![one, other, TERMINATOR], + }; + let grid = decode(0, 2, 1, &[0, 1], &tiles); + assert_eq!(grid.walkable(1, 0), Walkable::Yes); + assert_eq!(grid.walkable(2, 0), Walkable::Yes); + assert!(grid.walled(1, 0, Facing::Right), "the step onto the other tile"); + assert!(grid.walled(2, 0, Facing::Left), "and the step back, which is the same rule"); + assert!(!grid.walled(0, 0, Facing::Right), "two tiles of the same id are not a pair"); + + // The same two tile ids in a tileset the list does not name are ordinary ground. + let elsewhere = Tileset { id: tileset::CAVERN, ..tiles }; + let grid = decode(0, 2, 1, &[0, 1], &elsewhere); + assert!(!grid.walled(1, 0, Facing::Right)); + assert!(!grid.walled(2, 0, Facing::Left)); +} + +#[test] +fn a_block_id_the_blockset_does_not_reach_stays_unknown() { + // A blockset read short — a header pointer near the end of its bank — is not a tile the game + // is unsure about, and the search prices `Unknown` as plausible ground rather than refusing + // it, so saying so is the honest answer. + let grid = decode(0, 2, 1, &[0, 9], &tileset(0)); + assert_eq!(grid.walkable(0, 0), Walkable::Yes); + assert_eq!(grid.walkable(2, 0), Walkable::Unknown); + assert_eq!(grid.tile_id(2, 0), None); + // Block 9 is one block, which is four map tiles: two rows of two. + assert_eq!(grid.unknown_count(), 4); +} + +#[test] +fn reachable_from_counts_what_a_walk_can_get_to_and_not_what_it_can_see() { + // Two floor blocks with a wall block between them: eight walkable tiles, four of them fenced + // off from the fly. This is the number that tells a stalled walk from a long one. + let grid = decode(0, 3, 1, &[0, 1, 0], &tileset(0)); + assert_eq!(grid.walkable_count(), 8); + assert_eq!(grid.reachable_from(0, 0), 4); + assert_eq!(grid.reachable_from(4, 0), 4); + + // With the wall gone, the whole row is one region. + let grid = decode(0, 3, 1, &[0, 0, 0], &tileset(0)); + assert_eq!(grid.reachable_from(0, 0), 12); +} + +#[test] +fn a_directed_wall_fences_a_region_off_for_the_reachable_count_too() { + let (one, other) = (0x30, 0x2e); + let tiles = Tileset { + id: tileset::FOREST, + blocks: [[one; 16], [other; 16]].concat(), + passable: vec![one, other, TERMINATOR], + }; + // A column of `$30` and a column of `$2e`, four tiles each, with the tile-pair rule between + // every pair of them: every tile is walkable and half of them are unreachable. + let grid = decode(0, 2, 1, &[0, 1], &tiles); + assert_eq!(grid.walkable_count(), 8); + assert_eq!(grid.reachable_from(0, 0), 4); +} + +#[test] +fn the_cache_holds_one_map_and_drops_it_on_arrival_somewhere_else() { + let mut grids = MapGrids::default(); + assert_eq!(grids.held(), None); + let stored = grids.store(decode(3, 2, 2, &[0, 0, 0, 0], &tileset(0))); + assert_eq!(grids.held(), Some(3)); + assert_eq!(grids.get(3, 4, 4).as_deref(), Some(&*stored)); + // The same map at another size is another map's block data under the same id, which is what a + // half-loaded header looks like. + assert!(grids.get(3, 8, 8).is_none()); + assert!(grids.get(4, 4, 4).is_none()); + grids.store(decode(4, 1, 1, &[0], &tileset(0))); + assert_eq!(grids.held(), Some(4)); + assert!(grids.get(3, 4, 4).is_none()); +} diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs index 428fec6..469d708 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mod.rs @@ -11,6 +11,7 @@ pub mod catalog; #[cfg(test)] pub(crate) mod fake_wram; pub mod macros; +pub mod mapgrid; pub mod maps; pub mod scene; pub mod state; @@ -343,6 +344,13 @@ impl MemoryReader for SampleCache<'_> { self.bytes.insert(address, value); value } + + /// Straight through, uncached: a ROM byte cannot change, so there is nothing + /// for a per-sample cache to save, and the caller that reads a blockset + /// (`docs/design/macros.md` section 15) caches the decoded map instead. + fn read_rom(&mut self, bank: u8, address: u16) -> Option { + self.source.read_rom(bank, address) + } } fn word(memory: &mut impl MemoryReader, address: u16) -> u32 { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index 025608d..56af83d 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -29,9 +29,10 @@ use super::macros::cartridge::{ Objective, PushedLedger, StoodLedger, TalkLedger, TalkTarget, TargetKey, TargetLedger, Tile, }; use super::macros::geography::Amenity; +use super::mapgrid::{self, MapGrids}; use super::macros::state::{ BagItem, Battle, BattleKind, BattleMenu, Connections, Cursor, EnemyMon, Facing, GameState, - MapSize, Mon, Move, Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu, + MapGrid, MapSize, Mon, Move, Npc, Party, Pc, Player, Scene, Shop, ShopScreen, Sign, StartMenu, Status, TextBox, Walkable, Warp, }; use super::symbols::ram; @@ -775,12 +776,16 @@ pub fn npcs(memory: &mut dyn MemoryReader) -> Vec { npcs } -/// Whether the current tileset calls this tile id passable. +/// The current tileset's list of passable tile ids, terminator included. /// /// `CheckTilePassable` walks the list at `wTilesetCollisionPtr` — a little-endian pointer into the /// collision tables, which all live in ROM bank 0 at this commit and so are always mapped — until -/// it matches or hits `$ff`. `None` means the pointer is not one this module will follow. -fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option { +/// it matches or hits `$ff`. `None` means the pointer is not one this module will follow, or the +/// list is not terminated inside the bound below. +/// +/// One read of the list serves both callers: [`walkable`] asks about one tile of the window, and +/// [`map_grid`] asks about every tile of the map, and neither is allowed its own copy of the rule. +fn collision_list(memory: &mut dyn MemoryReader) -> Option> { let low = u16::from(read(memory, ram::wTilesetCollisionPtr)); let high = u16::from(read(memory, ram::wTilesetCollisionPtr + 1)); let base = high * 256 + low; @@ -792,16 +797,162 @@ fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option { } // No collision list in the game is longer than this; the bound is what stops a bad pointer // from walking the cartridge. + let mut list = Vec::new(); for offset in 0..64u16 { - match read(memory, base + offset) { - 0xff => return Some(false), - found if found == tile => return Some(true), - _ => {} + let byte = read(memory, base + offset); + list.push(byte); + if byte == mapgrid::TERMINATOR { + return Some(list); } } None } +/// Whether the current tileset calls this tile id passable. +/// +/// [`collision_list`]'s own walk, and `CheckTilePassable`'s: match or `$ff`, whichever comes +/// first. `None` means the list could not be read at all. +fn passable(memory: &mut dyn MemoryReader, tile: u8) -> Option { + let list = collision_list(memory)?; + for candidate in list { + if candidate == mapgrid::TERMINATOR { + return Some(false); + } + if candidate == tile { + return Some(true); + } + } + Some(false) +} + +/// Why a whole-map grid could not be decoded on this frame. +/// +/// `docs/design/macros.md` section 15 asks the fallback to *say when*, so every way out of +/// [`map_grid`] is named rather than being one `None`. Each one leaves the window predicate in +/// charge, which is what the walks did before the grid existed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GridRefusal { + /// The map header is not loaded, or its size is out of range (`map_size` said `None`). + NoHeader, + /// The player's coordinates are not readable, so nothing can be cross-checked. + NoPlayer, + /// The tileset's collision list could not be followed ([`collision_list`]). + NoCollisionList, + /// The blockset could not be read: the seam has no cartridge behind it + /// ([`MemoryReader::read_rom`] answered `None`), or the header's pointer runs off the image. + NoBlockset, + /// The screen is not showing the map — a battle, a text box, a frame mid-warp — so there is + /// nothing to check the decode against, and `wOverworldMap` shares its bytes with the picture + /// buffer (`ram/wram.asm`'s own union), which is exactly when it must not be trusted. + NoScreen, + /// The decode and the screen buffer disagree about a tile the window can answer for. A wrong + /// stride, a wrong quadrant or a half-loaded map all land here, and all of them answer + /// plausibly, which is why this check is not optional. + ScreenDisagrees, +} + +impl GridRefusal { + /// A short label for a log line and the probes. + pub fn label(self) -> &'static str { + match self { + GridRefusal::NoHeader => "no map header", + GridRefusal::NoPlayer => "no player", + GridRefusal::NoCollisionList => "no collision list", + GridRefusal::NoBlockset => "no blockset", + GridRefusal::NoScreen => "map not on screen", + GridRefusal::ScreenDisagrees => "screen disagrees", + } + } +} + +/// The whole loaded map's walkability, decoded from the tables the cartridge has loaded. +/// +/// `docs/design/macros.md` section 15. The rule is [`walkable`]'s rule — the tileset's collision +/// list — and what this adds is the tile id of every tile of the map rather than of the ten-by-nine +/// window: +/// +/// - the map's **blocks** come from `wOverworldMap`, which `LoadTileBlockMap` fills from the map's +/// own ROM bank as rows of `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself three rows +/// and three columns in. That is WRAM, so it needs no bank at all. +/// - a block's **tiles** come from the tileset header's blockset, sixteen bytes per block id +/// (`DrawTileBlock`). That is ROM, and not bank 0, so it is the one read that goes through +/// [`MemoryReader::read_rom`] — the cartridge image as the process already holds it, because the +/// alternative would be *writing* the mapper's bank register and the joypad is the only write +/// this workspace makes into a running game. +/// - the **tile-pair** refusals come from the values of `TilePairCollisionsLand`, keyed by +/// `wCurMapTileset`, and become directed walls ([`mapgrid::TILE_PAIRS_LAND`]). +/// +/// The last thing it does is check itself: the decoded tile ids are compared against +/// [`map_tile_id`] for the player's own tile and its four neighbours, every one the window can +/// answer for. A frame where the window can answer for none of them is refused +/// ([`GridRefusal::NoScreen`]) rather than trusted, because `wOverworldMap` shares its bytes with +/// the picture buffer and a battle is exactly when the blocks under it are somebody else's. +pub fn map_grid(memory: &mut dyn MemoryReader) -> Result { + let size = map_size(memory).ok_or(GridRefusal::NoHeader)?; + let player = player(memory).ok_or(GridRefusal::NoPlayer)?; + let passable = collision_list(memory).ok_or(GridRefusal::NoCollisionList)?; + let width_blocks = read(memory, ram::wCurMapWidth); + let height_blocks = read(memory, ram::wCurMapHeight); + let stride = u16::from(width_blocks) + (mapgrid::MAP_BORDER as u16) * 2; + let border = mapgrid::MAP_BORDER as u16; + let mut blocks = Vec::with_capacity(usize::from(width_blocks) * usize::from(height_blocks)); + for row in 0..u16::from(height_blocks) { + for column in 0..u16::from(width_blocks) { + blocks.push(read(memory, ram::wOverworldMap + (row + border) * stride + column + border)); + } + } + // Only as much of the blockset as this map's blocks index into: a tileset has up to 256 of + // them and a room uses a dozen, and a read that stops at the highest block id used is a read + // that cannot run off the end of a bank for tiles nothing asks about. + let highest = blocks.iter().copied().max().unwrap_or(0); + let bank = read(memory, ram::wTilesetBank); + let base = u16::from(read(memory, ram::wTilesetBlocksPtr)) + + u16::from(read(memory, ram::wTilesetBlocksPtr + 1)) * 256; + let wanted = (usize::from(highest) + 1) * mapgrid::BLOCK_BYTES; + let mut blockset = Vec::with_capacity(wanted); + for offset in 0..wanted { + let address = base.checked_add(u16::try_from(offset).map_err(|_| GridRefusal::NoBlockset)?); + let byte = address + .and_then(|address| memory.read_rom(bank, address)) + .ok_or(GridRefusal::NoBlockset)?; + blockset.push(byte); + } + let tiles = mapgrid::Tileset { id: read(memory, ram::wCurMapTileset), blocks: blockset, passable }; + let grid = mapgrid::decode(player.map, width_blocks, height_blocks, &blocks, &tiles); + if grid.width() != size.width || grid.height() != size.height { + return Err(GridRefusal::NoHeader); + } + // The cross-check. `map_tile_id` reads the screen buffer at the offset + // `_GetTileAndCoordsInFrontOfPlayer` uses, so agreeing with it on the tiles it can answer for + // is agreeing with the cartridge's own reading of the same ground. + let mut checked = 0; + for (x, y) in neighbourhood(player.x, player.y) { + let Some(screen) = map_tile_id(memory, x, y) else { continue }; + if grid.tile_id(x, y) != Some(screen) { + return Err(GridRefusal::ScreenDisagrees); + } + checked += 1; + } + if checked == 0 { + return Err(GridRefusal::NoScreen); + } + Ok(grid) +} + +/// The player's own tile and its four neighbours, which is every tile the window is certain to be +/// able to answer for from where the fly is standing. +fn neighbourhood(x: u8, y: u8) -> Vec<(u8, u8)> { + let mut out = vec![(x, y)]; + for (dx, dy) in [(0i16, 1i16), (0, -1), (-1, 0), (1, 0)] { + if let (Ok(nx), Ok(ny)) = + (u8::try_from(i16::from(x) + dx), u8::try_from(i16::from(y) + dy)) + { + out.push((nx, ny)); + } + } + out +} + /// Whether the player could stand on this tile of the current map. /// /// Mirrors `CheckTilePassable`: the tile id comes out of the screen buffer at the offset @@ -918,6 +1069,16 @@ pub struct PokeState<'a> { /// Tiles the cartridge has pushed the fly off ([`MacroState::pushed_tile`]). Session state /// beside the four above, owned by the same type (`infra/docs/macros-traps.md` row 37). pushed: &'a dyn PushedLedger, + /// Where the decoded map grid is kept between frames ([`MacroState::map_grid`], + /// `docs/design/macros.md` section 15). + /// + /// Mutable, unlike every ledger above, because this is the one thing the state *computes* + /// rather than looks up: a decode is a few thousand reads and a walk of the blockset, and it + /// is valid for as long as the map is loaded. Without a cache every caller decodes again, + /// which is correct and is what the tests do; the sim loop passes one + /// ([`PokeState::caching_grid`]) so that a precondition asking for the frontier costs a + /// refcount instead of a map. + grids: Option<&'a mut MapGrids>, } impl<'a> PokeState<'a> { @@ -935,6 +1096,7 @@ impl<'a> PokeState<'a> { stood: &NoStood, areas: &NoAreas, pushed: &NoPushed, + grids: None, } } @@ -948,6 +1110,7 @@ impl<'a> PokeState<'a> { stood: &NoStood, areas: &NoAreas, pushed: &NoPushed, + grids: None, } } @@ -965,7 +1128,18 @@ impl<'a> PokeState<'a> { areas: &'a dyn AreaLedger, pushed: &'a dyn PushedLedger, ) -> Self { - Self { memory, ledger, talk, targets, stood, areas, pushed } + Self { memory, ledger, talk, targets, stood, areas, pushed, grids: None } + } + + /// Keep the decoded map grid in `grids` instead of decoding it per question. + /// + /// The cache is keyed by map id and size and holds one map, so arriving somewhere else drops + /// it (`docs/design/macros.md` section 15). Session state: it is owned by + /// [`super::macros::driver::PokemonPalette`], never checkpointed, and rebuilt from the + /// cartridge on the first overworld frame after a restore. + pub fn caching_grid(mut self, grids: &'a mut MapGrids) -> Self { + self.grids = Some(grids); + self } } @@ -1051,6 +1225,28 @@ impl MacroState for PokeState<'_> { !controllable(self.memory) } + /// The whole loaded map's walkability, from the cache when it is for this map + /// (`docs/design/macros.md` section 15). + /// + /// `None` is the honest answer on every frame [`map_grid`] refuses — no cartridge behind the + /// seam, a battle or a text box over the map, a header that is not loaded — and every caller + /// falls back to the ten-by-nine window predicate then, which is what all of them did before + /// this existed. [`GridRefusal`] names which, for the probes. + fn map_grid(&mut self) -> Option> { + let size = map_size(self.memory)?; + let map = player(self.memory)?.map; + if let Some(grids) = self.grids.as_deref() + && let Some(grid) = grids.get(map, size.width, size.height) + { + return Some(grid); + } + let grid = map_grid(self.memory).ok()?; + match self.grids.as_deref_mut() { + Some(grids) => Some(grids.store(grid)), + None => Some(std::sync::Arc::new(grid)), + } + } + /// What the open mart sells, in menu order (`docs/design/macros.md` section 13). /// /// Gated on the mart scene being up, and that gate is the whole of the accuracy here: diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs index a371e73..e6bc45e 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs @@ -5,7 +5,8 @@ //! passable-tile list walked out of ROM bank 0. use super::*; -use crate::pokemon_red::fake_wram::{REDS_HOUSE_1F, WALL_TILE, Wram}; +use crate::pokemon_red::fake_wram::{self, REDS_HOUSE_1F, WALL_TILE, Wram}; +use crate::pokemon_red::macros::cartridge::MacroState; use crate::pokemon_red::macros::state::{BattleKind, BattleMenu, ShopScreen, Sign}; /// A walkable tile id from `RedsHouse1_Coll`. @@ -603,3 +604,141 @@ fn the_live_implementation_answers_the_whole_trait() { assert_eq!(state.warps().len(), 1); assert!(state.connections().south); } + +/// A map ten blocks by nine — twenty tiles by eighteen, wider than the ten-by-nine window — with +/// a wall down one column of blocks, and a screen buffer that agrees with it. +/// +/// The three tables the grid is decoded from, all synthetic: block ids in `wOverworldMap`, a +/// blockset in a ROM bank that is not bank 0, and a collision list in bank 0 where +/// `wTilesetCollisionPtr` points. +fn town() -> (Wram, Vec, Vec<[u8; 16]>) { + const FLOOR: u8 = 0x01; + let blockset = vec![[FLOOR; 16], [WALL_TILE; 16]]; + let (wide, high) = (10usize, 9usize); + let mut blocks = vec![0u8; wide * high]; + for row in 0..high { + blocks[row * wide + 5] = 1; + } + let mut wram = Wram::new(); + wram.started() + .map(fake_wram::PALLET_TOWN, wide as u8, high as u8, 3, 4) + .facing(0) + .house_collision() + .tileset(0) + .blockset(&blockset) + .map_blocks(&blocks) + .fill_screen(WALL_TILE) + .screen_from_blocks(&blocks, &blockset); + (wram, blocks, blockset) +} + +#[test] +fn the_whole_map_decodes_from_the_block_and_collision_tables() { + let (mut wram, _, _) = town(); + let grid = map_grid(&mut wram).expect("a decodable map"); + assert_eq!((grid.width(), grid.height()), (20, 18)); + assert_eq!(grid.unknown_count(), 0); + // The far corner of the map, which the window predicate cannot answer for at all: the grid + // does, and that is the whole of section 15. + assert_eq!(walkable(&mut wram, 19, 17), Walkable::Unknown); + assert_eq!(grid.walkable(19, 17), Walkable::Yes); + // The wall column, again outside the window. + assert_eq!(grid.walkable(10, 17), Walkable::No); + assert_eq!(grid.walkable(11, 17), Walkable::No); + // Inside the window the two readings agree tile for tile, which is what the reader checks + // itself with before it trusts a decode. + for y in 0..18u8 { + for x in 0..20u8 { + if let Some(tile) = map_tile_id(&mut wram, x, y) { + assert_eq!(grid.tile_id(x, y), Some(tile), "({x}, {y})"); + assert_eq!(grid.walkable(x, y), walkable(&mut wram, x, y), "({x}, {y})"); + } + } + } +} + +#[test] +fn a_decode_the_screen_disagrees_with_is_refused() { + let (mut wram, _, _) = town(); + // One screen tile the block data does not account for: a wrong stride, a wrong quadrant or a + // half-loaded map all look like this, and all of them answer plausibly. + wram.map_tile(3, 4, 0x77); + assert_eq!(map_grid(&mut wram), Err(GridRefusal::ScreenDisagrees)); +} + +#[test] +fn without_a_cartridge_behind_the_seam_there_is_no_grid() { + let (mut wram, blocks, _) = town(); + // The same WRAM with no blockset in any bank: `read_rom` answers `None`, which is what every + // reader that is not the emulator answers, and the grid narrows to nothing rather than + // decoding the map out of whatever bytes were to hand. + let mut bare = Wram::new(); + bare.started() + .map(fake_wram::PALLET_TOWN, 10, 9, 3, 4) + .facing(0) + .house_collision() + .map_blocks(&blocks) + .fill_screen(WALL_TILE); + assert_eq!(map_grid(&mut bare), Err(GridRefusal::NoBlockset)); + // And the window predicate still answers, which is the fallback the whole thing rests on. + assert_eq!(walkable(&mut wram, 3, 4), Walkable::Yes); +} + +#[test] +fn a_frame_that_is_not_showing_the_map_has_no_grid_to_check() { + let (mut wram, _, _) = town(); + // `wOverworldMap` shares its bytes with the picture buffer (`ram/wram.asm`'s own union), so a + // battle is exactly when the blocks under it belong to somebody else. Nothing can be + // cross-checked then, and a decode nothing can check is refused. + wram.battle(1); + assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoScreen)); + + let (mut wram, _, _) = town(); + wram.dialogue_box(); + assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoScreen)); +} + +#[test] +fn a_frame_with_no_map_header_has_no_grid() { + let mut wram = Wram::new(); + wram.started(); + assert_eq!(map_grid(&mut wram), Err(GridRefusal::NoHeader)); + assert_eq!(GridRefusal::NoHeader.label(), "no map header"); +} + +#[test] +fn the_grid_is_decoded_once_per_map_and_dropped_on_arrival_somewhere_else() { + let (mut wram, blocks, blockset) = town(); + let mut grids = MapGrids::default(); + { + let mut state = PokeState::new(&mut wram).caching_grid(&mut grids); + let first = state.map_grid().expect("a decodable map"); + let again = state.map_grid().expect("the cached map"); + assert!(std::sync::Arc::ptr_eq(&first, &again), "the second question is the same grid"); + } + assert_eq!(grids.held(), Some(fake_wram::PALLET_TOWN)); + + // Walking through a door: another map id, so the cache is somebody else's and is dropped. + wram.map(fake_wram::OAKS_LAB, 10, 9, 3, 4) + .map_blocks(&blocks) + .screen_from_blocks(&blocks, &blockset); + { + let mut state = PokeState::new(&mut wram).caching_grid(&mut grids); + let grid = state.map_grid().expect("a decodable map"); + assert_eq!(grid.map(), fake_wram::OAKS_LAB); + } + assert_eq!(grids.held(), Some(fake_wram::OAKS_LAB)); +} + +#[test] +fn a_state_with_no_cache_still_answers_and_a_state_with_no_cartridge_answers_none() { + let (mut wram, _, _) = town(); + let mut state = PokeState::new(&mut wram); + assert!(state.map_grid().is_some(), "no cache is slower, not blinder"); + + let mut bare = Wram::overworld(); + let mut state = PokeState::new(&mut bare); + assert!(state.map_grid().is_none(), "no blockset, no grid"); + // Which is the frame the window predicate is for. + assert_eq!(state.walkable(3, 6), Walkable::No); +} From f7fa39afa76b7126b1934cc55bac6e97bcb08bbd Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 03:32:35 +0000 Subject: [PATCH 04/13] macros: plan every walk over the map grid route and frontier ask for the grid once per plan and fall back to the window when there is none. With it: one plan crosses a whole map, GO WARP / GO OUT / GO ROUTE route to their warp or connection tile rather than to the nearest tile of an edge that reads Unknown, a tile-pair wall is planned around instead of being learned by walking into it, and the frontier is the nearest unstood walkable tile anywhere on the map instead of the nearest one on screen. --- .../src/pokemon_red/macros/path.rs | 106 ++++-- .../src/pokemon_red/macros/tests.rs | 1 + .../src/pokemon_red/macros/tests/map_aware.rs | 344 ++++++++++++++++++ 3 files changed, 426 insertions(+), 25 deletions(-) create mode 100644 services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/map_aware.rs diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs index f5fffa6..29d19e0 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/path.rs @@ -13,16 +13,29 @@ //! minimum Manhattan distance to any goal. That stays admissible because a step costs one and //! moves one tile. //! -//! **The walkable predicate has a window.** Agent A's [`Walkable::Unknown`] is load-bearing: the -//! tile ids a walkability test needs live in the screen buffer, so only the tiles around the -//! player can be answered at all. An unknown tile is *expensive* to path through rather than -//! forbidden ([`UNKNOWN_STEP`]): a known-walkable way round is always preferred, and the search -//! steps into the unknown only when nothing known gets any closer. That is what a map edge six -//! tiles away needs — it is off the screen by definition, so a search that refused every unknown -//! tile could not plan a single step toward Route 1 from the middle of Pallet Town, which is half -//! of why the fly never took it (`infra/docs/macros-bench.md`, 2026-09-16). The guess is cheap and -//! bounded: [`super::executor`] re-plans after every tile with a per-step check that the player -//! moved, and three failed steps abort as `Blocked`. +//! **It plans over the whole map when the map can be decoded** (`docs/design/macros.md` section +//! 15, the operator 2026-09-22: "the frontier and warp macros need to be map aware: A* over +//! walkable tiles"). [`MacroState::map_grid`] is every tile of the loaded map, walkability and +//! directed walls, decoded from the block and collision tables the cartridge has loaded +//! ([`crate::pokemon_red::mapgrid`]). With it, one plan crosses a town: `GO WARP` routes to its +//! warp tile, `GO OUT` and `GO ROUTE` to their door or connection tile, and the frontier is the +//! nearest unstood ground *anywhere on the map* rather than the nearest on screen. +//! +//! **Without it, the window is still the fallback.** Agent A's [`Walkable::Unknown`] is +//! load-bearing on a frame the grid cannot be decoded — no cartridge behind the seam, a battle or +//! a text box over the map, a header that is not loaded +//! ([`crate::pokemon_red::state::GridRefusal`] says which): the tile ids a walkability test needs +//! live in the screen buffer then, so only the tiles around the player can be answered at all. An +//! unknown tile is *expensive* to path through rather than forbidden ([`UNKNOWN_STEP`]): a +//! known-walkable way round is always preferred, and the search steps into the unknown only when +//! nothing known gets any closer. That is what a map edge six tiles away needed — it is off the +//! screen by definition, so a search that refused every unknown tile could not plan a single step +//! toward Route 1 from the middle of Pallet Town, which was half of why the fly never took it +//! (`infra/docs/macros-bench.md`, 2026-09-16). +//! +//! Either way the walk is re-planned only when the ground says so — a refusal, or the player not +//! where the plan expects it — which is [`super::executor`]'s committed route and not this +//! module's business. //! //! The search still has its second answer for a goal it cannot reach at all: when no goal is //! reachable, it returns the route to the reachable tile that gets closest to one. @@ -34,7 +47,24 @@ use super::cartridge::{ Edge, ExitId, LAST_MAP, MacroState, TalkTarget, Tile, destination_outdoors, outdoors, }; use super::geography; -use super::state::{Facing, Walkable}; +use super::state::{Facing, MapGrid, Walkable}; + +/// Whether the player could stand on a tile of the current map: the grid's answer, or the +/// window's when there is no grid. +/// +/// One place asks the question so that the search, the exit list and the frontier cannot disagree +/// about which reading they are on (`docs/design/macros.md` section 15). +fn walkable_at( + state: &mut dyn MacroState, + grid: Option<&MapGrid>, + x: u8, + y: u8, +) -> Walkable { + match grid { + Some(grid) => grid.walkable(x, y), + None => state.walkable(x, y), + } +} /// What one step onto a tile the walkable predicate cannot answer for costs. /// @@ -156,6 +186,10 @@ pub fn route_avoiding( } let player = state.player()?; let size = state.map_size()?; + // One decode per plan, from the cache the sim loop keeps: with a grid the search is over the + // whole map, without one it is over the ten-by-nine window as it always was. + let grid = state.map_grid(); + let grid = grid.as_deref(); let start = Tile::new(player.x, player.y); if let Some(goal) = goals.iter().position(|tile| *tile == start) { return Some(Route { goal: Some(goal), steps: Vec::new() }); @@ -191,6 +225,12 @@ pub fn route_avoiding( if refused.contains(&(tile, facing)) { continue; } + // A step the *tables* refuse: a tile-pair collision, which is passable ground on both + // sides and a wall between them (`mapgrid::TILE_PAIRS_LAND`). The walk used to learn + // each of these by spending a step on it; with the grid the first plan goes round. + if grid.is_some_and(|grid| grid.walled(tile.x, tile.y, facing)) { + continue; + } // **A tile the cartridge pushes the fly off is not a tile to walk through**, either // (row 37 of `infra/docs/macros-traps.md`). Excluding it as a *goal* was half the fix // and the measurement said so: Viridian City's (19, 9) went from 53,266 text-box @@ -200,10 +240,11 @@ pub fn route_avoiding( if next != start && state.pushed_tile(next.x, next.y) { continue; } - let step = match state.walkable(next.x, next.y) { + let step = match walkable_at(state, grid, next.x, next.y) { _ if next == start => 1, Walkable::Yes => 1, - // Off the screen buffer: plausible ground, priced so that anything known beats it. + // Off the screen buffer, or a block the blockset was read short of: plausible + // ground, priced so that anything known beats it. Walkable::Unknown => UNKNOWN_STEP, Walkable::No => continue, }; @@ -334,6 +375,8 @@ pub fn target_at(state: &mut dyn MacroState, tile: Tile) -> Option { pub fn exits(state: &mut dyn MacroState) -> Vec { let Some(size) = state.map_size() else { return Vec::new() }; let Some(player) = state.player() else { return Vec::new() }; + let grid = state.map_grid(); + let grid = grid.as_deref(); let here_outdoors = outdoors(player.map); let mut out = Vec::new(); for (index, warp) in state.warps().iter().enumerate() { @@ -373,13 +416,16 @@ pub fn exits(state: &mut dyn MacroState) -> Vec { let way = if here_outdoors { Way::Route } else { Way::Exit }; let into = geography::connected(player.map, edge); for tile in edge_tiles(facing, size.width, size.height) { - // Not `== Yes`: the walkable predicate's window is the screen, so the far edge of an - // outdoor map reads `Unknown` from anywhere but next to it, and filtering on `Yes` - // left a town's connections out of the exit list entirely -- which is half of why - // nothing could ever aim at Route 1 from the middle of Pallet Town. An unknown tile is - // a goal worth walking towards; the route search's own approach answer handles a goal - // it cannot reach yet, and a tile that turns out to be a wall costs one blocked walk. - if state.walkable(tile.x, tile.y) != Walkable::No { + // Not `== Yes`: without a grid the walkable predicate's window is the screen, so the + // far edge of an outdoor map reads `Unknown` from anywhere but next to it, and + // filtering on `Yes` left a town's connections out of the exit list entirely -- which + // is half of why nothing could ever aim at Route 1 from the middle of Pallet Town. An + // unknown tile is a goal worth walking towards; the route search's own approach answer + // handles a goal it cannot reach yet, and a tile that turns out to be a wall costs one + // blocked walk. With a grid the answer is `Yes` or `No` for every edge tile of the map + // and this rejects the walls, which is what lets one plan reach the right end of a + // connection instead of the nearest of twenty tiles along it. + if walkable_at(state, grid, tile.x, tile.y) != Walkable::No { out.push(Exit { id: ExitId::Edge(edge), tile, press: Some(facing), way, into }); } } @@ -396,9 +442,12 @@ pub fn exits(state: &mut dyn MacroState) -> Vec { /// Each answer is a tile to stand on paired with the direction the new ground lies in, which is /// the same shape `GO NPC` and `GO ITEM` use -- and the same press, which in the overworld walks /// onto the tile when it is walkable, so the frontier the fly is looking at becomes ground it has -/// stood on. Both tiles have to be walkable: an unreachable one is not ground, and the walkable -/// predicate's window means the answer is always local to the player, which is what makes the -/// re-plan after every tile do the work of a long walk. +/// stood on. Both tiles have to be walkable: an unreachable one is not ground. +/// +/// **With a grid this is the whole map** (`docs/design/macros.md` section 15): the nearest unstood +/// walkable tile anywhere on it, which is what the operator asked for and what the route search +/// then plans one walk to. Without a grid it is what it always was -- the ten-by-nine window, so +/// the answer is local to the player and the long walk is done by re-planning. /// /// Deduplicated by the tile to stand on, in tile order, so the choice between two equally near /// frontiers does not depend on iteration order. @@ -415,13 +464,15 @@ pub fn exits(state: &mut dyn MacroState) -> Vec { pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> { let Some(size) = state.map_size() else { return Vec::new() }; let Some(player) = state.player() else { return Vec::new() }; + let grid = state.map_grid(); + let grid = grid.as_deref(); let here = Tile::new(player.x, player.y); let held: Vec = state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect(); let mut out: Vec<(Tile, Facing)> = Vec::new(); for y in 0..size.height { for x in 0..size.width { let tile = Tile::new(x, y); - if tile != here && state.walkable(x, y) != Walkable::Yes { + if tile != here && walkable_at(state, grid, x, y) != Walkable::Yes { continue; } if tile != here && held.contains(&tile) { @@ -432,7 +483,12 @@ pub fn frontier(state: &mut dyn MacroState) -> Vec<(Tile, Facing)> { if next.x >= size.width || next.y >= size.height || next == here { continue; } - if state.walkable(next.x, next.y) != Walkable::Yes { + if walkable_at(state, grid, next.x, next.y) != Walkable::Yes { + continue; + } + // A step the tables refuse is not a way onto that ground, so the tile it leads to + // is not this tile's frontier -- somebody else's, if anything reaches it. + if grid.is_some_and(|grid| grid.walled(tile.x, tile.y, facing)) { continue; } if held.contains(&next) { diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs index fc4b42c..d8cfd3b 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests.rs @@ -3994,4 +3994,5 @@ fn a_scripted_push_back_records_the_tile_it_happened_on() { ); } +mod map_aware; mod shop_purchase; diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/map_aware.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/map_aware.rs new file mode 100644 index 0000000..1257e99 --- /dev/null +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/macros/tests/map_aware.rs @@ -0,0 +1,344 @@ +//! Walks planned over the whole map, and the same walks with the window as the only reading. +//! +//! `docs/design/macros.md` section 15. [`super::super::path`] has two readings of the ground now +//! and the interesting tests are the ones that tell them apart: a map bigger than the ten-by-nine +//! window, a frontier on the far side of it, and a wall the collision list cannot predict. The +//! fake here is deliberately not [`super::World`] — it implements the seam and nothing else, so a +//! failure is about the search rather than about a script's frames. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use crate::pokemon_red::macros::cartridge::{Edge, ExitId, MacroState, Tile}; +use crate::pokemon_red::macros::path::{self, Way}; +use crate::pokemon_red::macros::state::{ + BagItem, Battle, Connections, Facing, GameState, MapGrid, MapSize, Mon, Npc, Party, Pc, Player, + Scene, Shop, Sign, StartMenu, TextBox, Walkable, Warp, +}; + +/// A passable tile id and a wall tile id, for a grid built by hand. +const FLOOR: u8 = 0x01; +const WALL: u8 = 0x60; + +/// The ground, and nothing else: the seam's questions about a map and the fly standing on it. +struct Ground { + map: u8, + size: MapSize, + player: Tile, + /// Tiles that are not walkable; everything else inside `size` is. + walls: BTreeSet, + /// Steps the cartridge refuses although both tiles are passable, as the grid records them. + pair_walls: Vec<(Tile, Facing)>, + /// Ground the run has stood on, which is what makes a tile not a frontier. + stood: BTreeSet, + warps: Vec, + connections: Connections, + npcs: Vec, + /// Whether the whole map is decoded, or only the window can answer. + decoded: bool, +} + +impl Ground { + /// A map `wide` by `high` tiles with the fly at `player` and every tile walkable. + fn new(wide: u8, high: u8, player: (u8, u8)) -> Self { + Self { + map: 0, + size: MapSize { width: wide, height: high }, + player: Tile::new(player.0, player.1), + walls: BTreeSet::new(), + pair_walls: Vec::new(), + stood: BTreeSet::new(), + warps: Vec::new(), + connections: Connections::default(), + npcs: Vec::new(), + decoded: true, + } + } + + fn wall(mut self, x: u8, y: u8) -> Self { + self.walls.insert(Tile::new(x, y)); + self + } + + /// A column of wall with one gap in it, which is the shape that tells the two readings apart. + fn wall_column(mut self, x: u8, gap: u8) -> Self { + for y in 0..self.size.height { + if y != gap { + self.walls.insert(Tile::new(x, y)); + } + } + self + } + + /// Everything within `distance` of the fly counts as stood on, which is what a fly that has + /// been walking around one corner of a route has. + fn stood_around(mut self, distance: u32) -> Self { + for y in 0..self.size.height { + for x in 0..self.size.width { + let tile = Tile::new(x, y); + if tile.distance(self.player) <= distance { + self.stood.insert(tile); + } + } + } + self + } + + /// The window reading only: what every walk had before section 15. + fn window_only(mut self) -> Self { + self.decoded = false; + self + } + + fn pair_wall(mut self, from: (u8, u8), facing: Facing) -> Self { + self.pair_walls.push((Tile::new(from.0, from.1), facing)); + self + } + + fn connected(mut self, connections: Connections) -> Self { + self.connections = connections; + self + } + + /// The ten-by-nine window agent A's predicate can answer for, which moves with the fly. + fn in_window(&self, x: u8, y: u8) -> bool { + let dx = i32::from(x) - i32::from(self.player.x); + let dy = i32::from(y) - i32::from(self.player.y); + (-4..=5).contains(&dx) && (-4..=4).contains(&dy) + } + + fn walkable_tile(&self, x: u8, y: u8) -> bool { + x < self.size.width && y < self.size.height && !self.walls.contains(&Tile::new(x, y)) + } +} + +impl GameState for Ground { + fn scene(&mut self) -> Scene { + Scene::Overworld + } + + fn player(&mut self) -> Option { + Some(Player { map: self.map, x: self.player.x, y: self.player.y, facing: Facing::Down }) + } + + fn map_size(&mut self) -> Option { + Some(self.size) + } + + fn party(&mut self) -> Party { + Party { mons: Vec::::new(), active: None } + } + + fn battle(&mut self) -> Option { + None + } + + fn text_box(&mut self) -> TextBox { + TextBox { open: false, waiting: false } + } + + fn start_menu(&mut self) -> Option { + None + } + + fn shop(&mut self) -> Option { + None + } + + fn pc(&mut self) -> Option { + None + } + + fn money(&mut self) -> u32 { + 0 + } + + fn bag(&mut self) -> Vec { + Vec::new() + } + + fn npcs(&mut self) -> Vec { + self.npcs.clone() + } + + fn signs(&mut self) -> Vec { + Vec::new() + } + + /// Agent A's predicate: the window, and `Unknown` outside it. + fn walkable(&mut self, x: u8, y: u8) -> Walkable { + if x >= self.size.width || y >= self.size.height { + return Walkable::No; + } + if !self.in_window(x, y) { + return Walkable::Unknown; + } + if self.walkable_tile(x, y) { Walkable::Yes } else { Walkable::No } + } + + fn warps(&mut self) -> Vec { + self.warps.clone() + } + + fn connections(&mut self) -> Connections { + self.connections + } +} + +impl MacroState for Ground { + fn map_grid(&mut self) -> Option> { + if !self.decoded { + return None; + } + let mut grid = MapGrid::new(self.map, self.size.width, self.size.height); + for y in 0..self.size.height { + for x in 0..self.size.width { + let walkable = self.walkable_tile(x, y); + grid.set( + x, + y, + if walkable { FLOOR } else { WALL }, + if walkable { Walkable::Yes } else { Walkable::No }, + ); + } + } + for (tile, facing) in &self.pair_walls { + grid.wall(tile.x, tile.y, *facing); + } + Some(Arc::new(grid)) + } + + fn tile_visited(&mut self, x: u8, y: u8) -> bool { + self.stood.contains(&Tile::new(x, y)) + } +} + +/// Walk a route's presses and report where they land and whether every tile was walkable. +fn walk(ground: &Ground, from: Tile, steps: &[Facing]) -> (Tile, bool) { + let mut at = from; + let mut clean = true; + for facing in steps { + let Some(next) = at.step(*facing) else { + clean = false; + break; + }; + if !ground.walkable_tile(next.x, next.y) { + clean = false; + } + at = next; + } + (at, clean) +} + +#[test] +fn one_plan_crosses_a_map_larger_than_the_window() { + // Twenty by eighteen — Pallet Town's size — with a wall down the middle and one gap in it, + // which is where a plan has to go and is off the screen from where the fly starts. + let start = (2, 2); + let goal = Tile::new(18, 16); + let mut ground = Ground::new(20, 18, start).wall_column(10, 1); + let route = path::route(&mut ground, &[goal]).expect("a route across the map"); + assert_eq!(route.goal, Some(0), "the goal itself, not an approach to it"); + let (landed, clean) = walk(&ground, Tile::new(start.0, start.1), &route.steps); + assert_eq!(landed, goal); + assert!(clean, "every tile of the plan is walkable ground"); + // The gap is at the top, so the plan is longer than the Manhattan distance and knows it. + assert!(route.steps.len() > Tile::new(start.0, start.1).distance(goal) as usize); + + // The same map with only the window to read: the plan sets off through a wall it cannot see. + let mut blind = Ground::new(20, 18, start).wall_column(10, 1).window_only(); + let route = path::route(&mut blind, &[goal]).expect("a route through the unknown"); + let (_, clean) = walk(&blind, Tile::new(start.0, start.1), &route.steps); + assert!(!clean, "the window cannot see the wall, so the plan walks into it"); +} + +#[test] +fn the_frontier_is_the_nearest_unstood_tile_anywhere_on_the_map() { + // A fly that has covered the ground around it. Nine tiles is the furthest corner of the + // ten-by-nine window (five across and four down), so every tile the window can answer for has + // been stood on and the nearest new ground is the first tile outside it. + let mut ground = Ground::new(20, 18, (3, 3)).stood_around(9); + let frontier = path::frontier(&mut ground); + assert!(!frontier.is_empty(), "the rest of the map is still new ground"); + let (tile, facing) = frontier + .iter() + .copied() + .min_by_key(|(tile, _)| tile.distance(Tile::new(3, 3))) + .expect("a nearest frontier"); + let new_ground = tile.step(facing).expect("the ground it faces"); + assert!(!ground.tile_visited(new_ground.x, new_ground.y)); + assert_eq!(tile.distance(Tile::new(3, 3)), 9, "the near side of the unstood ground"); + // And a route to it, in one plan. + let goals: Vec = frontier.iter().map(|(tile, _)| *tile).collect(); + let route = path::route(&mut ground, &goals).expect("a route to the frontier"); + assert!(route.goal.is_some()); + + // The window reading has nothing to offer here at all, which is the pad the operator saw + // hanging: every tile it can answer for has been stood on. + let mut blind = Ground::new(20, 18, (3, 3)).stood_around(9).window_only(); + assert!(path::frontier(&mut blind).is_empty()); +} + +#[test] +fn a_step_the_tables_refuse_is_planned_around_rather_than_walked_into() { + // A tile-pair collision: both tiles passable, the step between them refused + // (`data/tilesets/pair_collision_tile_ids.asm`). The wall is the only way east on its row, so + // a search that did not know about it would plan straight through. + let goal = Tile::new(3, 0); + let mut ground = Ground::new(4, 3, (1, 0)) + .pair_wall((1, 0), Facing::Right) + .pair_wall((2, 0), Facing::Left); + let route = path::route(&mut ground, &[goal]).expect("a route round the pair wall"); + assert_eq!(route.steps.first(), Some(&Facing::Down), "round it, not through it"); + let (landed, clean) = walk(&ground, Tile::new(1, 0), &route.steps); + assert_eq!(landed, goal); + assert!(clean); + assert!(!route.steps.is_empty()); + + // Without the pair rule the same map is a straight line east. + let mut open = Ground::new(4, 3, (1, 0)); + let route = path::route(&mut open, &[goal]).expect("a route east"); + assert_eq!(route.steps, vec![Facing::Right, Facing::Right]); +} + +#[test] +fn a_connection_on_the_far_edge_is_a_goal_and_the_walls_along_it_are_not() { + // A route's north edge, off the screen from where the fly stands, with two walkable tiles on + // it. With the map decoded the exit list is those two tiles and not the whole row. + let mut ground = Ground::new(20, 18, (3, 16)) + .connected(Connections { north: true, south: false, east: false, west: false }); + for x in 0..20u8 { + if x != 7 && x != 8 { + ground = ground.wall(x, 0); + } + } + let exits = path::exits(&mut ground); + let north: Vec = exits + .iter() + .filter(|exit| exit.id == ExitId::Edge(Edge::North)) + .map(|exit| exit.id) + .collect(); + assert_eq!(north.len(), 2, "the two tiles a step north can be taken from"); + let goals: Vec = exits + .iter() + .filter(|exit| exit.way == Way::Route && exit.id == ExitId::Edge(Edge::North)) + .map(|exit| exit.tile) + .collect(); + assert!(goals.iter().all(|tile| tile.y == 0 && (tile.x == 7 || tile.x == 8))); + let route = path::route(&mut ground, &goals).expect("a route to the connection"); + assert!(route.goal.is_some()); + let (landed, clean) = walk(&ground, Tile::new(3, 16), &route.steps); + assert!(goals.contains(&landed)); + assert!(clean, "one plan, across sixteen tiles of map, every tile of it known ground"); + + // With only the window, every tile of that edge is `Unknown` and so every one of them is an + // exit, walls included: the search's own approach answer is what used to carry the walk. + let mut blind = Ground::new(20, 18, (3, 16)) + .connected(Connections { north: true, south: false, east: false, west: false }) + .window_only(); + let count = path::exits(&mut blind) + .iter() + .filter(|exit| exit.id == ExitId::Edge(Edge::North)) + .count(); + assert_eq!(count, 20); +} From 1b06e6a6cbbed1950b9fb1200db58edf99bf4613 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 04:10:28 +0000 Subject: [PATCH 05/13] gb: the collision tile is the lower left of a map tile s quadrant Measured on the cartridge, not derived: a map tile is 2x2 screen tiles and CheckTilePassable matches one id, and the screen agrees with the lower-left tile of the quadrant. Viridian Forest s (4, 32) reads 3, the second row of its block, where the first row holds bash4. On open ground most quadrants hold one id four times over, so the upper-left guess reads correctly on a town and falls apart in a forest -- which is why the decode is cross-checked against the screen before it is trusted. --- .../flybrain-gb/src/pokemon_red/fake_wram.rs | 4 +++- .../flybrain-gb/src/pokemon_red/mapgrid.rs | 22 ++++++++++++++++--- .../src/pokemon_red/mapgrid/tests.rs | 10 +++++---- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs index 02e58ac..96c4724 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs @@ -404,7 +404,9 @@ impl Wram { for x in 0..width * 2 { let Some(block) = blocks.get((y / 2) * width + (x / 2)) else { continue }; let Some(tiles) = blockset.get(usize::from(*block)) else { continue }; - let tile = tiles[(y % 2) * 2 * 4 + (x % 2) * 2]; + // The lower-left tile of the map tile's own quadrant, which is the one the + // cartridge's collision read uses (`mapgrid::ANCHOR_ROW`). + let tile = tiles[((y % 2) * 2 + 1) * 4 + (x % 2) * 2]; self.map_tile(x as u8, y as u8, tile); } } diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs index a812615..75db101 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs @@ -49,6 +49,22 @@ pub const MAP_BORDER: usize = 3; /// which is why `wCurMapWidth` in blocks is `map_size().width` in tiles divided by this. pub const TILES_PER_BLOCK: u8 = 2; +/// Which of a quadrant's two rows the collision read takes its tile id from: the lower one. +/// +/// A map tile is a 2x2 patch of screen tiles and only one of the four is ever asked about, because +/// `CheckTilePassable` matches a single tile id. Which one is **measured, not derived**: the +/// decoded ids were compared against the screen buffer on the cartridge, tile by tile, and the +/// screen agrees with the lower-left tile of each quadrant and not the upper-left -- Viridian +/// Forest's (4, 32) reads `$23`, the second row of its block, where the first row holds `$04` +/// (`tests/rom_map_grid.rs`, which is the test that pins it). +/// +/// That is the same corner `_GetTileAndCoordsInFrontOfPlayer` reads at screen `(8, 9)` for the +/// tile the player is standing on: the view is aligned so that the player's own 2x2 begins on +/// screen row 8, so row 9 is its lower half. An upper-left decode still answers, and answers +/// plausibly -- on the open ground of a town most quadrants hold one tile id four times over -- +/// which is why the cross-check in [`super::state::map_grid`] is not optional. +const ANCHOR_ROW: usize = 1; + /// Tileset ids the tile-pair lists name (`constants/tileset_constants.asm`, counted in the order /// that file declares them: OVERWORLD 0 … FOREST 3 … CAVERN 17). pub mod tileset { @@ -143,10 +159,10 @@ pub fn decode(map: u8, width_blocks: u8, height_blocks: u8, blocks: &[u8], tiles let Some(block) = blocks.get(block_index).copied() else { continue; }; - // The top left screen tile of the map tile's own 2x2 quadrant of the block, which is - // the corner every collision read in the game uses. + // The screen tile the collision read uses, inside the map tile's own 2x2 quadrant of + // the block: the **lower** left one ([`ANCHOR_ROW`]). let column = usize::from(x % TILES_PER_BLOCK) * usize::from(TILES_PER_BLOCK); - let row = usize::from(y % TILES_PER_BLOCK) * usize::from(TILES_PER_BLOCK); + let row = usize::from(y % TILES_PER_BLOCK) * usize::from(TILES_PER_BLOCK) + ANCHOR_ROW; let Some(tile) = tiles.tile(block, column, row) else { continue; }; diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs index 455ca32..da7c6c7 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs @@ -16,11 +16,13 @@ use crate::pokemon_red::macros::state::{Facing, Walkable}; fn blockset() -> Vec { let floor = [FLOOR; 16]; let wall = [WALL; 16]; + // The four ids sit on the rows the decode reads -- the *lower* row of each 2x2 quadrant + // (`ANCHOR_ROW`) -- and the rows it does not read hold ids that would be wrong answers. let quadrants = [ - NORTH_WEST, 0x90, NORTH_EAST, 0x91, // - 0x92, 0x93, 0x94, 0x95, // - SOUTH_WEST, 0x96, SOUTH_EAST, 0x97, // - 0x98, 0x99, 0x9a, 0x9b, + 0x90, 0x91, 0x92, 0x93, // + NORTH_WEST, 0x94, NORTH_EAST, 0x95, // + 0x96, 0x97, 0x98, 0x99, // + SOUTH_WEST, 0x9a, SOUTH_EAST, 0x9b, ]; let mut out = Vec::new(); out.extend_from_slice(&floor); From bcae388d998e3b9b65b639f53d3fc4ca0aff901d Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 04:10:28 +0000 Subject: [PATCH 06/13] gb: re-check a cached grid against the screen when it is served A warp writes wCurMap before the map header and the block data: on Oak s lab doormat wCurMap already reads PALLET_TOWN while the header still reads the lab s ten-by-twelve. The decode agrees with the screen on such a frame -- both are the old map -- so only the id is wrong, and a grid filed under it would stay wrong for as long as it was cached. One byte answers it: does the cached grid still agree with the screen about the tile the fly is standing on. --- .../flybrain-gb/src/pokemon_red/state.rs | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index 56af83d..74e4858 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -939,6 +939,34 @@ pub fn map_grid(memory: &mut dyn MemoryReader) -> Result { Ok(grid) } +/// Whether a cached grid is still the map that is loaded, checked from the tile the fly is on. +/// +/// The map id, the map header and the block data are written by different parts of a warp, so +/// there is a frame or two on the way through a door where `wCurMap` is the map the fly is +/// arriving on and the header and the blocks are still the map it is leaving: the decode agrees +/// with the screen (both are the old map) and is filed under the new id. Measured on the +/// cartridge — the fly on Oak's lab doormat with `wCurMap` already reading `PALLET_TOWN` and the +/// header still the lab's ten-by-twelve (`tests/rom_map_grid.rs`). +/// +/// Nothing in WRAM says "the map has finished loading", so the cache asks the cheapest question +/// that can tell: does the grid still agree with the screen about the tile the fly is standing on? +/// One byte, once per question. A grid that does not is dropped and decoded again, so a torn +/// frame's grid lives exactly as long as the tear does — and through it the cartridge is walking +/// the fly, which is not a frame any macro plans on. +fn still_the_loaded_map( + memory: &mut dyn MemoryReader, + grid: &MapGrid, + x: u8, + y: u8, +) -> bool { + match map_tile_id(memory, x, y) { + // The screen is not showing the map (a battle, a text box): nothing to check against, and + // the grid was checked when it was decoded. + None => true, + Some(tile) => grid.tile_id(x, y) == Some(tile), + } +} + /// The player's own tile and its four neighbours, which is every tile the window is certain to be /// able to answer for from where the fly is standing. fn neighbourhood(x: u8, y: u8) -> Vec<(u8, u8)> { @@ -1233,10 +1261,11 @@ impl MacroState for PokeState<'_> { /// falls back to the ten-by-nine window predicate then, which is what all of them did before /// this existed. [`GridRefusal`] names which, for the probes. fn map_grid(&mut self) -> Option> { + let player = player(self.memory)?; let size = map_size(self.memory)?; - let map = player(self.memory)?.map; if let Some(grids) = self.grids.as_deref() - && let Some(grid) = grids.get(map, size.width, size.height) + && let Some(grid) = grids.get(player.map, size.width, size.height) + && still_the_loaded_map(self.memory, &grid, player.x, player.y) { return Some(grid); } From 246e0c7d4c36deae4cbec23a67950745f20e9096 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 04:12:19 +0000 Subject: [PATCH 07/13] flysim: the map grid in the probes scene_probe prints the grid s size, its walkable count, the count reachable from where the fly stands and the count never stood on, draws the ground with the reading it actually used, and names the refusal when there is no grid. trap_hunt carries the same line into every trace line and into the summary, so a stalled walk can be read at a glance: a fly with 719 walkable tiles and 4 reachable ones is fenced in and no re-plan will help it. --- .../crates/flysim/examples/scene_probe.rs | 50 ++++++++++++++++-- .../crates/flysim/examples/trap_hunt.rs | 51 ++++++++++++++++++- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index e6785b6..09253ee 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -157,6 +157,10 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) { 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(); let ledger = AdapterLedger(adapter); let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger); let state: &mut dyn MacroState = &mut poke; @@ -182,16 +186,52 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) { 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() + ); + } + } // 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. - println!("\n### The ground (`v` = the adapter's lifetime ledger only)\n\n```"); + // 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 = (0..size.width) .map(|x| { - let walk = match state.walkable(x, y) { + 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 => '?', @@ -208,7 +248,11 @@ fn pad(gb: &mut Emulator, adapter: &PokemonRedReward, label: &str) { let mut n = 0; for y in 0..size.height { for x in 0..size.width { - if state.walkable(x, y) == Walkable::Yes && !state.tile_visited(x, y) { + 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; } } diff --git a/services/flysim/crates/flysim/examples/trap_hunt.rs b/services/flysim/crates/flysim/examples/trap_hunt.rs index 9f4dea0..f3e4bdb 100644 --- a/services/flysim/crates/flysim/examples/trap_hunt.rs +++ b/services/flysim/crates/flysim/examples/trap_hunt.rs @@ -205,6 +205,12 @@ struct Trace { /// Every input of the detector's disputed branch on the last frame /// (`pokemon_red::scene::why_unknown`). ended_why: String, + /// The whole-map grid on the last frame, as [`grid_line`] reads it + /// (`docs/design/macros.md` section 15): how much of the map is ground, how much of it the fly + /// could reach from where it stopped, and how much of that it had never stood on. A hunt that + /// ends with reachable far below walkable ended fenced in, which no amount of re-planning was + /// ever going to fix. + ended_grid: String, /// How the dialog branch's two halves agreed, per frame: `wFontLoaded`'s bit against the four /// corners and against the whole `TextBoxBorder` (`pokemon_red::state::dialog_border`). /// @@ -346,6 +352,7 @@ fn run( ended_in: ("", String::new()), longest_scene: BTreeMap::new(), ended_why: String::new(), + ended_grid: String::new(), font_corners_border: 0, font_corners_no_border: 0, font_no_corners: 0, @@ -504,9 +511,10 @@ fn run( let ahead = Tile::new(player.x, player.y).step(player.facing)?; flybrain_gb::pokemon_red::macros::path::target_at(state, ahead) }); + let ground = grid_line(state, player); let why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator); println!( - "trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}", + "trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}\n {ground}", (ms - began_ms) / MINUTE_MS ); } @@ -577,6 +585,13 @@ fn run( adapter.mode().to_string(), ); trace.ended_why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator); + trace.ended_grid = { + use flybrain_gb::pokemon_red::macros::cartridge::MacroState; + let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator); + let state: &mut dyn MacroState = &mut state; + let player = state.player(); + grid_line(state, player) + }; trace.ended_ms = agent.network.ms; trace.wall_seconds = began_wall.elapsed().as_secs_f64(); trace @@ -675,6 +690,39 @@ fn walk_report(trace: &Trace) { } } +/// The whole-map grid in one line: what a stalled walk looks like from outside. +/// +/// `docs/design/macros.md` section 15. Walkable is how much of the map is ground, reachable is +/// how much of that the fly can get to from where it is standing (the directed walls respected), +/// and unstood is how much of *that* this run has never been on -- which is the frontier's own +/// candidate pool. A walk that cannot finish is one of three shapes and these numbers tell them +/// apart: fenced in (reachable far below walkable), nothing left to explore (unstood zero), or no +/// grid at all, in which case the walks are back on the ten-by-nine window and the reason is +/// named. +fn grid_line( + state: &mut dyn flybrain_gb::pokemon_red::macros::cartridge::MacroState, + player: Option, +) -> String { + let Some(player) = player else { return "grid: no player".to_string() }; + let Some(grid) = state.map_grid() else { + return "grid: none".to_string(); + }; + let unstood = grid + .walkable_tiles() + .into_iter() + .filter(|(x, y)| !state.tile_visited(*x, *y)) + .count(); + format!( + "grid map={:#04x} {}x{} walkable={} reachable={} unstood={}", + grid.map(), + grid.width(), + grid.height(), + grid.walkable_count(), + grid.reachable_from(player.x, player.y), + unstood + ) +} + fn main() { let Some(path) = std::env::var_os("FLY_ROM") else { println!( @@ -749,6 +797,7 @@ fn main() { println!("| --- | ---: |"); println!("| rung reached | {} |", trace.rungs.iter().map(|(rank, ..)| *rank).max().unwrap_or(0)); println!("| distinct (map, tile) | {} |", ground.len()); + println!("| the map at the end | {} |", trace.ended_grid); println!("| macros started | {} |", trace.starts.len()); for (outcome, count) in &trace.outcomes { println!("| {outcome} | {count} |"); From 44a701129967fe4fabb796b1dfe74caba87c604c Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 04:12:19 +0000 Subject: [PATCH 08/13] tests: the grid against the cartridge on two maps ROM-gated and checkpoint-gated, skipped cleanly without either. The decode against the window predicate on every tile the window can answer for; the decode against a survey of real presses, where every refused press has to be a wall, a directed wall or a sprite in the way, and every step the cartridge made has to be one the grid would have planned; GO FRONTIER aiming at ground outside the window and walking there; and every way out of the map reachable in one plan with no guessed tile in it. Pallet Town: 221 walkable, 207 reachable, 90 window tiles, 120 surveyed, 58 refused presses. Viridian Forest: 719 walkable, all reachable, 90 window tiles, 120 surveyed, 74 refused presses, a 215-frame frontier walk out of the window and ways out 27 to 149 steps away. --- .../crates/flysim/tests/rom_map_grid.rs | 733 ++++++++++++++++++ 1 file changed, 733 insertions(+) create mode 100644 services/flysim/crates/flysim/tests/rom_map_grid.rs diff --git a/services/flysim/crates/flysim/tests/rom_map_grid.rs b/services/flysim/crates/flysim/tests/rom_map_grid.rs new file mode 100644 index 0000000..5d1d930 --- /dev/null +++ b/services/flysim/crates/flysim/tests/rom_map_grid.rs @@ -0,0 +1,733 @@ +//! The whole-map walkability grid against the cartridge (`docs/design/macros.md` section 15). +//! +//! Gated on `FLY_ROM` and on a `FLYSIM01` checkpoint, and skips cleanly without either. The +//! checkpoints live outside the tree (`.local/` is not tracked) and are the release container's +//! own states, pulled read-only: +//! +//! ```sh +//! FLY_ROM="$HOME/…/Pokemon Red (U) [S][BF].gb" \ +//! FLY_GRID_CHECKPOINT=.local/checkpoints/rank9-viridian-forest.checkpoint \ +//! cargo test --release -p flysim --test rom_map_grid -- --nocapture +//! ``` +//! +//! ## What only the cartridge can answer +//! +//! The unit tests in `pokemon_red/mapgrid/tests.rs` decode a made-up tileset, and the ones in +//! `pokemon_red/state/tests.rs` decode synthetic WRAM with a synthetic blockset in a synthetic +//! bank. Neither can say that those are the bytes the *game* writes: a wrong border, a wrong +//! stride or the wrong corner of a block all still answer, and answer plausibly. Three things here +//! need a running cartridge: +//! +//! 1. **the decode against the window predicate**, on every tile of the map the ten-by-nine +//! screen window can answer for — the same comparison the reader makes before it trusts +//! itself, done over the whole window rather than the player's own neighbourhood; +//! 2. **the decode against real presses** (the survey method of `docs/design/room-escape.md` +//! section 3): every tile the survey can stand on is `Yes`, and every step it refuses is `No`, +//! a directed wall, or a tile a sprite is standing on; +//! 3. **the walks**: that `GO FRONTIER` aims at ground outside the window and reaches it, and that +//! a way out of the map is one plan away rather than a re-plan at every window edge. + +use std::collections::{BTreeMap, BTreeSet}; + +use flybrain_gb::macros::{MacroPalette, Started}; +use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile}; +use flybrain_gb::pokemon_red::macros::palette::Palette; +use flybrain_gb::pokemon_red::macros::state::{Facing, MapGrid, Walkable}; +use flybrain_gb::pokemon_red::macros::{PokemonPalette, path}; +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, buttons, +}; + +/// One game frame at the Game Boy's real rate, for the adapter's brain clock. +const MS_PER_FRAME: f64 = 1000.0 / 59.7275; + +/// Frames a restored state is given before anything reads the screen buffer. +/// +/// `wTileMap` is the current view only on a *running* machine: read straight after +/// `import_state`, with no frame in between, it holds the view from wherever the state was taken +/// (`docs/design/macros-wram.md`). Twenty is what `tests/rom_scene.rs` gives every restored state +/// and what the survey below gives every one of its own. +const SETTLE_FRAMES: u32 = 20; + +/// The seed the macro tests run the executor with. Nothing here depends on it: the slot is chosen +/// by name, not by a readout. +const SEED: u32 = 20_260_922; + +fn rom() -> Option> { + let path = std::env::var_os("FLY_ROM")?; + match std::fs::read(&path) { + Ok(bytes) => Some(bytes), + Err(error) => panic!("FLY_ROM is set to {path:?} but could not be read: {error}"), + } +} + +fn checkpoint() -> Option { + let path = std::env::var_os("FLY_GRID_CHECKPOINT") + .or_else(|| std::env::var_os("FLY_TRAP_CHECKPOINT")) + .or_else(|| std::env::var_os("FLY_MACRO_CHECKPOINT"))?; + Some( + flysim::store::load(std::path::Path::new(&path)) + .expect("the checkpoint should be a FLYSIM01 envelope"), + ) +} + +macro_rules! skip_without { + () => { + match (rom(), checkpoint()) { + (Some(rom), Some(checkpoint)) => (rom, checkpoint), + (None, _) => { + eprintln!("skipped: FLY_ROM is not set"); + return; + } + (_, None) => { + eprintln!("skipped: no FLY_GRID_CHECKPOINT / FLY_TRAP_CHECKPOINT"); + return; + } + } + }; +} + +fn emulator(rom: &[u8]) -> Emulator { + Emulator::new(rom, DEFAULT_AUDIO_FREQUENCY, DEFAULT_AUDIO_FRAMES) + .expect("binjgb should accept the cartridge") +} + +/// A cartridge resumed from a checkpoint, with the reward ledger the checkpoint carried and the +/// macro palette the sim loop runs. +struct Game { + gb: Emulator, + adapter: PokemonRedReward, + palette: PokemonPalette, + ms: f64, +} + +impl Game { + fn resume(rom: &[u8], checkpoint: &flysim::store::Checkpoint) -> Self { + let mut gb = emulator(rom); + 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 mut game = + Self { gb, adapter, palette: PokemonPalette::new(SEED), ms: 0.0 }; + game.settle_overworld(); + if let Some(target) = std::env::var("FLY_GRID_TO_MAP") + .ok() + .and_then(|value| u8::from_str_radix(value.trim_start_matches("0x"), 16).ok()) + { + game.reach_map(target); + } + game + } + + /// Drive the cartridge with raw presses until the fly is standing on `target`. + /// + /// The test's knowledge of the game, not the fly's, and the same device + /// `tests/rom_scene.rs`'s biased walk is: it decides which state gets *produced* and nothing + /// it does is asserted. What it is for is the second map of the survey + /// (`docs/design/macros.md` section 15 asks for two): no checkpoint in `.local/` is standing + /// on Pallet Town, and Oak's lab is one door south of it. + /// + /// A biased random walk rather than the macros, deliberately: driving the state under test + /// into place with the macros under test is circular, and a walk needs no map knowledge. The + /// cycle is `tests/rom_scene.rs`'s — a direction, then B, which closes whatever a stray press + /// opened. + fn reach_map(&mut self, target: u8) { + let mut seed = u64::from(SEED); + let toward = if target < self.map() { buttons::DOWN } else { buttons::UP }; + let mut arrived: Option = None; + let mut walked = 0; + for frame in 0..90_000u32 { + if self.map() == target && walked > 0 { + eprintln!( + "reached map {target:#04x} after {frame} frames, {walked} tiles into it, at \ + {:?}", + self.tile() + ); + self.settle_overworld(); + return; + } + // A few tiles *into* the map, not the doormat: a warp writes the map id before the + // header and the blocks, so the arrival frame itself is the torn one + // (`pokemon_red::state::still_the_loaded_map`). + if self.map() == target && self.tile() != arrived.unwrap_or(Tile::new(255, 255)) { + match arrived { + None => arrived = Some(self.tile()), + Some(_) => walked += 1, + } + } + seed = seed + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + let random = [buttons::UP, buttons::DOWN, buttons::LEFT, buttons::RIGHT] + [((seed >> 33) % 4) as usize]; + // Half biased, half random: the random half is what gets the walk around the + // furniture the bias walks it into. + let step = if (seed >> 41).is_multiple_of(2) { toward } else { random }; + let mask = match frame % 48 { + 0..=7 | 24..=31 => step, + 12..=15 => buttons::B, + _ => buttons::NONE, + }; + self.frame(mask); + } + assert_eq!(self.map(), target, "FLY_GRID_TO_MAP: never reached map {target:#04x}"); + } + + /// Settle a restored state until the cartridge is showing the overworld, pressing nothing. + /// + /// Two reasons, both measured. `wTileMap` is the current view only on a *running* machine, so + /// every restored state needs frames before anything reads the screen + /// (`docs/design/macros-wram.md`); and a checkpoint is a frame of a live run, which can be + /// mid-step, mid-warp or inside a script that is walking the fly — Viridian City's own + /// checkpoint reads `Scene::Unknown` and its screen buffer is a tile out of step with its + /// coordinates, which is exactly the frame [`state::map_grid`] refuses rather than decodes. + /// A grid is a question about the overworld, so the test asks it there. + fn settle_overworld(&mut self) { + for _ in 0..1_200 { + self.frame(buttons::NONE); + if matches!(scene::detect(&mut self.gb), scene::Scene::Overworld) { + self.settle(SETTLE_FRAMES); + return; + } + } + eprintln!("the restored state never settled into the overworld"); + } + + /// Run frames with nothing held, sampling the adapter and observing the palette as the sim + /// loop does. + fn settle(&mut self, frames: u32) { + for _ in 0..frames { + self.frame(buttons::NONE); + } + } + + /// One frame with these buttons held, then the adapter's sample and the palette's observation + /// — the sim loop's order. + fn frame(&mut self, mask: u8) { + self.gb.set_buttons(mask); + self.gb.run_frame().expect("a frame should complete"); + self.ms += MS_PER_FRAME; + self.adapter.sample(&mut self.gb, self.ms); + let ledger = AdapterLedger(&self.adapter); + self.palette.clock(self.ms); + let _ = self.palette.observe(&mut self.gb, &ledger); + } + + fn map(&mut self) -> u8 { + self.gb.read_wram(ram::wCurMap) + } + + fn tile(&mut self) -> Tile { + Tile::new(self.gb.read_wram(ram::wXCoord), self.gb.read_wram(ram::wYCoord)) + } + + /// The decoded grid, or the reason there is none. + fn grid(&mut self) -> Result { + state::map_grid(&mut self.gb) + } + + /// Run `body` with the state the macros read, over this game's ledgers. + fn with_state(&mut self, body: impl FnOnce(&mut dyn MacroState) -> T) -> T { + let ledger = AdapterLedger(&self.adapter); + let mut poke = state::PokeState::with_ledger(&mut self.gb, &ledger); + body(&mut poke) + } + + /// The slot this scene binds `name` to, or `None` when the button is not on the pad. + fn slot(&mut self, name: &str) -> Option { + self.with_state(|state| { + let scene = state.scene(); + let palette = Palette::for_scene(scene, state); + palette + .slots + .iter() + .enumerate() + .find(|(_, spec)| spec.is_some_and(|spec| spec.name == name)) + .map(|(slot, _)| slot as u8) + }) + } + + /// Start `name` and run it to its outcome, returning the outcome's label and the frames spent. + /// + /// The slot is chosen by name rather than by a readout, which is what makes this a test of the + /// macro instead of a test of the decoder: `docs/design/macros.md` section 12 has the fly + /// choosing, and what is under test here is what the chosen walk does. + fn run_macro(&mut self, name: &str) -> Option<(String, u32)> { + let slot = self.slot(name)?; + let ledger = AdapterLedger(&self.adapter); + match self.palette.start(slot, &mut self.gb, &ledger) { + Started::Refused { reason, .. } => Some((format!("refused: {reason}"), 0)), + Started::Running(_) => { + let mut frames = 0; + loop { + let ledger = AdapterLedger(&self.adapter); + let mask = self.palette.step(&mut self.gb, &ledger); + match mask { + None => break, + Some(mask) => { + self.frame(mask); + frames += 1; + } + } + if frames > 4_000 { + break; + } + } + let outcome = self + .palette + .take_finished() + .map(|(_, outcome)| format!("{outcome:?}")) + .unwrap_or_else(|| "none".to_string()); + Some((outcome, frames)) + } + } + } +} + +/// Whether a tile is inside the ten-by-nine window the screen buffer can answer for. +fn in_window(player: Tile, x: u8, y: u8) -> bool { + let dx = i32::from(x) - i32::from(player.x); + let dy = i32::from(y) - i32::from(player.y); + (-4..=5).contains(&dx) && (-4..=4).contains(&dy) +} + +#[test] +fn the_decoded_grid_agrees_with_the_window_on_every_tile_the_window_can_answer() { + let (rom, checkpoint) = skip_without!(); + let mut game = Game::resume(&rom, &checkpoint); + let map = game.map(); + let here = game.tile(); + let grid = match game.grid() { + Ok(grid) => grid, + Err(refusal) => { + eprintln!("skipped: no grid on map {map:#04x} ({})", refusal.label()); + return; + } + }; + eprintln!( + "map {map:#04x} {}x{} at {here:?}: walkable {}, reachable {}, unknown {}", + grid.width(), + grid.height(), + grid.walkable_count(), + grid.reachable_from(here.x, here.y), + grid.unknown_count() + ); + assert_eq!(grid.map(), map); + assert_eq!(grid.unknown_count(), 0, "every block of the map is in the blockset"); + + let mut checked = 0; + let mut disagreements = Vec::new(); + for y in 0..grid.height() { + for x in 0..grid.width() { + let Some(tile) = state::map_tile_id(&mut game.gb, x, y) else { continue }; + let window = state::walkable(&mut game.gb, x, y); + checked += 1; + if grid.tile_id(x, y) != Some(tile) || grid.walkable(x, y) != window { + disagreements.push((x, y, tile, grid.tile_id(x, y), window, grid.walkable(x, y))); + } + assert!(in_window(here, x, y), "the window answered outside its own window"); + } + } + eprintln!("{checked} tiles the window can answer for, {} disagreements", disagreements.len()); + assert!(disagreements.is_empty(), "{disagreements:?}"); + assert!(checked >= 40, "the window should answer for most of its ninety tiles: {checked}"); +} + +/// The survey: walk the map with real presses on throwaway emulators, tile by tile. +/// +/// `docs/design/room-escape.md` section 3's method, and the one `tests/rom_scene.rs` used to pin +/// the window predicate's screen origin. Each tile keeps the state the walk arrived on, so every +/// press is made from the tile it belongs to rather than from wherever a long walk ended up. +/// +/// Bounded by `FLY_GRID_SURVEY_TILES` (default [`SURVEY_TILES`]) because a whole forest is +/// 700-odd tiles and four presses each, and the claim is about the *rule*, not about coverage: a +/// wrong corner, a wrong stride or a wrong border disagrees within a dozen tiles. +type Survey = ( + BTreeMap>, + BTreeSet<(Tile, &'static str)>, + BTreeSet<(Tile, &'static str)>, + BTreeSet<(Tile, &'static str)>, + BTreeSet<(Tile, &'static str)>, +); + +/// Tiles the survey stands on before it stops, unless `FLY_GRID_SURVEY_TILES` says otherwise. +const SURVEY_TILES: usize = 120; + +/// The four presses, with the direction each one is and the tile offset it aims at. +const PRESSES: [(&str, u8, Facing); 4] = [ + ("up", buttons::UP, Facing::Up), + ("down", buttons::DOWN, Facing::Down), + ("left", buttons::LEFT, Facing::Left), + ("right", buttons::RIGHT, Facing::Right), +]; + +fn survey(rom: &[u8], start: &[u8], budget: usize) -> Survey { + let read = |probe: &mut Emulator| { + ( + probe.read_wram(ram::wCurMap), + Tile::new(probe.read_wram(ram::wXCoord), probe.read_wram(ram::wYCoord)), + ) + }; + // 120 frames of held direction, then a release and twenty frames to settle, which are the two + // numbers `docs/design/macros-wram.md` measured: a press the player is not already facing + // turns first and steps second (53 frames from one staircase), and a state exported with a + // button held does not respond to that button after the import. + let step = |probe: &mut Emulator, mask: u8| { + let before = read(probe); + probe.set_buttons(mask); + let mut moved = false; + for _ in 0..120 { + probe.run_frame().expect("a frame should complete"); + if read(probe) != before { + moved = true; + break; + } + } + probe.set_buttons(buttons::NONE); + for _ in 0..SETTLE_FRAMES { + probe.run_frame().expect("a frame should complete"); + } + (read(probe), moved) + }; + let restore = |state: &[u8]| { + let mut probe = emulator(rom); + probe.import_state(state).expect("a surveyed state should import"); + probe + }; + + let mut first = restore(start); + for _ in 0..SETTLE_FRAMES { + first.run_frame().expect("a frame should complete"); + } + let (map, here) = read(&mut first); + let mut states: BTreeMap> = BTreeMap::new(); + let mut refused: BTreeSet<(Tile, &'static str)> = BTreeSet::new(); + let mut left: BTreeSet<(Tile, &'static str)> = BTreeSet::new(); + let mut interrupted: BTreeSet<(Tile, &'static str)> = BTreeSet::new(); + let mut blocked_by_sprite: BTreeSet<(Tile, &'static str)> = BTreeSet::new(); + states.insert(here, first.export_state().expect("a settled state should export")); + let mut queue = std::collections::VecDeque::from([here]); + while let Some(tile) = queue.pop_front() { + if states.len() >= budget { + break; + } + for (name, mask, _) in PRESSES { + let mut probe = restore(&states[&tile]); + // A press the cartridge answers with something other than a step -- a wild encounter + // in the grass, a bug catcher's line of sight, a script that takes the joypad -- says + // nothing about the ground either way, so the survey records it as its own category + // rather than as a wall. Viridian Forest is full of them: three tiles of the first + // hundred and twenty started a battle in three directions each. + if !matches!(scene::detect(&mut probe), scene::Scene::Overworld) { + interrupted.insert((tile, name)); + continue; + } + let ((there, at), moved) = step(&mut probe, mask); + if !matches!(scene::detect(&mut probe), scene::Scene::Overworld) { + interrupted.insert((tile, name)); + continue; + } + if !moved { + // **A person in the way is not a wall.** The collision table knows nothing about + // sprites and says so (`docs/design/macros-wram.md`), and Pallet Town's two + // villagers walk: whether one was standing on the target tile has to be read from + // the frame the press was made in, not from the state the survey started in. + let facing = PRESSES + .iter() + .find(|(press, _, _)| *press == name) + .map(|(_, _, facing)| *facing) + .expect("a known press"); + let ahead = tile.step(facing); + let sprite = ahead.is_some_and(|ahead| { + state::npcs(&mut probe).iter().any(|npc| (npc.x, npc.y) == (ahead.x, ahead.y)) + }); + if sprite { + blocked_by_sprite.insert((tile, name)); + } else { + refused.insert((tile, name)); + } + continue; + } + if there != map { + // A warp: the press left the map, so it says nothing about the ground here. + left.insert((tile, name)); + continue; + } + if let std::collections::btree_map::Entry::Vacant(entry) = states.entry(at) { + entry.insert(probe.export_state().expect("state export")); + queue.push_back(at); + } + } + } + (states, refused, left, interrupted, blocked_by_sprite) +} + +#[test] +fn the_decoded_grid_matches_a_survey_with_real_presses() { + let (rom, checkpoint) = skip_without!(); + let mut game = Game::resume(&rom, &checkpoint); + let map = game.map(); + let grid = match game.grid() { + Ok(grid) => grid, + Err(refusal) => { + eprintln!("skipped: no grid on map {map:#04x} ({})", refusal.label()); + return; + } + }; + let npcs: BTreeSet = game + .with_state(|state| state.npcs().iter().map(|npc| Tile::new(npc.x, npc.y)).collect()); + let budget = std::env::var("FLY_GRID_SURVEY_TILES") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(SURVEY_TILES); + let start = game.gb.export_state().expect("the settled state should export"); + let (stood, refused, left, interrupted, sprites) = survey(&rom, &start, budget); + eprintln!( + "survey of map {map:#04x}: {} tiles stood on, {} refused presses, {} presses that left \ + the map, {} the cartridge answered with a battle or a script, {} a sprite was standing \ + in the way of", + stood.len(), + refused.len(), + left.len(), + interrupted.len(), + sprites.len() + ); + assert!(stood.len() > 8, "the survey barely moved: {} tiles", stood.len()); + + // Every tile the survey stood on is ground the grid calls walkable. This is the half that + // catches a decode that is too *strict* — a wall where the game lets the fly stand. + let mut wrong_walls = Vec::new(); + for tile in stood.keys() { + if grid.walkable(tile.x, tile.y) != Walkable::Yes { + wrong_walls.push((*tile, grid.walkable(tile.x, tile.y), grid.tile_id(tile.x, tile.y))); + } + } + assert!(wrong_walls.is_empty(), "tiles the survey stood on that the grid calls walls: {wrong_walls:?}"); + + // Every press the cartridge refused is a wall in the grid, a directed wall out of that tile, + // or a tile a sprite is standing on — the three things a refusal can be + // (`docs/design/macros.md` section 15). This is the half that catches a decode that is too + // *permissive*. + let mut unexplained = Vec::new(); + for (tile, name) in &refused { + let (_, _, facing) = PRESSES.iter().find(|(press, _, _)| press == name).copied().unwrap(); + let ahead = tile.step(facing); + let explained = match ahead { + None => true, + Some(ahead) => { + ahead.x >= grid.width() + || ahead.y >= grid.height() + || grid.walkable(ahead.x, ahead.y) != Walkable::Yes + || grid.walled(tile.x, tile.y, facing) + || npcs.contains(&ahead) + } + }; + if !explained { + unexplained.push(( + *tile, + grid.tile_id(tile.x, tile.y), + *name, + ahead, + ahead.map(|at| grid.tile_id(at.x, at.y)), + grid.walled(tile.x, tile.y, facing), + )); + } + } + assert!( + unexplained.is_empty(), + "presses the cartridge refused that the grid calls open ground: {unexplained:?}" + ); + + // And every step that *worked* is one the grid would have planned: walkable, not walled. + let mut wrongly_walled = Vec::new(); + for tile in stood.keys() { + for (name, _, facing) in PRESSES { + if refused.contains(&(*tile, name)) + || left.contains(&(*tile, name)) + || interrupted.contains(&(*tile, name)) + || sprites.contains(&(*tile, name)) + { + continue; + } + let Some(ahead) = tile.step(facing) else { continue }; + if !stood.contains_key(&ahead) { + continue; + } + if grid.walkable(ahead.x, ahead.y) != Walkable::Yes + || grid.walled(tile.x, tile.y, facing) + { + wrongly_walled.push((*tile, name, ahead)); + } + } + } + assert!( + wrongly_walled.is_empty(), + "steps the cartridge made that the grid calls walls: {wrongly_walled:?}" + ); + eprintln!( + "the grid agrees with every one of {} stood tiles and {} refused presses on map {map:#04x}", + stood.len(), + refused.len() + ); +} + +#[test] +fn go_frontier_aims_outside_the_window_and_walks_there() { + let (rom, checkpoint) = skip_without!(); + let mut game = Game::resume(&rom, &checkpoint); + let map = game.map(); + let Ok(grid) = game.grid() else { + eprintln!("skipped: no grid on map {map:#04x}"); + return; + }; + + // **The plan.** Every frontier tile the ten-by-nine window cannot answer for is ground the + // fly could not have aimed at before section 15, and a route to the nearest of them is a + // plan that crosses the window with no guesses in it. + let (here, outside, plan) = game.with_state(|state| { + let here = state.player().map(|player| Tile::new(player.x, player.y)).expect("a player"); + let outside: Vec = path::frontier(state) + .into_iter() + .map(|(tile, _)| tile) + .filter(|tile| !in_window(here, tile.x, tile.y)) + .collect(); + let plan = path::route(state, &outside); + (here, outside, plan) + }); + eprintln!( + "map {map:#04x} at {here:?}: {} frontier tiles outside the window, plan {:?} steps", + outside.len(), + plan.as_ref().map(|route| route.steps.len()) + ); + if outside.is_empty() { + eprintln!("skipped: every frontier tile on this map is inside the window"); + return; + } + let plan = plan.expect("a route to the frontier beyond the window"); + assert!(plan.goal.is_some(), "the plan only approaches the frontier"); + let mut at = here; + for facing in &plan.steps { + at = at.step(*facing).expect("a step inside the map"); + assert_eq!( + grid.walkable(at.x, at.y), + Walkable::Yes, + "the plan walks through {at:?}, which the grid does not call ground" + ); + } + assert!(outside.contains(&at), "the plan ends on {at:?}, which is not one of its goals"); + assert!( + !in_window(here, at.x, at.y), + "the plan ends on {at:?}, which the window could have answered for anyway" + ); + + // **The walk.** The fly's own choice is not being simulated here — the slot is started by + // name — but everything after that is the shipping executor: one plan, the per-step moved + // check, the three-failure rule, the frame cap. What is asserted is that some hold of + // `GO FRONTIER` ends on ground that was outside the window when the hold began, which is the + // whole of the operator's ask. + let mut left_the_window = None; + let mut left_the_map = None; + let mut done = 0; + for hold in 0..12 { + let began = game.tile(); + let Some((outcome, frames)) = game.run_macro("GO FRONTIER") else { + eprintln!("GO FRONTIER left the pad after {hold} holds"); + break; + }; + let landed = game.tile(); + if outcome == "Done" { + done += 1; + } + eprintln!("hold {hold}: {began:?} -> {landed:?} in {frames} frames, {outcome}"); + if !in_window(began, landed.x, landed.y) { + left_the_window = Some((hold, began, landed, frames)); + break; + } + if game.map() != map { + left_the_map = Some((hold, game.map())); + break; + } + } + assert!(done > 0, "no hold of GO FRONTIER finished"); + match (left_the_window, left_the_map) { + (Some((hold, began, landed, frames)), _) => eprintln!( + "GO FRONTIER walked out of its own window on hold {hold}: {began:?} -> {landed:?} in \ + {frames} frames" + ), + // A doormat is unstood ground and the frontier is allowed to aim at it + // (`docs/design/macros.md` section 12.7: the reward ledger can never record a warp tile), + // so a near frontier that is a door takes the fly off the map before the far one is + // reached. That is the frontier's own rule rather than the grid's doing, and the plan + // above is what this test is for; the arrival is asserted on a map with no door next to + // the fly. + (None, Some((hold, map))) => eprintln!( + "GO FRONTIER stepped onto a warp tile on hold {hold} and left for map {map:#04x} \ + before it left its window" + ), + (None, None) => panic!("no hold of GO FRONTIER left the window it started in"), + } +} + +#[test] +fn a_way_out_of_the_map_is_one_plan_away() { + let (rom, checkpoint) = skip_without!(); + let mut game = Game::resume(&rom, &checkpoint); + let map = game.map(); + let Ok(grid) = game.grid() else { + eprintln!("skipped: no grid on map {map:#04x}"); + return; + }; + let (here, plans) = game.with_state(|state| { + let here = state.player().map(|player| Tile::new(player.x, player.y)).expect("a player"); + let exits = path::exits(state); + let mut plans = Vec::new(); + for exit in &exits { + if let Some(route) = path::route(state, &[exit.tile]) { + plans.push((exit.id, exit.tile, exit.way, route.goal.is_some(), route.steps.clone())); + } + } + (here, plans) + }); + assert!(!plans.is_empty(), "a map with no way out at all"); + let mut crossed = 0; + let mut reached_one = false; + for (id, tile, way, reached, steps) in &plans { + // Every tile of the plan is ground the grid calls walkable: a plan with no guesses in it, + // which is what "one plan per walk" needs to mean. + let mut at = here; + let mut guesses = 0; + for facing in steps { + at = at.step(*facing).expect("a step inside the map"); + if grid.walkable(at.x, at.y) != Walkable::Yes { + guesses += 1; + } + } + eprintln!( + "{id:?} at {tile:?} ({way:?}): {} steps, reached {reached}, {guesses} tiles the grid \ + does not call ground", + steps.len() + ); + assert_eq!(guesses, 0, "the plan to {tile:?} walks through {guesses} tiles of not-ground"); + if !*reached { + // An exit tile the map fences off from where the fly stands: the grid *knows* it + // cannot be reached, which is the honest answer and is what the closest-approach route + // is for. Pallet Town's north-west corner is one — walkable ground behind a fence. + eprintln!(" (that one only approaches: {} of {} tiles reachable)", 0, steps.len()); + continue; + } + reached_one = true; + assert_eq!(at, *tile, "the plan to {tile:?} ends on {at:?}"); + if steps.len() > 9 { + crossed += 1; + } + } + assert!(reached_one, "no way out of map {map:#04x} can be reached at all"); + assert!( + crossed > 0, + "no way out of map {map:#04x} is further than the window, so this checkpoint cannot show \ + the difference" + ); +} + From abd3e563a8dc5c15dd4f4a718f2c72e427598a09 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 04:15:29 +0000 Subject: [PATCH 09/13] gb: refuse a map header that does not fit wOverworldMap The buffer is ds 1300 and every real map plus its three-block border fits in it. A header that says otherwise is one read mid-load, and decoding it would read past the buffer into somebody else s WRAM, so it is a refusal rather than a clamp. --- .../flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs | 7 +++++++ .../flysim/crates/flybrain-gb/src/pokemon_red/state.rs | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs index 75db101..a9184ea 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs @@ -49,6 +49,13 @@ pub const MAP_BORDER: usize = 3; /// which is why `wCurMapWidth` in blocks is `map_size().width` in tiles divided by this. pub const TILES_PER_BLOCK: u8 = 2; +/// Bytes `wOverworldMap` has for the loaded map: `ds 1300` (`ram/wram.asm`). +/// +/// A map plus its border of [`MAP_BORDER`] blocks has to fit in this, and every real map does. A +/// header that says otherwise is a header read mid-load, which is why the bound is a refusal +/// rather than a clamp. +pub const OVERWORLD_MAP_BYTES: usize = 1300; + /// Which of a quadrant's two rows the collision read takes its tile id from: the lower one. /// /// A map tile is a 2x2 patch of screen tiles and only one of the four is ever asked about, because diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index 74e4858..fb56825 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -895,6 +895,14 @@ pub fn map_grid(memory: &mut dyn MemoryReader) -> Result { let height_blocks = read(memory, ram::wCurMapHeight); let stride = u16::from(width_blocks) + (mapgrid::MAP_BORDER as u16) * 2; let border = mapgrid::MAP_BORDER as u16; + // The map plus its border has to fit in `wOverworldMap`, which every real map does. One that + // does not is a header caught mid-load, and reading past the buffer would be reading somebody + // else's WRAM. + if usize::from(stride) * (usize::from(height_blocks) + mapgrid::MAP_BORDER * 2) + > mapgrid::OVERWORLD_MAP_BYTES + { + return Err(GridRefusal::NoHeader); + } let mut blocks = Vec::with_capacity(usize::from(width_blocks) * usize::from(height_blocks)); for row in 0..u16::from(height_blocks) { for column in 0..u16::from(width_blocks) { From c8de2e42e9760597f08d2f7927110c09b1d22f44 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 04:16:48 +0000 Subject: [PATCH 10/13] docs(gb): the module header names the corner the cartridge settled --- .../flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs | 7 ++++--- .../crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs index a9184ea..d13e630 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid.rs @@ -21,9 +21,10 @@ //! cartridge's *blocks* are 4x4 screen tiles; the player moves in *map tiles* of 2x2 screen tiles, //! which is the unit `wXCoord`, the warp table and everything in `macros/` is in. So one block is //! [`TILES_PER_BLOCK`] map tiles each way, and the screen tile a map tile's walkability is read -//! from is the top left of its 2x2 quadrant -- the same corner `_GetTileAndCoordsInFrontOfPlayer` -//! reads at screen `(8, 9)` for the tile the player stands on, which is what -//! [`super::state::map_grid`]'s cross-check against the window predicate proves on a cartridge. +//! from is the **lower** left of its 2x2 quadrant ([`ANCHOR_ROW`]) -- the corner +//! `_GetTileAndCoordsInFrontOfPlayer` reads at screen `(8, 9)` for the tile the player stands on, +//! measured rather than argued and pinned by [`super::state::map_grid`]'s cross-check against the +//! window predicate on a cartridge. //! //! What it does **not** model is what it did not model before: sprites standing on ground (the //! sprite list answers that, and [`super::macros::path::frontier`] reads it), warps that fire on diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs index da7c6c7..4ce210a 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/mapgrid/tests.rs @@ -12,7 +12,8 @@ use crate::pokemon_red::macros::state::{Facing, Walkable}; /// four different tile ids. /// /// A block is four screen tiles each way and a map tile is two, so the quadrants are the four -/// corners and the tile a map tile's walkability comes from is the top left of its own quadrant. +/// corners and the tile a map tile's walkability comes from is the lower left of its own quadrant +/// ([`ANCHOR_ROW`], which the cartridge settled). fn blockset() -> Vec { let floor = [FLOOR; 16]; let wall = [WALL; 16]; From b9e01dea5b0f41a498dc1f0cd9125775f075d907 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 04:21:54 +0000 Subject: [PATCH 11/13] docs: macros.md section 15 and the WRAM table behind it Section 15 is the contract for map-aware walks: what is decoded, the one ROM read and why it is a read of the cartridge image, what the window fallback is for and how it says so, and the proof. macros-wram.md section 9 is the bytes -- four addresses with their encodings, the bank-aware read on the seam, the corner the cartridge settled, the two gates against a plausible-but-wrong decode, and the survey of Pallet Town and Viridian Forest. --- docs/design/macros-wram.md | 92 ++++++++++++++++++++++++++++++++++++++ docs/design/macros.md | 85 +++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) diff --git a/docs/design/macros-wram.md b/docs/design/macros-wram.md index 95de865..5d5918f 100644 --- a/docs/design/macros-wram.md +++ b/docs/design/macros-wram.md @@ -205,6 +205,9 @@ current tileset's list of passable tiles, walking it until it matches or hits `$ is not followed and the answer is `Unknown`, because banks 1 and up are whatever the last bank switch left mapped. This is the only ROM read in the module and the reason it is allowed. +**Section 9 is the same predicate over the whole map** (2026-09-22): the window below is what the +walks fall back to on a frame the map cannot be decoded, and no longer what they plan over. + Three things bound it, all reported as `Unknown` rather than guessed: 1. **The window is ten tiles by nine and it follows the player**: `x - 4 ..= x + 5` and @@ -573,3 +576,92 @@ PP, type effectiveness applied from the ROM's type chart", and section 14 replac one button per move slot: which move is used is the fly's choice and the mushroom body's to learn. Knowledge that nothing reads is not narrowed, it is deleted — `MacroState` is four methods shorter and `pokemon_red/state.rs` never needed them. + +## 9. The whole map, not the window (2026-09-22, `docs/design/macros.md` section 15) + +The walkable predicate of section 2 answers about ten tiles by nine because that is how much map +the screen buffer holds. Every tile of the loaded map follows the same rule, decoded from the +tables the cartridge has loaded. + +**Four more names through the same door.** `services/flysim/tools/gen_symbols.py`'s `EXTRA_RAM` +takes the table from 63 addresses to **67**, and nothing else in it moves — no event flag, no +milestone, no existing address. `flysim --print-compatibility` is byte-identical across the change: +648 bytes, `0d9bfde7…707fa`. + +The prototype checkout `gen_symbols.py` reads is not on this box, so the four addresses were +resolved the way it would have resolved them, by a second tool that reads the disassembly directly: +`services/flysim/tools/resolve_wram.py` walks `ram/wram.asm` at the pinned commit with a byte +cursor that is **only ever live while it is anchored on an address `symbols.rs` already pins**, and +emits an address only when a pinned address *after* it agrees as well. It re-derives 40 of the 63 +addresses the table already carries with no disagreement, and each of the four new ones is +bracketed by two of them. A declaration form it cannot size exactly kills the cursor rather than +being guessed at, so an unanchored region cannot produce a number at all. + +| state | symbol | address | encoding | verified | +| --- | --- | ---: | --- | --- | +| the loaded map's blocks | `wOverworldMap` | `$c6e8` | one byte per 4x4-tile block. `LoadTileBlockMap` (`home/overworld.asm`) fills it from the map's own ROM bank as rows of `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself `MAP_BORDER` = 3 rows and columns in, so the border can hold strips of the connected maps. The map's own blocks are therefore a WRAM read. | survey + ROM (Pallet Town and Viridian Forest, below), trace | +| which tileset | `wCurMapTileset` | `$d367` | tileset id (`constants/tileset_constants.asm`, `OVERWORLD` 0 … `FOREST` 3 … `CAVERN` 17). Keys the tile-pair lists, which is the only thing this work reads it for. | ROM, trace | +| the blockset's bank | `wTilesetBank` | `$d52b` | the tileset header's `db BANK(\1)` (`data/tilesets/tileset_headers.asm`). Not bank 0, which is the whole reason the seam grew a bank-aware read. | ROM (the overworld tileset's blockset reads from bank `$19`), trace | +| blocks to tiles | `wTilesetBlocksPtr` | `$d52c` | little-endian pointer, 16 bytes per block id, four rows of four screen tile ids. `DrawTileBlock` (`home/overworld.asm`) indexes it as `block * $10` and walks four rows of four, which pins the layout exactly. | ROM, trace | + +### The one ROM read that needed a bank + +`MemoryReader` gains `read_rom(bank, address) -> Option`, defaulted to `None`. The bus read +cannot reach the blockset — banks 1 and up are whatever the cartridge's last switch left mapped, and +the only way to change that would be to *write* the mapper's bank register, which the doctrine +forbids (`docs/design/macros.md` section 12: the joypad register is the only write). So the +emulator implements it over **the cartridge image the process already holds**: below `$4000` it is +bank 0 whatever the bank says, `$4000..$8000` is the banked window, and an offset past the end of +the image is `None`. Nothing is written, no bank is switched, and the emulator's state does not +move. Every other reader — the synthetic WRAM of the tests, the sim loop's stubs — keeps the +default, and `None` there means the grid narrows to the window predicate rather than decoding a map +out of whatever bytes were to hand. + +### The corner, which was measured + +A map tile is 2x2 screen tiles and `CheckTilePassable` matches **one** id, so a decode has to pick +the same one the cartridge picks. It is the **lower left** of the four. The upper left is the +plausible guess: the view is centred so that the player's own 2x2 begins at screen row 8 and +`_GetTileAndCoordsInFrontOfPlayer` reads `(8, 9)`, which is its lower half. Measured on the +cartridge rather than argued: Viridian Forest's (4, 32) reads `$23` on the screen, which is the +second row of its block, where the first row holds `$04`. On a town most quadrants hold one tile id +four times over, so an upper-left decode reads correctly there and falls apart in a forest — which +is exactly the shape of mistake the cross-check below exists for. + +### Two gates, because a wrong decode answers plausibly + +- **Against the screen, before the grid is trusted.** The decoded ids are compared with + `map_tile_id` over the fly's own tile and its four neighbours; a frame where the window can answer + for none of them is refused. `wOverworldMap` shares its bytes with the picture buffer + (`ram/wram.asm`'s own `UNION`), so a battle is precisely when the blocks under it are somebody + else's. +- **Against the screen again, whenever a cached grid is served.** A warp writes `wCurMap` before the + header and the blocks: measured on Oak's lab's doormat, where `wCurMap` reads `PALLET_TOWN` while + the header still reads the lab's ten-by-twelve. The decode and the screen agree on such a frame — + both are the old map — so only the *id* is wrong, and the check that catches it is one byte: does + the cached grid still agree with the screen about the tile the fly is standing on. + +### The survey, on two maps + +`services/flysim/crates/flysim/tests/rom_map_grid.rs`, the method of +`docs/design/room-escape.md` section 3: walk the map with real button presses on throwaway +emulators, 120 frames of held direction per step and twenty released frames before each state is +kept, and compare the grid against what the cartridge did. + +| map | size | walkable | reachable | unknown | window tiles compared | tiles surveyed | refused presses | a sprite was in the way | a battle or a script answered | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Pallet Town `$00` | 20x18 | 221 | 207 | 0 | 90, no disagreement | 120 | 58, all explained | 4 | 0 | +| Viridian Forest `$33` | 34x48 | 719 | 719 | 0 | 90, no disagreement | 120 | 74, all explained | 2 | 10 | + +"All explained" is the assertion that matters: every press the cartridge refused is a tile the grid +calls a wall, a directed wall out of that tile, or a tile a sprite was standing on **in the frame +the press was made in** — Pallet Town's two villagers walk, so reading the sprite list from the +state the survey started in would not do. And every step the cartridge made is one the grid would +have planned. Both halves are needed: the first catches a decode that is too permissive, the second +one that is too strict. + +Three things are still not modelled, and none of them is new: a sprite in the way (the sprite list +answers that, and the executor's per-step moved check covers the rest), a warp that fires on the +step onto it, and a script that pushes the fly off a tile (a session ledger answers that). The +water half of the tile-pair lists is deliberately absent: it is the list +`CheckForJumpingAndTilePairCollisions` uses while surfing, and the palette cannot surf. diff --git a/docs/design/macros.md b/docs/design/macros.md index d4aac9a..085a8d8 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -1032,3 +1032,88 @@ rather than argued: `LAYOUT.macroPalette` was `{x: 480, y: 816, w: 364, h: 212}` while the grid existed — one box moved, the strip's top edge from 852 to 816 — and it is back to `{x: 480, y: 852, w: 364, h: 176}`. Nothing in the layout differs from main. + +## 15. Map-aware walks: one plan over the whole map (the operator, 2026-09-22: "the frontier and +## warp macros need to be map aware: A* over walkable tiles.") + +Section 4 has said "A* over the current map's walkable tiles" since the first draft, and it was +never that. The walkable predicate answers for the ten tiles by nine of the screen buffer and +`Walkable::Unknown` for everything else (`docs/design/macros-wram.md`), so every walk in the game +planned through guesses at eight times the price of a known tile, re-planned at every window edge, +and `GO FRONTIER` aimed at whatever unstood ground was on screen -- never the far side of a town, +which nothing could see. + +What changes is where the tile ids come from. The rule does not change at all: a tile is walkable +when the current tileset's collision list holds its id, which is `CheckTilePassable`. + +- **The whole map is decoded** from the tables the cartridge has already loaded: the block ids out + of `wOverworldMap`, the block-to-tiles blockset out of the tileset header, the collision list as + before, and the `TilePairCollisionsLand` pairs as **directed walls**. `MapGrid` is every tile of + the loaded map with those walls, and `docs/design/macros-wram.md` section 9 is the byte-level + evidence, the new addresses and the verification. +- **One read of a ROM bank was needed, so `MemoryReader` gained one method.** The blockset does + not live in bank 0, and the only way to reach another bank through the CPU bus would be to + *write* the mapper's bank register. `MemoryReader::read_rom(bank, address)` reads the cartridge image the process + already holds instead: the same bytes, addressed the way the disassembly addresses them, and no + write into a running game. The joypad register is still the only write (section 12). +- **`path::route` and `path::frontier` plan over the grid**, and the plan is the same A*: one step + per tile, the multi-goal heuristic, the committed route of section 12.3, re-planned only on a + refusal or a displacement. `GO WARP`, `GO OUT` and `GO ROUTE` route to their warp or connection + tile across the whole map; `GO OBJECTIVE` takes the exit that is the first hop; the frontier is + the nearest unstood walkable tile *anywhere on the map*, by the stood ledger of section 12.7. +- **The window stays as the fallback, and it says when.** A frame with no grid falls back to the + ten-by-nine reading exactly as before, and `GridRefusal` names which of the five reasons it is: + no map header, no player, no collision list, no blockset (which is what a reader with no + cartridge behind it answers), the map not on screen, or the decode disagreeing with the screen. + Nothing is guessed and nothing silently degrades. +- **The grid is checked against the screen before it is trusted, and again whenever it is served.** + The decode is compared with the window predicate over the tile the fly is standing on and its + four neighbours, and a frame where the window can answer for none of them is refused — the block + data shares its bytes with the picture buffer, so a battle is exactly when it belongs to somebody + else. Serving a cached grid re-checks the fly's own tile, because a warp writes the map id before + the header and the blocks: for a frame or two on a doormat, `wCurMap` is the map the fly is + arriving on and the blocks are still the map it is leaving. +- **Cached per map, decoded once on arrival**, dropped when the map or its size changes. Session + state beside the talked, blocked, reached, stood and errand ledgers; never checkpointed, so a + restored run decodes the map again on its first overworld frame. +- **The probes report it.** `examples/scene_probe.rs` prints the grid's size, its walkable count, + the count reachable from where the fly stands and the count never stood on, draws the ground with + the reading it used, and names the refusal when there is none; `examples/trap_hunt.rs` carries the + same line into every trace line and into the summary table. Three numbers read a stalled walk at + a glance: a fly with forty walkable tiles and four reachable ones is fenced in, and no amount of + re-planning will help it. + +**Nothing about the choice moves.** The scene deals the same buttons, the readout presses them, and +what changed is what a chosen walk knows about the ground — which is where section 12 puts +knowledge. The decoder, the reward catalog, the adapter version and the compatibility string are +untouched: 648 bytes, `0d9bfde7…707fa`, byte-identical across the change. + +Two things the cartridge settled rather than the design, both measured and both written up with +their bytes in `docs/design/macros-wram.md` section 9: the collision id of a map tile is the +**lower left** of its four screen tiles and not the upper left, and **a warp writes the map id +before the map**, which is what the per-serve check above is for. + +### 15.1 The proof + +- **Unit tests, no cartridge.** The decoder against a made-up tileset: a block's four quadrants, + a collision list over a map wider than the window, a tile-pair collision as a wall in both + directions and not in another tileset, a block id past the end of the blockset staying `Unknown`, + and the reachable count over a fenced region. The reader against synthetic WRAM with a synthetic + blockset in a synthetic bank: the whole map decoded, a screen that disagrees refused, a reader + with no cartridge refused, a battle frame refused, and the cache holding one map. The search over + a grid: one plan across a map larger than the window where the window's own plan walks into a + wall it cannot see, a frontier beyond the window where the window's frontier is empty, a + tile-pair wall planned around, and a connection whose walls are not goals. +- **ROM-gated, from the release container's own checkpoints** (`tests/rom_map_grid.rs`, `FLY_ROM` + plus a checkpoint, skipped cleanly without either). On **Pallet Town** — 20x18, 221 walkable + tiles, 207 of them reachable, none unknown — and on **Viridian Forest** — 34x48, 719 walkable, + all reachable, none unknown: the decode agrees with the window predicate on all ninety tiles the + window can answer for, and it agrees with a **survey of real presses** on every one of the first + 120 tiles the walk can stand on (58 refused presses on the town, 74 in the forest, every one of + them a wall, a directed wall or a tile a sprite was standing on; 10 presses in the forest that + the cartridge answered with a battle, which say nothing about the ground either way). + `GO FRONTIER` in the forest planned to ground outside the window and walked there in 215 frames + (615 of its frontier tiles are outside the window); every way out of the forest is one plan away, + 27 to 149 steps, with no guessed tile in any of them. +- **The trap hunt**, twenty brain minutes from the rung-9 checkpoint, before and after, is in + `infra/docs/macros-traps.md`. From ba581e44d30f3aaf2d322a0d49c90f1112548d75 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 04:50:24 +0000 Subject: [PATCH 12/13] flysim: the trap hunt says which refusal a frame with no grid is --- services/flysim/crates/flysim/examples/trap_hunt.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/services/flysim/crates/flysim/examples/trap_hunt.rs b/services/flysim/crates/flysim/examples/trap_hunt.rs index 31a4fde..e202e8b 100644 --- a/services/flysim/crates/flysim/examples/trap_hunt.rs +++ b/services/flysim/crates/flysim/examples/trap_hunt.rs @@ -551,6 +551,9 @@ fn run( next_trace = ms + trace_every_ms; let scene = macros.as_ref().map_or("", MacroLayer::scene_name); use flybrain_gb::pokemon_red::macros::cartridge::{MacroState, Tile}; + // Read before the state borrows the emulator: this is the same call the state makes, + // and the only one that can say *which* refusal a frame is. + let refusal = flybrain_gb::pokemon_red::state::map_grid(&mut emulator).err(); let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator); let state: &mut dyn MacroState = &mut state; let player = state.player(); @@ -558,7 +561,7 @@ fn run( let ahead = Tile::new(player.x, player.y).step(player.facing)?; flybrain_gb::pokemon_red::macros::path::target_at(state, ahead) }); - let ground = grid_line(state, player); + let ground = grid_line(state, player, refusal); let why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator); println!( "trace {:7.2} min scene={scene:<9} player={player:?} ahead={ahead:?}\n {why}\n {ground}", @@ -634,10 +637,11 @@ fn run( trace.ended_why = flybrain_gb::pokemon_red::scene::why_unknown(&mut emulator); trace.ended_grid = { use flybrain_gb::pokemon_red::macros::cartridge::MacroState; + let refusal = flybrain_gb::pokemon_red::state::map_grid(&mut emulator).err(); let mut state = flybrain_gb::pokemon_red::state::PokeState::new(&mut emulator); let state: &mut dyn MacroState = &mut state; let player = state.player(); - grid_line(state, player) + grid_line(state, player, refusal) }; trace.ended_ms = agent.network.ms; trace.wall_seconds = began_wall.elapsed().as_secs_f64(); @@ -749,10 +753,13 @@ fn walk_report(trace: &Trace) { fn grid_line( state: &mut dyn flybrain_gb::pokemon_red::macros::cartridge::MacroState, player: Option, + refusal: Option, ) -> String { let Some(player) = player else { return "grid: no player".to_string() }; let Some(grid) = state.map_grid() else { - return "grid: none".to_string(); + // Which of section 15's refusals this frame is, rather than a bare "none": a walk that is + // on the window reading should say why it is. + return format!("grid: none ({})", refusal.map_or("unknown", |refusal| refusal.label())); }; let unstood = grid .walkable_tiles() From 4b93f9dc7f768d7d9ffc679411318b7e0836b4af Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 22 Sep 2026 05:15:30 +0000 Subject: [PATCH 13/13] docs: the trap hunt for section 15, before and after 286 distinct tiles become 489 over twenty brain minutes from the rung-9 checkpoint, the median flagged window holds 55 tiles instead of 16, GO FRONTIER runs 64 walks worth up to twelve net tiles instead of four worth one, and one walk spends its cap where six did. The cost is named rather than buried: flagged windows go 61 to 70 and windows under four tiles 5 to 14, and every one of those is a window spent inside a battle -- a fly that covers more ground walks into more grass, 48,756 battle frames becoming 50,413 with the longest battle 9,549 frames becoming 13,411. This is the first measurement in that file where more ground and fewer flags do not both hold, and it is stated as such. --- docs/design/macros.md | 10 ++++-- infra/docs/macros-traps.md | 68 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/docs/design/macros.md b/docs/design/macros.md index 851b4ea..e54f8a5 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -1168,5 +1168,11 @@ before the map**, which is what the per-serve check above is for. `GO FRONTIER` in the forest planned to ground outside the window and walked there in 215 frames (615 of its frontier tiles are outside the window); every way out of the forest is one plan away, 27 to 149 steps, with no guessed tile in any of them. -- **The trap hunt**, twenty brain minutes from the rung-9 checkpoint, before and after, is in - `infra/docs/macros-traps.md`. +- **The trap hunt**, twenty brain minutes from the rung-9 checkpoint, against `main` at v0.4.2: + **286 distinct (map, tile) become 489**, the median flagged window holds 55 tiles instead of 16, + `GO FRONTIER` runs 64 walks worth up to twelve net tiles instead of four worth one, and one walk + spends its cap where six did. It costs battle: flagged windows go 61 to 70 and windows under four + tiles 5 to 14, every one of them a window spent inside a battle, because a fly that covers more + ground walks into more grass. `infra/docs/macros-traps.md` has both runs whole and says so at + length; this is the one measurement in this file where more ground and fewer flags do not both + hold. diff --git a/infra/docs/macros-traps.md b/infra/docs/macros-traps.md index 9e5918e..86ac5e0 100644 --- a/infra/docs/macros-traps.md +++ b/infra/docs/macros-traps.md @@ -1359,3 +1359,71 @@ Four things about it that are not improvements, recorded rather than buried. - `infra/tests/lint.sh`: all checks passed, de-PII guard included. - `--print-compatibility`: byte-identical to v0.4.1, 648 bytes, decoder / reward catalog / adapter version / roles untouched. + +## 2026-09-22, section 15: the walkable window becomes the whole map + +The operator: "the frontier and warp macros need to be map aware: A* over walkable tiles." +`docs/design/macros.md` section 15 is the contract, `docs/design/macros-wram.md` section 9 the +bytes, and this is the measurement. Nothing about the *choice* moves: the scene deals the same +buttons and what changed is what a chosen walk knows about the ground. + +**Row 35 has no source any more.** That row — "the screen-buffer tile read disagrees with itself on +a map smaller than the screen", worked around by taking a counter's reach from the map id — was a +fact about reading tiles out of a view that cannot centre on an 8x8 map. The grid reads the map's +own block data, so it answers the same way from every tile of every map. The counter rule is left +exactly as it is: this branch changes the ground, not the amenity, and a workaround that is no +longer needed is not the same thing as one that was wrong. + +### The trap hunt, before and after + +20 brain minutes, the hunt's own seed, 4 sweep threads, the same connectome, the same cartridge and +the same rung-9 checkpoint in both runs. "Before" is `main` at v0.4.2, which is the branch's own +merge base, so the two rows differ by this work and nothing else: + +```sh +FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \ + FLY_TRAP_CHECKPOINT=.local/checkpoints/release-rank9-20260922T0254.checkpoint \ + FLY_TRAP_MINUTES=20 FLY_TRAP_MODE=macros cargo run --release -p flysim --example trap_hunt +``` + +| measure | before (`main`, v0.4.2) | after (this branch) | +| --- | ---: | ---: | +| distinct (map, tile) over 20 brain minutes | 286 | **489** | +| median distinct tiles in a flagged window | 16 | **55** | +| most tiles in any one window | 94 | **180** | +| macros started | 830 | 912 | +| done / blocked / timeout / refused | 726 / 97 / 6 / 0 | 820 / 90 / **1** / 8 | +| windows flagged | 61 of 73 | 70 of 73 | +| windows under 4 distinct tiles | 5 | 14 | +| frames in a battle (longest run) | 48,756 (9,549) | 50,413 (13,411) | +| frames in the overworld | 22,149 | 18,972 | +| `GO FRONTIER` done / timeout / mean net / max net tiles | 4 / 1 / 0.5 / 1 | **64** / 0 / 1.6 / **12** | +| `GO WARP` done / timeout / mean frames / max net | 14 / 2 / 403 / 30 | 4 / **0** / 751 / 26 | +| `GO ROUTE` done / timeout / mean frames / max net | 9 / 2 / 483 / 22 | 11 / 1 / 410 / 8 | +| `GO OBJECTIVE` done / timeout / max net | 3 / 1 / 8 | 5 / **0** / **42** | +| wall seconds on the development box | 1,390 | 932 | + +**The ground is the measure and the ground moved.** 286 distinct tiles became 489; the median +flagged window holds 55 of them instead of 16 and the widest holds 180 instead of 94. `GO FRONTIER` +went from four walks worth a net tile each to sixty-four worth up to twelve, `GO OBJECTIVE`'s best +walk from eight net tiles to forty-two, and the only walk that spent its cap is one `GO ROUTE` that +gained forty-two tiles while doing it. Nothing timed out that used to arrive. + +**The cost, named rather than buried: the flag count went the wrong way**, 61 windows to 70, and +the windows holding fewer than four tiles went 5 to 14. Every one of those is a battle. The fly +covers more ground, so it walks into more grass and more trainers: battle frames 48,756 to 50,413, +the longest single battle 9,549 frames to 13,411, overworld frames 22,149 down to 18,972. A +two-minute window spent inside one battle is a window with one tile in it, and the worst window of +the after-run is 83 macros on one tile with **no repeated sequence at all** — which is a battle, +not a loop. This file has recorded battle text as check 10's known false-positive shape since its +first section, and the `NEXT` x12 to x14 runs that flag 43 of the 70 windows are exactly it. + +So the two halves of the usual reading disagree here for the first time, and this is the honest +statement of it: **more ground, more battle, more flags.** What the flag counts cannot show and the +tile counts can is that the fly is walking across maps instead of round the tile it is standing on. + +### Gates + +`cargo test --workspace`, `cargo clippy --all-targets` and `infra/tests/lint.sh` on the development +box. `flysim --print-compatibility` is byte-identical across the change: 648 bytes, +`0d9bfde7…707fa`, so the live checkpoint carries over.