1//! zbanks/alttp - the C bot that cleared Eastern Palace - driving our console. 2//! 3//! The bot is compiled from its own sources (`//third-party/c:zbanks-alttp`, 4//! upstream commit bcb2537, plus the carried patches in 5//! `third-party/c/patches/`), and linked here. 6//! Upstream links it into a patched Snes9x (`snes9x.patch`), which gives it 7//! exactly two things, and this crate is those two things over [`Console`]: 8//! 9//! - `struct ap_snes9x` (`alttp_public.h`): `base(addr)` - a pointer to the 10//! byte at a SNES address, which the bot caches for its whole life in 11//! `ap_init` and reads and WRITES through - plus `save`/`load` of named 12//! savestates and a slot for its one-line info string. 13//! - `ap_tick(frame, *joypad)` once per frame, before the frame runs, with the 14//! pad the human is holding; whatever it leaves in `*joypad` is the pad for 15//! that frame, and afterwards the human's pad is put back. 16//! 17//! `research/zbanks-alttp.md` has the contract with citations and every way 18//! this host differs from upstream's. 19//! 20//! The bot's state is C globals, so there is one bot per process; [`Bot::start`] 21//! refuses a second. 22 23use std::cell::RefCell; 24use std::ffi::{CStr, c_char, c_int}; 25use std::sync::atomic::{AtomicBool, Ordering}; 26 27use console::{Console, SnesButton, WRAM_LEN}; 28 29pub mod outcomes; 30pub mod recovery; 31pub mod stall; 32 33// ---- The C side ------------------------------------------------------------- 34 35/// `struct ap_snes9x`, alttp_public.h:10-15. 36#[repr(C)] 37struct ApSnes9x { 38 base: extern "C" fn(addr: u32) -> *mut u8, 39 save: extern "C" fn(filename: *const c_char) -> c_int, 40 load: extern "C" fn(filename: *const c_char) -> c_int, 41 info_string_ptr: *mut *const c_char, 42} 43 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} 73 74/// `struct zb_raw_outcome`, shim/host.c: one goal's outcome as the shim 75/// describes it, exactly the fields [`outcomes::Outcome`] keeps. 76#[repr(C)] 77struct RawOutcome { 78 identity: [c_char; 192], 79 attempts: c_int, 80 completed: c_int, 81 frame: u32, 82} 83 84// ---- The goal choice ------------------------------------------------------------ 85 86/// `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} 115 116/// `struct zb_goal_context`, shim/host.c. 117#[repr(C)] 118struct RawContext { 119 link_screen: *const c_char, 120 link_indoors: u8, 121 has_sword: u8, 122 link_x: u16, 123 link_y: u16, 124 screen_x0: u16, 125 screen_y0: u16, 126 screen_x1: u16, 127 screen_y1: u16, 128} 129 130type RawChooser = extern "C" fn(options: *const RawGoalOption, count: usize, context: *const RawContext) -> c_int; 131 132/// Where Link is when a goal choice is made. 133#[derive(Clone, Debug, Default, PartialEq, Eq)] 134pub struct Situation { 135 /// The bot's name for the screen Link is on. 136 pub link_screen: String, 137 pub link_indoors: bool, 138 pub has_sword: bool, 139 /// Link where the bot puts him, in its coordinates (indoors is 140 /// x >= 0x4000, ap_math.h). 141 pub link: (u16, u16), 142 /// The bounds of Link's screen, top-left and bottom-right; zero when the 143 /// bot has no screen for where he stands. 144 pub screen: ((u16, u16), (u16, u16)), 145} 146 147/// One goal the bot could pursue next, in its own records' terms. Offered only 148/// when it is satisfiable and its score is within the chooser's margin of the 149/// lowest; the first option is always upstream's own pick. 150#[derive(Clone, Debug, PartialEq, Eq, Hash)] 151pub struct GoalOption { 152 /// `PICKUP`, `CHEST`, `EXPLORE`, `ITEM`, `NPC` or `SCRIPT` (ap_plan.h:5-12). 153 pub kind: String, 154 /// The node's name in the bot's words ("door U 0x84 DOOR|NODE NORM", 155 /// "chest 0", "sprite 0x73.0x100 TALK|NODE", "Script: ..."). 156 pub node: String, 157 /// The screen's name: a given one ("Well Uncle") or its id and bounds. 158 pub screen: String, 159 pub screen_id: u16, 160 pub indoors: bool, 161 /// For an exit, which way it leads: 1 north, 2 south, 3 west, 4 east 162 /// (ap_map.h:9); 0 for anything else. 163 pub direction: u8, 164 pub sprite_type: u8, 165 pub sprite_subtype: u16, 166 /// What the bot believes is there (ap_item.c), empty if unknown. 167 pub item: String, 168 /// `ap_req_print` of the goal's requirements. 169 pub needs: String, 170 /// `ap_goal_score` now; lower is what upstream would do first. 171 pub score: i32, 172 /// The path-length part of the score (score less attempts x 100 and the 173 /// no-sword penalty, ap_plan.c:758-775). 174 pub distance: i32, 175 pub attempts: i32, 176 /// An exit whose far side the bot has already mapped. 177 pub adjacent_known: bool, 178 /// The node's centre, in the bot's coordinates. 179 pub at: (u16, u16), 180 /// Its screen's bounds, top-left and bottom-right. 181 pub screen_bounds: ((u16, u16), (u16, u16)), 182 /// The bot's own path to it: the node name of the exit it leaves Link's 183 /// screen by (empty when the goal is on Link's screen), that exit's 184 /// direction (as `direction`), and how many screens the path enters. 185 pub via: String, 186 pub via_direction: u8, 187 pub screens_on_path: u8, 188 /// Bit j: this goal's node lies on option j's path - doing it is on the 189 /// way to option j. 190 pub on_way_to: u8, 191} 192 193/// Who makes the goal choice when the bot makes one (carried patch 0002). The 194/// answer is an index into `options`; `None`, or anything out of range, keeps 195/// upstream's pick (index 0). 196pub trait GoalChooser { 197 fn choose(&mut self, frame: u32, here: &Situation, options: &[GoalOption]) -> Option<usize>; 198} 199 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} 254 255// ---- The joypad word ---------------------------------------------------------- 256 257/// 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]; 272 273/// Hold exactly the buttons in `pad` (Snes9x's layout) on controller one. 274pub fn apply_pad(console: &mut Console, pad: u16) { 275 for (bit, button) in PAD_BITS { 276 console.set_button(button, pad & bit != 0); 277 } 278} 279 280/// The buttons a pad word holds, in [`PAD_BITS`] order. 281pub fn pad_buttons(pad: u16) -> impl Iterator<Item = SnesButton> { 282 PAD_BITS.into_iter().filter(move |(bit, _)| pad & bit != 0).map(|(_, b)| b) 283} 284 285/// The pad word for a set of held buttons. 286pub fn pad_word(held: impl IntoIterator<Item = SnesButton>) -> u16 { 287 let mut pad = 0; 288 for button in held { 289 for (bit, b) in PAD_BITS { 290 if b == button { 291 pad |= bit; 292 } 293 } 294 } 295 pad 296} 297 298// ---- The memory `base()` serves ------------------------------------------------- 299 300/// Where the bot's pointers point. Allocated once and never freed or moved: 301/// `ap_init` caches a pointer per RAM variable (ap_snes.c:60-61) and uses it 302/// for the life of the process, as it could inside Snes9x, whose memory map 303/// never moves either. 304struct Memory { 305 /// A copy of work RAM, `$7E0000-$7FFFFF`. Filled from the console before 306 /// every tick and written back after it, which is where the bot's 307 /// cheats (alttp.c:20-26) land in the game. 308 wram: *mut u8, 309 /// The cartridge, LoROM, as loaded. Read-only to the bot in practice. 310 rom: *mut u8, 311 rom_len: usize, 312 /// Returned for an address Snes9x itself would give a NULL for (I/O 313 /// registers, unmapped space). Upstream would crash on a NULL; the bot 314 /// never asks for one (every address it asks for is listed in 315 /// research/zbanks-alttp.md), and this page records if it ever does. 316 stray: *mut u8, 317} 318 319// SAFETY: the pointers are to leaked, never-freed allocations, only touched on 320// the thread that owns the [`Bot`] (the bot is not reentrant anyway). 321unsafe impl Send for Memory {} 322unsafe impl Sync for Memory {} 323 324static MEMORY: std::sync::OnceLock<Memory> = std::sync::OnceLock::new(); 325static STARTED: AtomicBool = AtomicBool::new(false); 326 327/// Every address `base()` was asked for that mapped to nothing, counted. 328static STRAY_READS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); 329 330/// The bot was written against the Randomizer, which is built on the 331/// JAPANESE 1.0 ROM; ours is USA 1.0. Work RAM is laid out the same in both, 332/// but code and data in ROM moved, so every ROM table the bot reads 333/// (ap_snes.h:102-169, ap_map.c:364-420) is at a Japanese address that holds 334/// something else in ours. Each row is (Japanese address, length, the same 335/// table's USA address), found by matching the table's bytes from 336/// spannerisms/jpdasm in our ROM; every row's bytes are identical in both 337/// (research/zbanks-alttp.md §ROM). `base()` answers a Japanese address from 338/// the USA table - the bot asks for the chest table and gets the chest table. 339/// Addresses outside these rows are the same in both ROMs (Map16Definitions 340/// 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]; 354 355fn jp_to_us(addr: u32) -> u32 { 356 for (jp, len, us) in JP_TO_US { 357 if addr >= jp && addr < jp + len { 358 return us + (addr - jp); 359 } 360 } 361 addr 362} 363 364/// `base()`: Snes9x's `S9xGetMemPointer` for a LoROM cartridge with the 365/// memory the bot uses. Banks `$7E-$7F` are work RAM; the low 8K of banks 366/// `$00-$3F`/`$80-$BF` mirror its first 8K; `$8000-$FFFF` of every other 367/// bank is 32K of ROM. As in Snes9x the pointer is into one flat array, so 368/// the bot's own arithmetic past it (ap_ram arrays, ROM tables) walks on 369/// 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} 392 393// ---- Savestates ------------------------------------------------------------------- 394 395/// Where the bot's named savestates live. Upstream's are Snes9x freeze files 396/// in its working directory; ours are console snapshots, kept wherever the 397/// host keeps them (beside the ROM, for the apps). 398pub trait States { 399 fn load(&mut self, name: &str) -> Option<Vec<u8>>; 400 fn save(&mut self, name: &str, snapshot: &[u8]) -> bool; 401} 402 403/// What a load/save callback needs while the C is running: the console, and 404/// where states are. Set only for the duration of a call into the bot. 405struct Call { 406 console: *mut Console, 407 states: *mut dyn States, 408} 409 410thread_local! { 411 static CALL: RefCell<Option<Call>> = const { RefCell::new(None) }; 412 /// Every load/save the bot asked for, and how it went: (verb, name, ok). 413 static STATE_LOG: RefCell<Vec<(&'static str, String, bool)>> = const { RefCell::new(Vec::new()) }; 414} 415 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} 425 426/// 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} 442 443/// 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} 456 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} 468 469// ---- The bot ---------------------------------------------------------------------- 470 471/// The info string slot Snes9x shows on screen (`GFX.InfoString`); the bot 472/// points it at its own buffer every tick (alttp.c:15). 473static mut INFO_STRING: *const c_char = std::ptr::null(); 474static mut EMU: ApSnes9x = ApSnes9x { 475 base, 476 save, 477 load, 478 info_string_ptr: std::ptr::null_mut(), 479}; 480 481/// Where [`Bot::start`] loads and appends goal outcomes, beside 482/// `output_dir` - the same directory `map_export.txt` lives in (a caller's 483/// `bot_dir`), so a restart's fresh bot finds both. 484pub const OUTCOMES_FILE: &str = "goal_outcomes.jsonl"; 485 486/// The bot, started. Not `Send`: the C is not thread-safe and the callbacks 487/// find the console through a thread-local. 488pub struct Bot { 489 frame: u32, 490 /// Whether goals the bot gave up on go back on its list when Link's 491 /// possessions change ([`POSSESSIONS`]). On by default; off, the bot is 492 /// upstream's, which never retries one. 493 pub retry_given_up: bool, 494 /// [`POSSESSIONS`] as of the last tick. 495 possessions: Option<Vec<u8>>, 496 /// Goals put back so far, and the times it happened. 497 retried: u64, 498 retries: u64, 499 /// Every goal's most recent outcome, by identity - loaded from 500 /// `goal_outcomes.jsonl` beside `output_dir` at [`Self::start`] and 501 /// appended to as goals leave the list ([`Self::tick`], which drains 502 /// `shim/host.c`'s own ring every frame). Public so 503 /// `decisions::goal_choice::Facts` can look an offered option's own 504 /// history up by the identical identity string it already computes. 505 pub outcomes: outcomes::Outcomes, 506 _not_send: std::marker::PhantomData<*const ()>, 507} 508 509/// What Link has that can make a goal the bot gave up on worth trying again: 510/// his items, sword, shield and armour, bottles (`$7EF340-$7EF35F`, less the 511/// bomb count `$7EF343`, which the bot's own cheat rewrites every frame), 512/// compasses, big keys and maps (`$7EF364-$7EF369`), the current dungeon's 513/// small keys (`$7EF36F` - a key picked up or spent on a door both change 514/// what is reachable), pendants (`$7EF374`), abilities (`$7EF379`), crystals 515/// (`$7EF37A`) and the game's progress (`$7EF3C5-$7EF3C6`). Not rupees, 516/// health, magic or arrows: those change all the time and open nothing. 517/// Addresses from `research/alttp-ram-map.md` §3. 518pub const POSSESSIONS: &[std::ops::RangeInclusive<u32>] = &[ 519 0x7E_F340..=0x7E_F342, 520 0x7E_F344..=0x7E_F35F, 521 0x7E_F364..=0x7E_F369, 522 0x7E_F36F..=0x7E_F36F, 523 0x7E_F374..=0x7E_F374, 524 0x7E_F379..=0x7E_F37A, 525 0x7E_F3C5..=0x7E_F3C6, 526]; 527 528/// [`POSSESSIONS`], read out of work RAM. 529pub fn possessions(wram: &[u8]) -> Vec<u8> { 530 POSSESSIONS.iter().flat_map(|r| r.clone()).map(|a| wram[(a - 0x7E_0000) as usize]).collect() 531} 532 533/// What changed between two [`possessions`] readings, in words for the log: 534/// `$7EF366 00->04`. 535pub fn possessions_changed(before: &[u8], after: &[u8]) -> Vec<String> { 536 POSSESSIONS 537 .iter() 538 .flat_map(|r| r.clone()) 539 .zip(before.iter().zip(after)) 540 .filter(|(_, (b, a))| b != a) 541 .map(|(addr, (b, a))| format!("${addr:06X} {b:02x}->{a:02x}")) 542 .collect() 543} 544 545/// What the bot has to say about itself, read from its own globals. 546#[derive(Clone, Debug, Default)] 547pub struct Report { 548 /// The frame number last passed to `ap_tick`. 549 pub frame: u32, 550 /// `ap_info_string`: the one line the bot prints over the picture. 551 pub info: String, 552 /// The task list (ap_plan.c), in `ap_print_task`'s words, current first. 553 pub tasks: Vec<String>, 554 /// The first goals on the goal list: (type, node, screen, attempts, last score). 555 pub goals: Vec<Goal>, 556 pub goal_count: usize, 557 /// Link where the bot places him, in its own map coordinates. 558 pub link: (u16, u16), 559 /// `assert_bp` failures so far, and the last one's frame and address. 560 pub traps: u64, 561 pub last_trap_frame: u64, 562 pub last_trap_ip: u64, 563 /// `base()` calls that mapped to nothing (should stay 0). 564 pub stray_reads: u64, 565 /// `ap_manual_mode`: the bot has run out of goals and given the pad back 566 /// to the human for good (ap_plan.c:915-918, alttp.c:47). 567 pub manual_mode: bool, 568 /// Goals the bot has given up on (permafailed) that are waiting for 569 /// Link's possessions to change. 570 pub given_up: usize, 571 /// Goals put back after a change, and how many changes did it. 572 pub retried: u64, 573 pub retries: u64, 574} 575 576#[derive(Clone, Debug, Default)] 577pub struct Goal { 578 pub kind: String, 579 pub node: String, 580 pub screen: String, 581 pub attempts: i32, 582 pub last_score: i32, 583} 584 585/// `GOAL_SCORE_*`, ap_plan.c:7-9: what a goal's `last_score` means when it 586/// is not a cost. 587pub fn score_words(score: i32) -> Option<&'static str> { 588 match score { 589 -2 => Some("complete"), 590 i32::MAX => Some("over limit"), 591 s if s == i32::MAX - 1 => Some("unsatisfiable"), 592 _ => None, 593 } 594} 595 596impl Bot { 597 /// `ap_init` (ap_snes.c:55): cache every RAM pointer, then `load("home")` 598 /// and `load("hpegs")` from `states`, then plan. Call once per process, 599 /// with the console at the frame boundary the bot should first see. 600 /// 601 /// `output_dir` is where the bot's own files go (goals.txt, map, 602 /// map.pbm, full_map.dot, ... - upstream writes them into its working 603 /// directory; see shim/host.c). 604 /// 605 /// # Panics 606 /// 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 } 639 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 } 650 651 /// One `ap_tick`, the way snes9x.patch calls it: RAM as the last frame 652 /// left it, `human` the pad the player is holding (Snes9x layout; 653 /// holding X pauses the bot, alttp.c:57). Returns the pad for the coming 654 /// frame; the caller holds it ([`apply_pad`]) and runs the frame. The 655 /// 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 } 667 668 /// After a tick: note any goal the bot just gave up on, and if Link's 669 /// possessions changed since the last tick, put every given-up goal back 670 /// (shim/host.c, "goals the bot gave up on"). Bounded: a goal only comes 671 /// back when something Link has changed, and gives up again after the 672 /// 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 } 686 687 /// Drain `shim/host.c`'s own outcome ring (`zb_drain_goal_outcomes`, 688 /// filled by `zb_watch_goals`) into [`Self::outcomes`], which appends 689 /// each to `goal_outcomes.jsonl` at once - a restart's fresh bot has no 690 /// C-side memory of its own, so this file is what tells it a re-offered 691 /// 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 } 712 713 /// Put back every goal the bot has given up on, unconditionally - 714 /// [`Self::watch_given_up`]'s own retry, without waiting for a 715 /// possession change. [`recovery::Recovery`] calls this after a stall a 716 /// possession change was never going to fix (research/zbanks-alttp.md, 717 /// the door `D 0x80` stall in room `$0123`: the same task failed five 718 /// times while Link sat idle, and nothing about what he held was ever 719 /// 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 } 731 732 /// How many goals have completed so far: left the bot's goal list with 733 /// `attempts <= 3` (`zb_watch_goals`'s own test - a permafailed goal 734 /// leaves with more, `packages/zbanks/CLAUDE.md`). A [`recovery::Recovery`] 735 /// progress signal alongside [`possessions`]: a goal finishing is 736 /// evidence a retry actually helped, not just that it was accepted. 737 pub fn completed_count(&self) -> u64 { 738 // SAFETY: plain read of the shim's own counter, between ticks. 739 unsafe { zb_completed_count() } 740 } 741 742 /// The console was replaced under the bot (a snapshot loaded, a power 743 /// cycle) - as when a Snes9x user loads a state: the bot keeps its map 744 /// and goals, and the frame count runs on (Snes9x's TotalEmulatedFrames 745 /// is not part of a freeze file). Nothing to do but say so; here for the 746 /// host to call so the intent is written down. 747 pub fn console_replaced(&mut self) {} 748 749 /// 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 } 791 792 /// `ap_map_export(name)` (ap_map.c:3471-3499): everything the bot has 793 /// learned of the map - screens, nodes, how transitions connect, every 794 /// tile attribute - in the file format its own `ap_map_import` reads. 795 /// Upstream carried its map from run to run this way: each `ap_tick`'s 796 /// first frame imports `map.19.txt` (alttp.c:31-38), a file promoted by 797 /// hand from earlier exports (the commented `map_state.5.00019532.txt`, 798 /// `map_state.7.00021016.txt` beside it). `name` lands in the output 799 /// directory, like every file the bot writes. 800 pub fn export_map(&self, name: &str) { 801 let name = std::ffi::CString::new(name).expect("no NUL in a file name"); 802 // SAFETY: between ticks; the bot only reads its own structures. 803 unsafe { 804 ap_map_export(name.as_ptr()); 805 zb_flush_stdout(); 806 } 807 } 808 809 /// Hand the goal choice (ap_goal_evaluate, via carried patch 0002) to 810 /// `chooser`, offering it every satisfiable goal scored within `margin` 811 /// of upstream's lowest; `None` gives it back to upstream. With no 812 /// 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 } 819 820 /// Push out whatever the bot has printed and stdio is still holding. 821 pub fn flush_log(&self) { 822 // SAFETY: fflush(stdout). 823 unsafe { zb_flush_stdout() }; 824 } 825 826 /// Every savestate load/save the bot has asked for: (verb, name, ok). 827 pub fn state_log(&self) -> Vec<(&'static str, String, bool)> { 828 STATE_LOG.with(|log| log.borrow().clone()) 829 } 830} 831 832fn bytes(chars: &[c_char]) -> &[u8] { 833 // SAFETY: c_char and u8 have the same size and alignment. 834 unsafe { std::slice::from_raw_parts(chars.as_ptr().cast(), chars.len()) } 835} 836 837// ---- The Randomizer, made of a vanilla cartridge -------------------------------- 838 839/// What the ROM upstream played on does that a vanilla USA cartridge does 840/// not, and the bot depends on - done by the host instead of by the 841/// Randomizer's patcher. See research/zbanks-alttp.md §"Vanilla is not the 842/// Randomizer" for the evidence behind each. 843/// 844/// Upstream played the Randomizer in "Open mode, Sword on Uncle" (its 845/// README). Open mode is not in the bot; it is in the ROM and the save the 846/// bot ran on. The Randomizer builds it from an initial save 847/// (sporchia/alttp_vt_randomizer 219fcaf, `Rom::setOpenMode` 848/// app/Rom.php:1554-1563, `InitialSram` app/Support/InitialSram.php:24-31, 849/// 78-80, 638-660) and from ROM patches (KatDevsGames/z3randomizer 850/// hooks.asm:1558-1566, events.asm:62-85). This module is the two of those 851/// the bot meets, [`patch_rom`] and [`preset_file`]. 852pub mod rando { 853 /// A change to the cartridge image: `(USA address, the vanilla bytes 854 /// there, what they become)`. 855 pub struct Patch { 856 pub address: u32, 857 pub vanilla: &'static [u8], 858 pub patched: &'static [u8], 859 pub why: &'static str, 860 } 861 862 /// No item-get text: upstream's own 24-minute video (zbanks/alttp 863 /// `media` branch, alttp_20200104.mp4) shows not one text box across 864 /// ~45 item checks, sampled 20 times a second, while the bot cannot 865 /// close one - after OPEN_CHEST finishes (ap_plan.c:445-447) the next 866 /// task follows targets, and ap_follow_targets clears A every frame 867 /// (ap_map.c:825), so a message holds Link still until its goals time 868 /// out and fail for good. Three places show one in vanilla, and each is 869 /// made to show none. 870 const FF_76: [u8; 0x4C * 2] = [0xFF; 0x4C * 2]; 871 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 ]; 941 942 /// The vanilla USA 1.0 item message table at $08C2DD, as dumped from the 943 /// 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 ]; 957 958 fn offset(address: u32) -> usize { 959 ((address >> 16) & 0x7F) as usize * 0x8000 + (address & 0x7FFF) as usize 960 } 961 962 /// Apply [`PATCHES`] to a USA 1.0 image in place. An image already 963 /// patched is left as it is; anything else at those addresses is a 964 /// 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 } 979 980 /// `(offset into a save file, value)`, OR-ed in as `InitialSram::setValue` 981 /// does. Offsets are into the 0x500-byte file, which the game keeps at 982 /// $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. 996 997 /// Where save file `slot` (0-2) and its backup copy live in the 998 /// cartridge's SRAM (`SaveGameFile`, usdasm bank_00.asm:1650-1718). 999 pub fn file_offsets(slot: usize) -> [usize; 2] { 1000 [slot * 0x500, 0xF00 + slot * 0x500] 1001 } 1002 1003 /// The game's own check: a file's 16-bit words, the last at 0x4FE, sum 1004 /// to 0x5A5A (usdasm bank_00.asm:1698-1718). 1005 fn checksum_ok(file: &[u8]) -> bool { 1006 let sum = file.chunks_exact(2).fold(0u16, |s, w| s.wrapping_add(u16::from_le_bytes([w[0], w[1]]))); 1007 sum == 0x5A5A 1008 } 1009 1010 /// Put the open-mode preset into save file `slot` of `sram`, with the 1011 /// checksum redone, and make the backup copy the same file (a file just 1012 /// made in name entry has only its primary copy written; `SaveGameFile` 1013 /// 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}