zbanks/alttp - the C bot that cleared Eastern Palace - driving our console.
The bot is compiled from its own sources (//third-party/c:zbanks-alttp,
upstream commit bcb2537, plus the carried patches in
third-party/c/patches/), and linked here.
Upstream links it into a patched Snes9x (snes9x.patch), which gives it
exactly two things, and this crate is those two things over [Console]:
struct ap_snes9x(alttp_public.h):base(addr)- a pointer to the byte at a SNES address, which the bot caches for its whole life inap_initand reads and WRITES through - plussave/loadof named savestates and a slot for its one-line info string.ap_tick(frame, *joypad)once per frame, before the frame runs, with the pad the human is holding; whatever it leaves in*joypadis the pad for that frame, and afterwards the human's pad is put back.
research/zbanks-alttp.md has the contract with citations and every way
this host differs from upstream's.
The bot's state is C globals, so there is one bot per process; [Bot::start]
refuses a second.
---- The C side -------------------------------------------------------------
struct ap_snes9x, alttp_public.h:10-15.
44unsafe extern "C" { 45 // alttp_public.h 46 fn ap_init(emu: *mut ApSnes9x); 47 fn ap_tick(frame: u32, joypad: *mut u16); 48 // ap_map.h:209-213 49 fn ap_map_export(filename: *const c_char); 50 // ap_snes.h:36, set by ap_goal_evaluate when no goal is left (ap_plan.c:917) 51 static ap_manual_mode: bool; 52 53 // shim/host.c 54 fn zb_install_trap_handler(); 55 fn zb_set_output_dir(dir: *const c_char); 56 fn zb_traps() -> u64; 57 fn zb_last_trap_ip() -> u64; 58 fn zb_last_trap_frame() -> u64; 59 fn zb_anchor() -> usize; 60 fn zb_info_string() -> *const c_char; 61 fn zb_link_xy(x: *mut u16, y: *mut u16); 62 fn zb_tasks(buf: *mut c_char, len: usize) -> usize; 63 fn zb_goals(buf: *mut c_char, len: usize, max_goals: usize) -> usize; 64 fn zb_goal_count() -> usize; 65 fn zb_flush_stdout(); 66 fn zb_set_goal_chooser(chooser: Option<RawChooser>, margin: c_int); 67 fn zb_watch_goals() -> usize; 68 fn zb_given_up_count() -> usize; 69 fn zb_retry_given_up(why: *const c_char) -> usize; 70 fn zb_completed_count() -> u64; 71 fn zb_drain_goal_outcomes(out: *mut RawOutcome, max: usize) -> usize; 72}
struct zb_raw_outcome, shim/host.c: one goal's outcome as the shim
describes it, exactly the fields [outcomes::Outcome] keeps.
---- The goal choice ------------------------------------------------------------
struct zb_goal_option, shim/host.c: one goal as the shim describes it.
87#[repr(C)] 88struct RawGoalOption { 89 kind: *const c_char, 90 node: *const c_char, 91 screen: *const c_char, 92 screen_id: u16, 93 indoors: u8, 94 direction: u8, 95 sprite_type: u8, 96 sprite_subtype: u16, 97 item: *const c_char, 98 script: *const c_char, 99 needs: *const c_char, 100 score: c_int, 101 distance: c_int, 102 attempts: c_int, 103 adjacent_known: u8, 104 x: u16, 105 y: u16, 106 screen_x0: u16, 107 screen_y0: u16, 108 screen_x1: u16, 109 screen_y1: u16, 110 via: *const c_char, 111 via_direction: u8, 112 screens_on_path: u8, 113 on_way_to: u8, 114}
struct zb_goal_context, shim/host.c.
130type RawChooser = extern "C" fn(options: *const RawGoalOption, count: usize, context: *const RawContext) -> c_int;
Where Link is when a goal choice is made.
The bot's name for the screen Link is on.
Link where the bot puts him, in its coordinates (indoors is x >= 0x4000, ap_math.h).
141 pub link: (u16, u16),
The bounds of Link's screen, top-left and bottom-right; zero when the bot has no screen for where he stands.
One goal the bot could pursue next, in its own records' terms. Offered only when it is satisfiable and its score is within the chooser's margin of the lowest; the first option is always upstream's own pick.
PICKUP, CHEST, EXPLORE, ITEM, NPC or SCRIPT (ap_plan.h:5-12).
153 pub kind: String,
The node's name in the bot's words ("door U 0x84 DOOR|NODE NORM", "chest 0", "sprite 0x73.0x100 TALK|NODE", "Script: ...").
156 pub node: String,
The screen's name: a given one ("Well Uncle") or its id and bounds.
For an exit, which way it leads: 1 north, 2 south, 3 west, 4 east (ap_map.h:9); 0 for anything else.
What the bot believes is there (ap_item.c), empty if unknown.
167 pub item: String,
ap_req_print of the goal's requirements.
169 pub needs: String,
ap_goal_score now; lower is what upstream would do first.
171 pub score: i32,
The path-length part of the score (score less attempts x 100 and the no-sword penalty, ap_plan.c:758-775).
An exit whose far side the bot has already mapped.
177 pub adjacent_known: bool,
The node's centre, in the bot's coordinates.
179 pub at: (u16, u16),
Its screen's bounds, top-left and bottom-right.
181 pub screen_bounds: ((u16, u16), (u16, u16)),
The bot's own path to it: the node name of the exit it leaves Link's
screen by (empty when the goal is on Link's screen), that exit's
direction (as direction), and how many screens the path enters.
Bit j: this goal's node lies on option j's path - doing it is on the way to option j.
Who makes the goal choice when the bot makes one (carried patch 0002). The
answer is an index into options; None, or anything out of range, keeps
upstream's pick (index 0).
200thread_local! { 201 static CHOOSER: RefCell<Option<Box<dyn GoalChooser>>> = RefCell::new(None); 202 static FRAME: std::cell::Cell<u32> = const { std::cell::Cell::new(0) }; 203} 204 205fn owned(p: *const c_char) -> String { 206 if p.is_null() { 207 return String::new(); 208 } 209 // SAFETY: the shim passes NUL-terminated strings valid for the call. 210 unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned() 211} 212 213extern "C" fn choose_goal(options: *const RawGoalOption, count: usize, context: *const RawContext) -> c_int { 214 // SAFETY: the shim passes a context valid for the call. 215 let context = unsafe { &*context }; 216 let here = Situation { 217 link_screen: owned(context.link_screen), 218 link_indoors: context.link_indoors != 0, 219 has_sword: context.has_sword != 0, 220 link: (context.link_x, context.link_y), 221 screen: ((context.screen_x0, context.screen_y0), (context.screen_x1, context.screen_y1)), 222 }; 223 // SAFETY: the shim passes `count` initialised options. 224 let raw = unsafe { std::slice::from_raw_parts(options, count) }; 225 let options: Vec<GoalOption> = raw 226 .iter() 227 .map(|o| GoalOption { 228 kind: owned(o.kind), 229 node: owned(o.node), 230 screen: owned(o.screen), 231 screen_id: o.screen_id, 232 indoors: o.indoors != 0, 233 direction: o.direction, 234 sprite_type: o.sprite_type, 235 sprite_subtype: o.sprite_subtype, 236 item: owned(o.item), 237 needs: owned(o.needs), 238 score: o.score, 239 distance: o.distance, 240 attempts: o.attempts, 241 adjacent_known: o.adjacent_known != 0, 242 at: (o.x, o.y), 243 screen_bounds: ((o.screen_x0, o.screen_y0), (o.screen_x1, o.screen_y1)), 244 via: owned(o.via), 245 via_direction: o.via_direction, 246 screens_on_path: o.screens_on_path, 247 on_way_to: o.on_way_to, 248 }) 249 .collect(); 250 let frame = FRAME.with(std::cell::Cell::get); 251 let pick = CHOOSER.with(|c| c.borrow_mut().as_mut().and_then(|c| c.choose(frame, &here, &options))); 252 pick.filter(|&i| i < options.len()).map_or(-1, |i| i as c_int) 253}
---- The joypad word ----------------------------------------------------------
Snes9x's joypad word, ap_snes.h:6-17: bit 15 is B down to bit 4, R.
258pub const PAD_BITS: [(u16, SnesButton); 12] = [ 259 (1 << 15, SnesButton::B), 260 (1 << 14, SnesButton::Y), 261 (1 << 13, SnesButton::Select), 262 (1 << 12, SnesButton::Start), 263 (1 << 11, SnesButton::Up), 264 (1 << 10, SnesButton::Down), 265 (1 << 9, SnesButton::Left), 266 (1 << 8, SnesButton::Right), 267 (1 << 7, SnesButton::A), 268 (1 << 6, SnesButton::X), 269 (1 << 5, SnesButton::L), 270 (1 << 4, SnesButton::R), 271];
Hold exactly the buttons in pad (Snes9x's layout) on controller one.
The buttons a pad word holds, in [PAD_BITS] order.
The pad word for a set of held buttons.
---- The memory base() serves -------------------------------------------------
Where the bot's pointers point. Allocated once and never freed or moved:
ap_init caches a pointer per RAM variable (ap_snes.c:60-61) and uses it
for the life of the process, as it could inside Snes9x, whose memory map
never moves either.
304struct Memory {
A copy of work RAM, $7E0000-$7FFFFF. Filled from the console before
every tick and written back after it, which is where the bot's
cheats (alttp.c:20-26) land in the game.
308 wram: *mut u8,
The cartridge, LoROM, as loaded. Read-only to the bot in practice.
Returned for an address Snes9x itself would give a NULL for (I/O registers, unmapped space). Upstream would crash on a NULL; the bot never asks for one (every address it asks for is listed in research/zbanks-alttp.md), and this page records if it ever does.
SAFETY: the pointers are to leaked, never-freed allocations, only touched on
the thread that owns the [Bot] (the bot is not reentrant anyway).
Every address base() was asked for that mapped to nothing, counted.
328static STRAY_READS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
The bot was written against the Randomizer, which is built on the
JAPANESE 1.0 ROM; ours is USA 1.0. Work RAM is laid out the same in both,
but code and data in ROM moved, so every ROM table the bot reads
(ap_snes.h:102-169, ap_map.c:364-420) is at a Japanese address that holds
something else in ours. Each row is (Japanese address, length, the same
table's USA address), found by matching the table's bytes from
spannerisms/jpdasm in our ROM; every row's bytes are identical in both
(research/zbanks-alttp.md §ROM). base() answers a Japanese address from
the USA table - the bot asks for the chest table and gets the chest table.
Addresses outside these rows are the same in both ROMs (Map16Definitions
at $0F8000, the $1B overworld entrance tables, $1BF110).
341pub const JP_TO_US: [(u32, u32, u32); 11] = [ 342 (0x01E96C, 168 * 3, 0x01E96E), // RoomData_ChestItems (dungeon_chests) 343 (0x02CCBD, 0x10A, 0x02CF59), // EntranceData Y (entrance_ys) 344 (0x02CDC7, 0x10A, 0x02D063), // EntranceData X (entrance_xs) 345 (0x02EB29, 0x100, 0x02EDC5), // .bombable_door_location (over_overlay_map16s) 346 (0x06F735, 0x20, 0x06F72F), // sprite hitbox x_lo 347 (0x06F755, 0x20, 0x06F74F), // hitbox x_hi 348 (0x06F775, 0x20, 0x06F76F), // hitbox width 349 (0x06F795, 0x20, 0x06F78F), // hitbox y_lo 350 (0x06F7B5, 0x20, 0x06F7AF), // hitbox y_hi 351 (0x06F7D5, 0x20, 0x06F7CF), // hitbox height 352 (0x0FFD94, 0x202, 0x0E9459), // OverworldTileTypes (over_tattr2; LOAD2 in ap_map.c:417) 353];
base(): Snes9x's S9xGetMemPointer for a LoROM cartridge with the
memory the bot uses. Banks $7E-$7F are work RAM; the low 8K of banks
$00-$3F/$80-$BF mirror its first 8K; $8000-$FFFF of every other
bank is 32K of ROM. As in Snes9x the pointer is into one flat array, so
the bot's own arithmetic past it (ap_ram arrays, ROM tables) walks on
through RAM or on into the next ROM bank exactly as it would there.
370extern "C" fn base(addr: u32) -> *mut u8 { 371 let memory = MEMORY.get().expect("base() before Bot::start"); 372 let bank = (addr >> 16) & 0xFF; 373 let offset = (addr & 0xFFFF) as usize; 374 // SAFETY: every offset below is reduced into its allocation. 375 unsafe { 376 match bank { 377 0x7E | 0x7F => memory.wram.add(((bank as usize - 0x7E) << 16 | offset) % WRAM_LEN), 378 _ if offset < 0x2000 && (bank & 0x7F) < 0x40 => memory.wram.add(offset), 379 _ if offset >= 0x8000 => { 380 let addr = jp_to_us(addr & 0xFFFFFF); 381 let (bank, offset) = ((addr >> 16) & 0xFF, (addr & 0xFFFF) as usize); 382 let rom_offset = ((bank & 0x7F) as usize) * 0x8000 + (offset - 0x8000); 383 memory.rom.add(rom_offset % memory.rom_len) 384 } 385 _ => { 386 STRAY_READS.fetch_add(1, Ordering::Relaxed); 387 memory.stray 388 } 389 } 390 } 391}
---- Savestates -------------------------------------------------------------------
Where the bot's named savestates live. Upstream's are Snes9x freeze files in its working directory; ours are console snapshots, kept wherever the host keeps them (beside the ROM, for the apps).
What a load/save callback needs while the C is running: the console, and where states are. Set only for the duration of a call into the bot.
Every load/save the bot asked for, and how it went: (verb, name, ok).
416fn with_call<R>(f: impl FnOnce(&mut Console, &mut dyn States) -> R) -> Option<R> { 417 CALL.with(|call| { 418 let call = call.borrow(); 419 let call = call.as_ref()?; 420 // SAFETY: set by `Bot::call` from live &mut borrows that outlive the 421 // C call this runs inside, and cleared before those borrows end. 422 Some(unsafe { f(&mut *call.console, &mut *call.states) }) 423 }) 424}
Snes9x S9xUnfreezeGame: nonzero on success (bool8 TRUE).
427extern "C" fn load(filename: *const c_char) -> c_int { 428 // SAFETY: the bot passes a NUL-terminated literal. 429 let name = unsafe { CStr::from_ptr(filename) }.to_string_lossy().into_owned(); 430 let ok = with_call(|console, states| { 431 let Some(snapshot) = states.load(&name) else { return false }; 432 if console.restore(&snapshot).is_err() { 433 return false; 434 } 435 refresh_wram(console); 436 true 437 }) 438 .unwrap_or(false); 439 STATE_LOG.with(|log| log.borrow_mut().push(("load", name, ok))); 440 c_int::from(ok) 441}
Snes9x S9xFreezeGame.
444extern "C" fn save(filename: *const c_char) -> c_int { 445 // SAFETY: as for `load`. 446 let name = unsafe { CStr::from_ptr(filename) }.to_string_lossy().into_owned(); 447 let ok = with_call(|console, states| { 448 // The bot's own RAM writes this frame go in before the machine is kept. 449 flush_wram(console); 450 console.snapshot().map(|s| states.save(&name, &s)).unwrap_or(false) 451 }) 452 .unwrap_or(false); 453 STATE_LOG.with(|log| log.borrow_mut().push(("save", name, ok))); 454 c_int::from(ok) 455}
457fn refresh_wram(console: &Console) { 458 let memory = MEMORY.get().expect("memory"); 459 // SAFETY: `wram` is WRAM_LEN bytes, never aliased by a Rust reference. 460 unsafe { std::ptr::copy_nonoverlapping(console.wram().as_ptr(), memory.wram, WRAM_LEN) }; 461} 462 463fn flush_wram(console: &mut Console) { 464 let memory = MEMORY.get().expect("memory"); 465 // SAFETY: as above. 466 unsafe { std::ptr::copy_nonoverlapping(memory.wram, console.wram_mut().as_mut_ptr(), WRAM_LEN) }; 467}
---- The bot ----------------------------------------------------------------------
The info string slot Snes9x shows on screen (GFX.InfoString); the bot
points it at its own buffer every tick (alttp.c:15).
Where [Bot::start] loads and appends goal outcomes, beside
output_dir - the same directory map_export.txt lives in (a caller's
bot_dir), so a restart's fresh bot finds both.
484pub const OUTCOMES_FILE: &str = "goal_outcomes.jsonl";
The bot, started. Not Send: the C is not thread-safe and the callbacks
find the console through a thread-local.
Whether goals the bot gave up on go back on its list when Link's
possessions change ([POSSESSIONS]). On by default; off, the bot is
upstream's, which never retries one.
493 pub retry_given_up: bool,
[POSSESSIONS] as of the last tick.
495 possessions: Option<Vec<u8>>,
Every goal's most recent outcome, by identity - loaded from
goal_outcomes.jsonl beside output_dir at [Self::start] and
appended to as goals leave the list ([Self::tick], which drains
shim/host.c's own ring every frame). Public so
decisions::goal_choice::Facts can look an offered option's own
history up by the identical identity string it already computes.
What Link has that can make a goal the bot gave up on worth trying again:
his items, sword, shield and armour, bottles ($7EF340-$7EF35F, less the
bomb count $7EF343, which the bot's own cheat rewrites every frame),
compasses, big keys and maps ($7EF364-$7EF369), the current dungeon's
small keys ($7EF36F - a key picked up or spent on a door both change
what is reachable), pendants ($7EF374), abilities ($7EF379), crystals
($7EF37A) and the game's progress ($7EF3C5-$7EF3C6). Not rupees,
health, magic or arrows: those change all the time and open nothing.
Addresses from research/alttp-ram-map.md §3.
[POSSESSIONS], read out of work RAM.
What changed between two [possessions] readings, in words for the log:
$7EF366 00->04.
What the bot has to say about itself, read from its own globals.
The frame number last passed to ap_tick.
549 pub frame: u32,
ap_info_string: the one line the bot prints over the picture.
551 pub info: String,
The task list (ap_plan.c), in ap_print_task's words, current first.
553 pub tasks: Vec<String>,
The first goals on the goal list: (type, node, screen, attempts, last score).
Link where the bot places him, in its own map coordinates.
558 pub link: (u16, u16),
assert_bp failures so far, and the last one's frame and address.
base() calls that mapped to nothing (should stay 0).
564 pub stray_reads: u64,
ap_manual_mode: the bot has run out of goals and given the pad back
to the human for good (ap_plan.c:915-918, alttp.c:47).
567 pub manual_mode: bool,
Goals the bot has given up on (permafailed) that are waiting for Link's possessions to change.
570 pub given_up: usize,
Goals put back after a change, and how many changes did it.
GOAL_SCORE_*, ap_plan.c:7-9: what a goal's last_score means when it
is not a cost.
596impl Bot {
ap_init (ap_snes.c:55): cache every RAM pointer, then load("home")
and load("hpegs") from states, then plan. Call once per process,
with the console at the frame boundary the bot should first see.
output_dir is where the bot's own files go (goals.txt, map,
map.pbm, full_map.dot, ... - upstream writes them into its working
directory; see shim/host.c).
Panics
On a second call in the same process.
607 pub fn start(console: &mut Console, rom: &[u8], states: &mut dyn States, output_dir: &std::path::Path) -> Bot { 608 assert!(!STARTED.swap(true, Ordering::SeqCst), "one zbanks bot per process: its state is C globals"); 609 let dir = std::ffi::CString::new(output_dir.as_os_str().as_encoded_bytes()).expect("output dir has no NUL"); 610 // SAFETY: the shim copies the string. 611 unsafe { zb_set_output_dir(dir.as_ptr()) }; 612 let outcomes_path = output_dir.join(OUTCOMES_FILE); 613 let mut outcomes = outcomes::Outcomes::load(&outcomes_path); 614 if let Err(e) = outcomes.log_to(&outcomes_path) { 615 eprintln!("zbanks: goal outcomes will not be recorded to disk: {e}"); 616 } 617 let wram = Box::leak(vec![0u8; WRAM_LEN].into_boxed_slice()).as_mut_ptr(); 618 let rom_copy = Box::leak(rom.to_vec().into_boxed_slice()); 619 let stray = Box::leak(vec![0u8; 0x10000].into_boxed_slice()).as_mut_ptr(); 620 let _ = MEMORY.set(Memory { wram, rom: rom_copy.as_mut_ptr(), rom_len: rom_copy.len(), stray }); 621 refresh_wram(console); 622 // SAFETY: installs a SIGTRAP handler; see shim/host.c. 623 unsafe { zb_install_trap_handler() }; 624 let mut bot = Bot { 625 frame: 0, 626 retry_given_up: true, 627 possessions: None, 628 retried: 0, 629 retries: 0, 630 outcomes, 631 _not_send: std::marker::PhantomData, 632 }; 633 bot.call(console, states, |emu| unsafe { 634 (*emu).info_string_ptr = &raw mut INFO_STRING; 635 ap_init(emu); 636 }); 637 bot 638 }
640 fn call<R>(&mut self, console: &mut Console, states: &mut dyn States, f: impl FnOnce(*mut ApSnes9x) -> R) -> R { 641 // SAFETY: the lifetimes are erased only for the duration of `f`; CALL 642 // is cleared before this returns (the borrows outlive it). 643 let states: *mut (dyn States + '_) = states; 644 let states: *mut (dyn States + 'static) = unsafe { std::mem::transmute(states) }; 645 CALL.with(|c| *c.borrow_mut() = Some(Call { console, states })); 646 let r = f(&raw mut EMU); 647 CALL.with(|c| *c.borrow_mut() = None); 648 r 649 }
One ap_tick, the way snes9x.patch calls it: RAM as the last frame
left it, human the pad the player is holding (Snes9x layout;
holding X pauses the bot, alttp.c:57). Returns the pad for the coming
frame; the caller holds it ([apply_pad]) and runs the frame. The
bot's RAM writes are in the console when this returns.
656 pub fn tick(&mut self, console: &mut Console, states: &mut dyn States, human: u16) -> u16 { 657 refresh_wram(console); 658 let frame = self.frame; 659 FRAME.with(|f| f.set(frame)); 660 let mut pad = human; 661 self.call(console, states, |_| unsafe { ap_tick(frame, &mut pad) }); 662 flush_wram(console); 663 self.frame += 1; 664 self.watch_given_up(console); 665 pad 666 }
After a tick: note any goal the bot just gave up on, and if Link's possessions changed since the last tick, put every given-up goal back (shim/host.c, "goals the bot gave up on"). Bounded: a goal only comes back when something Link has changed, and gives up again after the bot's own four attempts.
673 fn watch_given_up(&mut self, console: &Console) { 674 // SAFETY: between ticks; reads the bot's goal list. 675 unsafe { zb_watch_goals() }; 676 self.drain_outcomes(); 677 let now = possessions(console.wram()); 678 let Some(before) = self.possessions.replace(now.clone()) else { return }; 679 // SAFETY: as above. 680 if !self.retry_given_up || before == now || unsafe { zb_given_up_count() } == 0 { 681 return; 682 } 683 let why = format!("Link's possessions changed ({})", possessions_changed(&before, &now).join(", ")); 684 self.retry_all_given_up(&why); 685 }
Drain shim/host.c's own outcome ring (zb_drain_goal_outcomes,
filled by zb_watch_goals) into [Self::outcomes], which appends
each to goal_outcomes.jsonl at once - a restart's fresh bot has no
C-side memory of its own, so this file is what tells it a re-offered
goal already failed (or succeeded) here before.
692 fn drain_outcomes(&mut self) { 693 const MAX: usize = 64; 694 let mut raw: [RawOutcome; MAX] = [const { 695 RawOutcome { identity: [0; 192], attempts: 0, completed: 0, frame: 0 } 696 }; MAX]; 697 // SAFETY: `raw` is a valid, correctly-sized out-param; the shim 698 // writes at most `MAX` entries and returns how many. 699 let n = unsafe { zb_drain_goal_outcomes(raw.as_mut_ptr(), MAX) }; 700 for r in &raw[..n] { 701 // SAFETY: `identity` is a NUL-terminated C string the shim 702 // built with snprintf into a fixed-size buffer. 703 let identity = unsafe { CStr::from_ptr(r.identity.as_ptr()) }.to_string_lossy().into_owned(); 704 self.outcomes.record(outcomes::Outcome { 705 identity, 706 attempts: r.attempts, 707 completed: r.completed != 0, 708 frame: r.frame, 709 }); 710 } 711 }
Put back every goal the bot has given up on, unconditionally -
[Self::watch_given_up]'s own retry, without waiting for a
possession change. [recovery::Recovery] calls this after a stall a
possession change was never going to fix (research/zbanks-alttp.md,
the door D 0x80 stall in room $0123: the same task failed five
times while Link sat idle, and nothing about what he held was ever
going to change on its own). Returns how many were added.
720 pub fn retry_all_given_up(&mut self, why: &str) -> usize { 721 let why = std::ffi::CString::new(why).unwrap_or_default(); 722 // SAFETY: between ticks; the shim adds goals through the bot's own 723 // ap_goal_add and copies nothing it keeps from `why`. 724 let added = unsafe { zb_retry_given_up(why.as_ptr()) }; 725 if added > 0 { 726 self.retried += added as u64; 727 self.retries += 1; 728 } 729 added 730 }
How many goals have completed so far: left the bot's goal list with
attempts <= 3 (zb_watch_goals's own test - a permafailed goal
leaves with more, packages/zbanks/CLAUDE.md). A [recovery::Recovery]
progress signal alongside [possessions]: a goal finishing is
evidence a retry actually helped, not just that it was accepted.
The console was replaced under the bot (a snapshot loaded, a power cycle) - as when a Snes9x user loads a state: the bot keeps its map and goals, and the frame count runs on (Snes9x's TotalEmulatedFrames is not part of a freeze file). Nothing to do but say so; here for the host to call so the intent is written down.
747 pub fn console_replaced(&mut self) {}
What the bot says about itself.
750 pub fn report(&self, max_goals: usize) -> Report { 751 let mut buf = vec![0 as c_char; 64 * 1024]; 752 // SAFETY: plain reads of the bot's globals, between ticks. 753 unsafe { 754 let info = CStr::from_ptr(zb_info_string()).to_string_lossy().into_owned(); 755 let n = zb_tasks(buf.as_mut_ptr(), buf.len()); 756 let tasks = String::from_utf8_lossy(bytes(&buf[..n])).lines().map(String::from).collect(); 757 let n = zb_goals(buf.as_mut_ptr(), buf.len(), max_goals); 758 let goals = String::from_utf8_lossy(bytes(&buf[..n])) 759 .lines() 760 .map(|line| { 761 let mut f = line.split('\t'); 762 Goal { 763 kind: f.next().unwrap_or("").into(), 764 node: f.next().unwrap_or("").into(), 765 screen: f.next().unwrap_or("").into(), 766 attempts: f.next().and_then(|s| s.parse().ok()).unwrap_or(0), 767 last_score: f.next().and_then(|s| s.parse().ok()).unwrap_or(0), 768 } 769 }) 770 .collect(); 771 let (mut x, mut y) = (0, 0); 772 zb_link_xy(&mut x, &mut y); 773 Report { 774 frame: self.frame.saturating_sub(1), 775 info, 776 tasks, 777 goals, 778 goal_count: zb_goal_count(), 779 link: (x, y), 780 traps: zb_traps(), 781 last_trap_frame: zb_last_trap_frame(), 782 last_trap_ip: zb_last_trap_ip().wrapping_sub(zb_anchor() as u64), 783 stray_reads: STRAY_READS.load(Ordering::Relaxed), 784 manual_mode: ap_manual_mode, 785 given_up: zb_given_up_count(), 786 retried: self.retried, 787 retries: self.retries, 788 } 789 } 790 }
ap_map_export(name) (ap_map.c:3471-3499): everything the bot has
learned of the map - screens, nodes, how transitions connect, every
tile attribute - in the file format its own ap_map_import reads.
Upstream carried its map from run to run this way: each ap_tick's
first frame imports map.19.txt (alttp.c:31-38), a file promoted by
hand from earlier exports (the commented map_state.5.00019532.txt,
map_state.7.00021016.txt beside it). name lands in the output
directory, like every file the bot writes.
Hand the goal choice (ap_goal_evaluate, via carried patch 0002) to
chooser, offering it every satisfiable goal scored within margin
of upstream's lowest; None gives it back to upstream. With no
chooser the bot plays exactly as upstream's does.
813 pub fn set_goal_chooser(&mut self, chooser: Option<Box<dyn GoalChooser>>, margin: i32) { 814 let on = chooser.is_some(); 815 CHOOSER.with(|c| *c.borrow_mut() = chooser); 816 // SAFETY: sets two statics in the shim; the bot is between ticks. 817 unsafe { zb_set_goal_chooser(on.then_some(choose_goal as RawChooser), margin) }; 818 }
Push out whatever the bot has printed and stdio is still holding.
Every savestate load/save the bot has asked for: (verb, name, ok).
---- The Randomizer, made of a vanilla cartridge --------------------------------
What the ROM upstream played on does that a vanilla USA cartridge does not, and the bot depends on - done by the host instead of by the Randomizer's patcher. See research/zbanks-alttp.md §"Vanilla is not the Randomizer" for the evidence behind each.
Upstream played the Randomizer in "Open mode, Sword on Uncle" (its
README). Open mode is not in the bot; it is in the ROM and the save the
bot ran on. The Randomizer builds it from an initial save
(sporchia/alttp_vt_randomizer 219fcaf, Rom::setOpenMode
app/Rom.php:1554-1563, InitialSram app/Support/InitialSram.php:24-31,
78-80, 638-660) and from ROM patches (KatDevsGames/z3randomizer
hooks.asm:1558-1566, events.asm:62-85). This module is the two of those
the bot meets, [patch_rom] and [preset_file].
852pub mod rando {
A change to the cartridge image: (USA address, the vanilla bytes there, what they become).
No item-get text: upstream's own 24-minute video (zbanks/alttp
media branch, alttp_20200104.mp4) shows not one text box across
~45 item checks, sampled 20 times a second, while the bot cannot
close one - after OPEN_CHEST finishes (ap_plan.c:445-447) the next
task follows targets, and ap_follow_targets clears A every frame
(ap_map.c:825), so a message holds Link still until its goals time
out and fail for good. Three places show one in vanilla, and each is
made to show none.
870 const FF_76: [u8; 0x4C * 2] = [0xFF; 0x4C * 2];
872 pub const PATCHES: [Patch; 7] = [ 873 Patch { 874 address: 0x05DF65, 875 vanilla: &[0xA9, 0x01, 0x8F, 0xC5, 0xF3, 0x7E], 876 patched: &[0xEA; 6], 877 why: "Uncle_GrantEquipment's `LDA #$01 : STA $7EF3C5` (usdasm bank_05.asm:17083-17084) \ 878 would put an open-mode game back into the rain; the Randomizer NOPs it \ 879 (z3randomizer hooks.asm:1560-1562, Open Mode Fixes)", 880 }, 881 Patch { 882 address: 0x08C2DD, 883 vanilla: &VANILLA_ITEM_MESSAGES, 884 patched: &FF_76, 885 why: "Ancilla22_ItemReceipt .message, one word per item (usdasm bank_08.asm:13233-13310): \ 886 $FFFF is 'no message' (bank_08.asm:13773-13775)", 887 }, 888 Patch { 889 address: 0x08C380, 890 vanilla: &[0x55, 0x01, 0x56, 0x01, 0x57, 0x01], 891 patched: &[0xFF; 6], 892 why: "Ancilla22_ItemReceipt .heart_piece_message, messages $155-$157 for a heart piece \ 893 from a chest (usdasm bank_08.asm:13322-13326, used at 13754-13756)", 894 }, 895 Patch { 896 address: 0x05F0BC, 897 vanilla: &[0x22, 0x19, 0xE2, 0x05], 898 patched: &[0xEA; 4], 899 why: "Sprite_EB_HeartPiece's `JSL ShowMessageUnconditional` for a heart piece lying on \ 900 the ground (usdasm bank_05.asm:20413-20422); the registers it leaves are not read after", 901 }, 902 Patch { 903 address: 0x05DF34, 904 vanilla: &[0x22, 0xF0, 0xE1, 0x05], 905 patched: &[0x22, 0x29, 0xF1, 0x06], 906 why: "Uncle_LyingInDefeat's `JSL ShowMessageOnContact` (message $0E, usdasm \ 907 bank_05.asm:17049-17052) becomes the contact test that routine starts with, \ 908 `JSL CheckDamageToLink_same_layer_long` ($06F129, bank_05.asm:17556): same carry \ 909 on touching him, no text box. The Randomizer blanks that message \ 910 (uncle_dying_sewer to {NOTEXT}, alttp_vt_randomizer app/Text.php:1096, 1107), \ 911 and the bot's TALK_NPC task ends the moment uncle is touched (ap_plan.c:466-469)", 912 }, 913 Patch { 914 address: 0x05E776, 915 vanilla: &[0x22, 0xF0, 0xE1, 0x05], 916 patched: &[0x22, 0x29, 0xF1, 0x06], 917 why: "Snitch_Meander's `JSL ShowMessageOnContact` (message $2F, 'Here is [LINK], the \ 918 wanted man!', usdasm bank_05.asm:18772-18789, text.asm:1315-1318) - the Kakariko \ 919 informants, sprites $34/$3D, present whenever the game state is 2, which open \ 920 mode sets (bank_09.asm:15742-15755) - becomes `JSL \ 921 CheckDamageToLink_same_layer_long` ($06F129): same carry on contact, so he still \ 922 calls the guards and runs, but no text box. NOT a Randomizer difference: the \ 923 Randomizer keeps this message, retexted (alttp_vt_randomizer app/Text.php:167, not \ 924 in removeUnwanted, 931-1127). It is here because the bot cannot close a box it \ 925 walked into: it mashes A for dialog (ap_plan.c:357-366) and ap_follow_targets \ 926 clears A on the same frame (ap_map.c:825). Upstream's video walks past both \ 927 informants in Kakariko (1220-1367 s) without either touching Link; ours was \ 928 walked into by one (run s1map, frame ~22,900) and held until manual mode", 929 }, 930 Patch { 931 address: 0x05E781, 932 vanilla: &[0x98, 0x9D, 0xE0, 0x0D], 933 patched: &[0xEA; 4], 934 why: "Snitch_Meander's `TYA : STA $0DE0,X` after the contact test above: \ 935 ShowMessageOnContact left Link's facing in Y for the informant to face, but \ 936 CheckDamageToLink leaves $0E40,X ($82) there (bank_06.asm:06F16F-06F172), which \ 937 would be stored as a direction; without the store he keeps his own, and \ 938 Snitch_FreakOut zeroes $0DE0 anyway (bank_05.asm:05E81F)", 939 }, 940 ];
The vanilla USA 1.0 item message table at $08C2DD, as dumped from the ROM (SHA-1 6d4f10a8...), so a patch can refuse any other image.
944 #[rustfmt::skip] 945 const VANILLA_ITEM_MESSAGES: [u8; 0x4C * 2] = [ 946 0xFF, 0xFF, 0x70, 0x00, 0x77, 0x00, 0x52, 0x00, 0xFF, 0xFF, 0x78, 0x00, 0x78, 0x00, 0x62, 0x00, 947 0x61, 0x00, 0x66, 0x00, 0x69, 0x00, 0x53, 0x00, 0x52, 0x00, 0x56, 0x00, 0xFF, 0xFF, 0x64, 0x00, 948 0x63, 0x00, 0x65, 0x00, 0x51, 0x00, 0x54, 0x00, 0x67, 0x00, 0x68, 0x00, 0x6B, 0x00, 0x77, 0x00, 949 0x79, 0x00, 0x55, 0x00, 0x6E, 0x00, 0x58, 0x00, 0x6D, 0x00, 0x5D, 0x00, 0x57, 0x00, 0x5E, 0x00, 950 0xFF, 0xFF, 0x74, 0x00, 0x75, 0x00, 0x76, 0x00, 0xFF, 0xFF, 0x5F, 0x00, 0x58, 0x01, 0xFF, 0xFF, 951 0x6A, 0x00, 0x5C, 0x00, 0x8F, 0x00, 0x71, 0x00, 0x72, 0x00, 0x73, 0x00, 0x71, 0x00, 0x72, 0x00, 952 0x73, 0x00, 0x6A, 0x00, 0x6C, 0x00, 0x60, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x59, 0x00, 953 0x84, 0x00, 0x5A, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x59, 0x01, 954 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 955 0xFF, 0xFF, 0xDB, 0x00, 0x67, 0x00, 0x7C, 0x00, 956 ];
Apply [PATCHES] to a USA 1.0 image in place. An image already
patched is left as it is; anything else at those addresses is a
different ROM, refused before anything is changed.
965 pub fn patch_rom(rom: &mut [u8]) -> Result<(), String> { 966 for patch in &PATCHES { 967 let at = offset(patch.address); 968 let here = rom.get(at..at + patch.vanilla.len()).ok_or("ROM too small")?; 969 if here != patch.vanilla && here != patch.patched { 970 return Err(format!("${:06X} is neither vanilla USA 1.0 nor patched", patch.address)); 971 } 972 } 973 for patch in &PATCHES { 974 let at = offset(patch.address); 975 rom[at..at + patch.patched.len()].copy_from_slice(patch.patched); 976 } 977 Ok(()) 978 }
(offset into a save file, value), OR-ed in as InitialSram::setValue
does. Offsets are into the 0x500-byte file, which the game keeps at
$7EF000 while playing.
983 pub const PRESET: [(usize, u8); 7] = [ 984 // InitialSram::__construct - every Randomizer file starts this way. 985 (0x20D, 0xF0), // room 0x106 (Kakariko bomb hut) pre-opened 986 (0x20F, 0xF0), // room 0x107 (brewery) pre-opened 987 (0x379, 0b0110_1000), // ability flags 988 // Rom::setOpenMode 989 (0x280 + 0x1B, 0x20), // preOpenCastleGate: overworld 0x1B 990 (0x3C5, 0x02), // setProgressIndicator(2): Zelda rescued - no rain 991 (0x3C6, 0x14), // setProgressFlags(0x14): Zelda at the sanctuary, uncle left the house 992 (0x3C8, 0x01), // setStartingEntrance(1): Link's house 993 ]; 994 // Not set: 0x401/0x402 = 0xFF - the Randomizer's own save bytes, which 995 // only its own code reads.
Where save file slot (0-2) and its backup copy live in the
cartridge's SRAM (SaveGameFile, usdasm bank_00.asm:1650-1718).
The game's own check: a file's 16-bit words, the last at 0x4FE, sum to 0x5A5A (usdasm bank_00.asm:1698-1718).
Put the open-mode preset into save file slot of sram, with the
checksum redone, and make the backup copy the same file (a file just
made in name entry has only its primary copy written; SaveGameFile
writes both). Refuses a primary copy that is not a valid save.
1014 pub fn preset_file(sram: &mut [u8], slot: usize) -> Result<(), String> { 1015 let [primary, backup] = file_offsets(slot); 1016 if sram.len() < backup + 0x500 { 1017 return Err("SRAM too small".into()); 1018 } 1019 let file = &mut sram[primary..primary + 0x500]; 1020 if !checksum_ok(file) { 1021 return Err(format!("save file {slot} has a bad checksum; create it first")); 1022 } 1023 for (offset, value) in PRESET { 1024 file[offset] |= value; 1025 } 1026 let sum = file[..0x4FE] 1027 .chunks_exact(2) 1028 .fold(0u16, |s, w| s.wrapping_add(u16::from_le_bytes([w[0], w[1]]))); 1029 file[0x4FE..0x500].copy_from_slice(&0x5A5Au16.wrapping_sub(sum).to_le_bytes()); 1030 debug_assert!(checksum_ok(file)); 1031 sram.copy_within(primary..primary + 0x500, backup); 1032 Ok(()) 1033 } 1034}