lib.rsannotatedlib.rssource1034 lines · 44.4 KB · raw
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}