From bd246d059015414d5e8351c25cd741caeac363f9 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Wed, 21 Oct 2026 13:46:44 -0700 Subject: [PATCH 1/4] Share HiROM contracts across physical aliases without collapsing execution banks --- docs/HIROM_ALIASES.md | 38 +++ recompiler-rs/src/bin/analyze.rs | 19 +- recompiler-rs/src/decoder.rs | 23 +- recompiler-rs/src/hirom_cfg.rs | 245 ++++++++++++++++++ recompiler-rs/src/lib.rs | 1 + recompiler-rs/src/rom.rs | 34 +++ recompiler/snes65816.py | 28 +- recompiler/v2/coverage_profile.py | 12 +- recompiler/v2/hirom_cfg.py | 93 +++++++ recompiler/v2/program_emit.py | 2 +- runner/src/cpu_state.c | 9 +- tests/runtime_dispatch/known_lle_entry_test.c | 27 ++ tests/test_coverage_feedback.py | 2 +- tests/test_hirom_aliases.py | 175 +++++++++++++ tools/disassembly_layout.py | 16 +- tools/export_disassembly_authority.py | 14 +- tools/ingest_disassembly_authority.py | 54 ++-- tools/tier2_ingest.py | 5 +- tools/v2_analyze.py | 2 + 19 files changed, 730 insertions(+), 69 deletions(-) create mode 100644 docs/HIROM_ALIASES.md create mode 100644 recompiler-rs/src/hirom_cfg.rs create mode 100644 recompiler/v2/hirom_cfg.py create mode 100644 tests/test_hirom_aliases.py diff --git a/docs/HIROM_ALIASES.md b/docs/HIROM_ALIASES.md new file mode 100644 index 00000000..823d9d98 --- /dev/null +++ b/docs/HIROM_ALIASES.md @@ -0,0 +1,38 @@ +# HiROM aliases and execution contexts + +Standard HiROM maps the upper halves of banks 00/40/80/C0 to the same ROM +bytes. The same relationship repeats for the other bank indices. Lower +halves alias only between full ROM banks 40–7D and C0–FD; 7E/7F are WRAM. +FE/FF have no whole-bank XOR-80 execution mirror. + +The Python emitter and native analyzer expand authored cfg contracts into +valid physical aliases before analysis. Local entries, hooks and declared +exit contracts take precedence over inherited ones. Unspecified aliases +inherit in sorted source-bank order. Friendly names keep their original +owner. RAM relocation and auto-vector configuration are not cloned. + +Inherited contracts must actually apply at the alias: add an explicit local +entry/exit declaration when execution differs. Inferred exit widths and +compiled functions remain tied to their execution bank. For example, the +same `JMP $1000` bytes at 00:9000 and C0:9000 target RAM and ROM respectively. +The runtime may reuse an XOR-80 body within a low-bank pair or a full-bank +pair only after verifying the live cartridge ROM pointers. It never uses +that check to substitute a full-bank body for a low-bank body. + +Coverage reports include `rom_pc24` for physical grouping while retaining +the raw target, execution variant, outcomes and qualification provenance. +A successful C0 run cannot qualify bank 00. Coverage remains an analysis +seed and never overrides structural analysis or replay requirements. + +Disassembly layout materialization and source export accept standard LoROM +and HiROM, including full-bank HiROM addresses below $8000. Authority ingestion +deduplicates identical physical instruction spans and detects conflicts or +code/data overlap across aliases. Entry widths retain their explicit HiROM +execution bank. The v1 authority schema describes one unconditional physical +instruction layout: alternate-width interpretations with different byte +boundaries need separate authority inputs, not a silently selected layout. + +These changes do not generalize aliasing for SA-1, S-DD1 or other banked +mapping. Synthetic tests cover mapper boundaries, cfg precedence, short-jump +semantics, native/emitter agreement, source export and authority overlays; +the reported Secret of Mana/SD3 scene still needs its author's title replay. diff --git a/recompiler-rs/src/bin/analyze.rs b/recompiler-rs/src/bin/analyze.rs index fd750086..26676d8f 100644 --- a/recompiler-rs/src/bin/analyze.rs +++ b/recompiler-rs/src/bin/analyze.rs @@ -25,8 +25,8 @@ use snesrecomp_analyzer::decoder::{ }; use snesrecomp_analyzer::insn::Mode; use snesrecomp_analyzer::rom::{ - addr_to_rom_offset, detect_rom_mapping, is_rom_address, load_rom, vector_table_offset, - RelocRegion, RomMapping, + addr_to_rom_offset, detect_rom_mapping, execution_mirror_bank as mirror_bank, is_rom_address, + load_rom, vector_table_offset, RelocRegion, RomMapping, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -167,18 +167,6 @@ fn filename_bank(path: &Path) -> Option { u32::from_str_radix(body, 16).ok() } -fn mirror_bank(mapping: RomMapping, bank: u32) -> Option { - if mapping == RomMapping::Sa1 { - return None; - } - let bank = bank & 0xFF; - if bank < 0x40 || (0x80..0xC0).contains(&bank) { - Some(bank ^ 0x80) - } else { - None - } -} - fn mirror_pc24(mapping: RomMapping, pc24: u32) -> Option { mirror_bank(mapping, (pc24 >> 16) & 0xFF).map(|bank| (bank << 16) | (pc24 & 0xFFFF)) } @@ -281,6 +269,9 @@ fn load_inputs(cfg_dir: &Path, rom: &mut Vec, all_cfg_roots: bool) -> Result cfg.bank = bank as i32; cfgs.push(cfg); } + if mapping == RomMapping::HiRom { + cfgs = snesrecomp_analyzer::hirom_cfg::expand(cfgs); + } expand_auto_vectors(&mut cfgs, rom.as_slice()); let mut roots = architectural_roots(rom.as_slice()); diff --git a/recompiler-rs/src/decoder.rs b/recompiler-rs/src/decoder.rs index 8573c685..a1ce15f9 100644 --- a/recompiler-rs/src/decoder.rs +++ b/recompiler-rs/src/decoder.rs @@ -17,7 +17,8 @@ use std::sync::{Arc, Mutex}; use crate::insn::{decode_insn, Insn, Mode}; use crate::rom::{ - addr_in_reloc_region, addr_to_rom_offset, is_rom_address, RelocRegion, RomMapping, + addr_in_reloc_region, addr_to_rom_offset, execution_mirror_bank, is_rom_address, RelocRegion, + RomMapping, }; pub const PHP_STACK_MAX_DEPTH: usize = 8; @@ -871,7 +872,7 @@ fn labeled_successors( let mut skip = map.get(&tp).copied(); if skip.is_none() { let tbank = (tp >> 16) & 0xFF; - if mapping != RomMapping::Sa1 && (tbank < 0x40 || (0x80..0xC0).contains(&tbank)) { + if execution_mirror_bank(mapping, tbank).is_some() { skip = map.get(&(tp ^ 0x800000)).copied(); } } @@ -887,8 +888,7 @@ fn labeled_successors( let mut hit = cem.get(&(tp, post_m, post_x)).copied(); if hit.is_none() { let tbank = (tp >> 16) & 0xFF; - if mapping != RomMapping::Sa1 && (tbank < 0x40 || (0x80..0xC0).contains(&tbank)) - { + if execution_mirror_bank(mapping, tbank).is_some() { hit = cem.get(&(tp ^ 0x800000, post_m, post_x)).copied(); } } @@ -904,8 +904,7 @@ fn labeled_successors( let mut mode_set: Option> = cmm.get(&(tp, post_m, post_x)).cloned(); if mode_set.is_none() { let tbank = (tp >> 16) & 0xFF; - if mapping != RomMapping::Sa1 && (tbank < 0x40 || (0x80..0xC0).contains(&tbank)) - { + if execution_mirror_bank(mapping, tbank).is_some() { mode_set = cmm.get(&(tp ^ 0x800000, post_m, post_x)).cloned(); } } @@ -2515,7 +2514,7 @@ fn lookup_exit_mx( let key = (pc24 & 0xFFFFFF, m & 1, x & 1); map.get(&key).copied().or_else(|| { let bank = (pc24 >> 16) & 0xFF; - if mapping != RomMapping::Sa1 && (bank < 0x40 || (0x80..0xC0).contains(&bank)) { + if execution_mirror_bank(mapping, bank).is_some() { map.get(&((pc24 ^ 0x800000) & 0xFFFFFF, m & 1, x & 1)) .copied() } else { @@ -2539,7 +2538,7 @@ fn lookup_exit_mx_modes( let key = (pc24 & 0xFFFFFF, m & 1, x & 1); let mut result = map.get(&key).cloned().or_else(|| { let bank = (pc24 >> 16) & 0xFF; - if mapping != RomMapping::Sa1 && (bank < 0x40 || (0x80..0xC0).contains(&bank)) { + if execution_mirror_bank(mapping, bank).is_some() { map.get(&((pc24 ^ 0x800000) & 0xFFFFFF, m & 1, x & 1)) .cloned() } else { @@ -3481,18 +3480,14 @@ pub(crate) fn compute_deps( if let Some(tp) = tp { cem_keys.push((tp, ins.m_flag, ins.x_flag)); let tbank = (tp >> 16) & 0xFF; - if graph.rom_mapping != RomMapping::Sa1 - && (tbank < 0x40 || (0x80..0xC0).contains(&tbank)) - { + if execution_mirror_bank(graph.rom_mapping, tbank).is_some() { cem_keys.push((tp ^ 0x800000, ins.m_flag, ins.x_flag)); } } for tp in dispatch_targets { cem_keys.push((tp, ins.m_flag, ins.x_flag)); let tbank = (tp >> 16) & 0xFF; - if graph.rom_mapping != RomMapping::Sa1 - && (tbank < 0x40 || (0x80..0xC0).contains(&tbank)) - { + if execution_mirror_bank(graph.rom_mapping, tbank).is_some() { cem_keys.push((tp ^ 0x800000, ins.m_flag, ins.x_flag)); } } diff --git a/recompiler-rs/src/hirom_cfg.rs b/recompiler-rs/src/hirom_cfg.rs new file mode 100644 index 00000000..4a9f393d --- /dev/null +++ b/recompiler-rs/src/hirom_cfg.rs @@ -0,0 +1,245 @@ +//! Authored contracts shared by physical HiROM aliases; every execution bank +//! remains a separate analysis context. Mirrors v2/hirom_cfg.py. +use crate::cfg::BankCfg; +use crate::rom::hirom_bank_aliases; +use std::collections::HashSet; + +pub fn expand(cfgs: Vec) -> Vec { + let explicit_exits: HashSet<_> = cfgs + .iter() + .flat_map(|c| c.exit_mx_at.iter().map(|&(b, pc, _, _)| (b, pc))) + .collect(); + let explicit_variants: HashSet<_> = cfgs + .iter() + .flat_map(|c| { + c.exit_mx_at_per_variant + .iter() + .map(|&(b, pc, m, x, _, _)| (b, pc, m, x)) + }) + .collect(); + let explicit_sets: HashSet<_> = cfgs + .iter() + .flat_map(|c| { + c.exit_mx_set + .iter() + .map(|(b, pc, m, x, _)| (*b, *pc, *m, *x)) + }) + .collect(); + let mut result = cfgs.clone(); + for src in &cfgs { + let bank = src.bank as u32; + for target in hirom_bank_aliases(bank, 0x8000) { + if target == bank { + continue; + } + let index = match result.iter().position(|c| c.bank == target as i32) { + Some(i) => i, + None => { + result.push(BankCfg { + bank: target as i32, + ..Default::default() + }); + result.len() - 1 + } + }; + let dst = &mut result[index]; + let aliases = |pc: u32| hirom_bank_aliases(bank, pc).contains(&target); + let span = |start: u32, end: u32| { + let floor = if bank & 0x7f < 0x40 || target & 0x7f < 0x40 { + 0x8000 + } else { + 0 + }; + let start = start.max(floor); + (start < end && aliases(start)).then_some((start, end)) + }; + for entry in &src.entries { + if aliases(entry.start) && !dst.entries.iter().any(|e| e.start == entry.start) { + let mut entry = entry.clone(); + entry.name = None; + dst.entries.push(entry); + } + } + for include in &src.includes { + if !dst.includes.contains(include) { + dst.includes.push(include.clone()); + } + } + macro_rules! inherit_map { + ($field:ident) => { + for (&pc, value) in &src.$field { + if aliases(pc) { + dst.$field.entry(pc).or_insert_with(|| value.clone()); + } + } + }; + } + inherit_map!(authority_insns); + inherit_map!(hle_func); + inherit_map!(hle_dispatch); + macro_rules! inherit_set { + ($field:ident) => { + dst.$field + .extend(src.$field.iter().copied().filter(|pc| aliases(*pc))); + }; + } + inherit_set!(terminal_jsr); + inherit_set!(noreturn_jsr); + inherit_set!(live_operand); + inherit_set!(brk_rti); + inherit_set!(inline_dispatch_loops); + for &pc in &src.hle_spc_upload { + if aliases(pc) && !dst.hle_spc_upload.contains(&pc) { + dst.hle_spc_upload.push(pc); + } + } + for &(start, end) in &src.exclude_ranges { + if let Some(range) = span(start, end) { + if !dst.exclude_ranges.contains(&range) { + dst.exclude_ranges.push(range); + } + } + } + for &(start, end) in &src.authority_data { + if let Some(range) = span(start, end) { + if !dst.authority_data.contains(&range) { + dst.authority_data.push(range); + } + } + } + for site in &src.indirect_dispatch { + if aliases(site.site_pc16) + && !dst + .indirect_dispatch + .iter() + .any(|s| s.site_pc16 == site.site_pc16) + { + dst.indirect_dispatch.push(site.clone()); + } + } + for (&pc, &value) in &src.force_variant_at { + if pc >> 16 == bank && aliases(pc & 0xffff) { + dst.force_variant_at + .entry(target << 16 | (pc & 0xffff)) + .or_insert(value); + } + } + } + } + for cfg in &mut result { + cfg.force_lle = cfg + .force_lle + .iter() + .flat_map(|&pc| { + let mut banks = hirom_bank_aliases(pc >> 16, pc & 0xffff); + if banks.is_empty() { + banks.push(pc >> 16); + } + banks.into_iter().map(move |b| b << 16 | (pc & 0xffff)) + }) + .collect(); + macro_rules! expand_exits { + ($field:ident, ($bank:ident, $pc:ident, $($rest:ident),+), $explicit:ident, $key:expr) => {{ + let mut rows = Vec::new(); + for ($bank, $pc, $($rest),+) in &cfg.$field { + let mut banks = hirom_bank_aliases(*$bank as u32, *$pc); + if banks.is_empty() { banks.push(*$bank as u32); } + for b in banks { + let row = (b as u8, *$pc, $($rest.clone()),+); + if b != *$bank as u32 && $explicit.contains(&($key)(&row)) { continue; } + if !rows.contains(&row) { rows.push(row); } + } + } + cfg.$field = rows; + }}; + } + expand_exits!(exit_mx_at, (bank, pc, m, x), explicit_exits, |r: &( + u8, + u32, + u8, + u8 + )| ( + r.0, r.1 + )); + expand_exits!( + exit_mx_at_per_variant, + (bank, pc, m, x, em, ex), + explicit_variants, + |r: &(u8, u32, u8, u8, u8, u8)| (r.0, r.1, r.2, r.3) + ); + expand_exits!( + exit_mx_set, + (bank, pc, m, x, exits), + explicit_sets, + |r: &(u8, u32, u8, u8, Vec<(u8, u8)>)| (r.0, r.1, r.2, r.3) + ); + let mut regions = Vec::new(); + for &(bank, start, end) in &cfg.data_regions { + let mut banks = hirom_bank_aliases(bank, start.max(0x8000)); + if banks.is_empty() { + banks.push(bank); + } + for b in banks { + let lo = start.max(if b & 0x7f < 0x40 { 0x8000 } else { 0 }); + if lo < end && !regions.contains(&(b, lo, end)) { + regions.push((b, lo, end)); + } + } + } + cfg.data_regions = regions; + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cfg::parse_bank_cfg; + use crate::rom::{execution_mirror_bank, RomMapping}; + + #[test] + fn contracts_inherit_only_into_valid_windows_and_preserve_explicit_exits() { + let low = parse_bank_cfg("bank = 00\nexit_mx_at 009000 1 1\n", "low.cfg").unwrap(); + let full = parse_bank_cfg("bank = C0\nfunc Upper 9000 entry_mx:0,1\nfunc Lower 1000\nforce_lle C09000\nexit_mx_at C09000 0 0\n", "full.cfg").unwrap(); + let expanded = expand(vec![low, full]); + let low = expanded.iter().find(|c| c.bank == 0).unwrap(); + assert_eq!(low.entries.len(), 1); + assert_eq!( + ( + low.entries[0].start, + low.entries[0].entry_m, + low.entries[0].entry_x + ), + (0x9000, 0, 1) + ); + let full = expanded.iter().find(|c| c.bank == 0xc0).unwrap(); + assert_eq!( + full.force_lle, + [0x009000, 0x409000, 0x809000, 0xc09000].into() + ); + let exits: Vec<_> = expanded + .iter() + .flat_map(|c| c.exit_mx_at.iter().copied()) + .collect(); + assert!(exits.contains(&(0, 0x9000, 1, 1))); + assert!(!exits.contains(&(0, 0x9000, 0, 0))); + assert!(exits.contains(&(0xc0, 0x9000, 0, 0))); + assert!(!exits.contains(&(0xc0, 0x9000, 1, 1))); + } + + #[test] + fn physical_aliases_do_not_imply_cross_window_execution_mirrors() { + for bank in [0, 0x40, 0x80, 0xc0] { + let mut aliases = hirom_bank_aliases(bank, 0x9000); + aliases.sort(); + assert_eq!(aliases, [0, 0x40, 0x80, 0xc0]); + } + assert_eq!(hirom_bank_aliases(0xc0, 0x1000), [0xc0, 0x40]); + assert!(hirom_bank_aliases(0, 0x1000).is_empty()); + assert!(hirom_bank_aliases(0x7e, 0x9000).is_empty()); + assert_eq!(execution_mirror_bank(RomMapping::HiRom, 0xc0), Some(0x40)); + assert_eq!(execution_mirror_bank(RomMapping::HiRom, 0), Some(0x80)); + assert_eq!(execution_mirror_bank(RomMapping::HiRom, 0xfe), None); + assert_eq!(execution_mirror_bank(RomMapping::Sa1, 0), None); + } +} diff --git a/recompiler-rs/src/lib.rs b/recompiler-rs/src/lib.rs index 16ad445c..5692504a 100644 --- a/recompiler-rs/src/lib.rs +++ b/recompiler-rs/src/lib.rs @@ -7,5 +7,6 @@ pub mod cfg; pub mod decoder; +pub mod hirom_cfg; pub mod insn; pub mod rom; diff --git a/recompiler-rs/src/rom.rs b/recompiler-rs/src/rom.rs index 9a4c7c04..c5fefb6a 100644 --- a/recompiler-rs/src/rom.rs +++ b/recompiler-rs/src/rom.rs @@ -19,6 +19,40 @@ pub enum RomMapping { const SDD1_MMC_DEFAULT_PAGES: [usize; 4] = [0, 1, 2, 3]; +/// Whole-bank execution-compatible XOR-80 pairs. Never cross low/full HiROM banks. +pub fn execution_mirror_bank(mapping: RomMapping, bank: u32) -> Option { + if mapping == RomMapping::HiRom { + return (!matches!(bank & 0xff, 0x7e | 0x7f | 0xfe | 0xff)).then_some((bank & 0xff) ^ 0x80); + } + if mapping == RomMapping::Sa1 { + return None; + } + let bank = bank & 0xFF; + if bank < 0x40 || (0x80..0xC0).contains(&bank) { + Some(bank ^ 0x80) + } else { + None + } +} + +/// Physical HiROM byte aliases. These do not imply identical execution-bank +/// semantics or permit sharing inferred callee exits across low/full banks. +pub fn hirom_bank_aliases(bank: u32, addr: u32) -> Vec { + let bank = bank & 0xff; + let addr = addr & 0xffff; + if !is_rom_address(RomMapping::HiRom, bank, addr) { + return Vec::new(); + } + let mut result = vec![bank]; + for high in [0, 0x40, 0x80, 0xc0] { + let candidate = (bank & 0x3f) | high; + if candidate != bank && is_rom_address(RomMapping::HiRom, candidate, addr) { + result.push(candidate); + } + } + result +} + fn header_score(data: &[u8], base: usize, expected_low_nibble: u8) -> i32 { if base + 0x40 > data.len() { return -1; diff --git a/recompiler/snes65816.py b/recompiler/snes65816.py index 08972644..dc64d8ed 100644 --- a/recompiler/snes65816.py +++ b/recompiler/snes65816.py @@ -178,12 +178,38 @@ def is_rom_address(bank: int, addr: int) -> bool: def rom_bank_mirror(bank: int): - """Only the active mapper's proven reset-map bank aliases.""" + """Execution-compatible XOR-80 bank pair, not every physical ROM alias. + + HiROM's low and full banks share upper-half bytes, but a short transfer + below $8000 observes different memory. They must be analyzed separately. + """ + if _active_rom_mapping == ROM_MAP_HIROM: + return bank ^ 0x80 if bank not in (0x7E, 0x7F, 0xFE, 0xFF) else None if _active_rom_mapping != ROM_MAP_SA1 and (bank < 0x40 or 0x80 <= bank < 0xC0): return bank ^ 0x80 return None +def rom_bank_aliases(bank: int, addr: int) -> tuple[int, ...]: + """Physical reset-map ROM aliases, ordered with the requested bank first. + + This is evidence/configuration identity, not permission to substitute an + analyzed body or its inferred exit modes in another execution bank. + """ + bank, addr = bank & 0xFF, addr & 0xFFFF + if not is_rom_address(bank, addr) or _reloc_lookup(bank, addr) is not None: + return () + if _active_rom_mapping == ROM_MAP_HIROM: + candidates = [(bank & 0x3F) | high for high in (0, 0x40, 0x80, 0xC0)] + else: + mirror = rom_bank_mirror(bank) + candidates = [bank] if mirror is None else [bank, mirror] + offset = rom_offset(bank, addr) + return (bank, *sorted(b for b in set(candidates) if b != bank + and is_rom_address(b, addr) + and rom_offset(b, addr) == offset)) + + def vector_table_offset(data: bytes) -> int: """Return the physical offset of the $FFE0-$FFFF vector table.""" mapping = detect_rom_mapping(data) diff --git a/recompiler/v2/coverage_profile.py b/recompiler/v2/coverage_profile.py index 560fabc1..7e1f08c0 100644 --- a/recompiler/v2/coverage_profile.py +++ b/recompiler/v2/coverage_profile.py @@ -23,7 +23,7 @@ def pc(value): return result -def canonical_pc(value, mapper): +def execution_pc(value, mapper): """Only collapse the documented upper-half LoROM execution mirror. Preserve raw PCs everywhere else, including mapper-dependent SA-1/SDD1 @@ -36,6 +36,16 @@ def canonical_pc(value, mapper): return value +def canonical_pc(value, mapper): + """Physical ROM grouping key; retain raw PCs for execution qualification.""" + value = pc(value) + if mapper == "hirom": + bank, addr = value >> 16, value & 0xFFFF + if bank not in (0x7E, 0x7F) and ((bank & 0x7F) >= 0x40 or addr >= 0x8000): + return ((bank & 0x3F) | 0xC0) << 16 | addr + return execution_pc(value, mapper) + + def row_key(row): return (row.get("processor", "snes_cpu"), row.get("record_kind", "transfer"), pc(row.get("site_pc24", 0)), pc(row["target_pc24"]), row.get("entry_mx", "unknown"), diff --git a/recompiler/v2/hirom_cfg.py b/recompiler/v2/hirom_cfg.py new file mode 100644 index 00000000..31bbfa09 --- /dev/null +++ b/recompiler/v2/hirom_cfg.py @@ -0,0 +1,93 @@ +"""Share authored HiROM contracts without collapsing execution-bank analysis.""" +from copy import deepcopy + +from snes65816 import get_rom_mapping, rom_bank_aliases +from v2.cfg_loader import BankCfg + + +def expand_hirom_cfgs(parsed): + if get_rom_mapping() != 'hirom': + return parsed + originals = {bank: (path, cfg) for bank, path, cfg in parsed} + result = {bank: (path, deepcopy(cfg)) for bank, path, cfg in parsed} + exit_keys = {'exit_mx_at': 2, 'exit_mx_at_per_variant': 4, + 'declared_exit_mx_at_per_variant': 4, 'exit_mx_set': 4} + explicit_exits = {name: {tuple(row[:size]) for _, _, cfg in parsed + for row in getattr(cfg, name)} + for name, size in exit_keys.items()} + # Local declarations win. Inherited declarations are selected in stable + # bank order; no inferred result is ever copied by this preprocessing. + for bank, path, cfg in parsed: + for target in rom_bank_aliases(bank, 0x8000): + if target == bank: + continue + if target not in result: + result[target] = (path, BankCfg(bank=target)) + dst = result[target][1] + + def aliases(pc): + return target in rom_bank_aliases(bank, pc) + + def span(start, end): + # HiROM maps either the whole bank or its upper half. + start = max(start, 0x8000 if target & 0x7F < 0x40 else 0) + start = max(start, 0x8000 if bank & 0x7F < 0x40 else 0) + return (start, end) if start < end and aliases(start) else None + + present = {e.start for e in dst.entries} + for entry in cfg.entries: + if entry.start not in present and aliases(entry.start): + copy = deepcopy(entry) + copy.name = None # one authored friendly symbol, exact-bank bodies + dst.entries.append(copy) + present.add(entry.start) + for name in ('includes',): + for value in getattr(cfg, name): + if value not in getattr(dst, name): + getattr(dst, name).append(value) + for name in ('authority_insns', 'hle_spc_upload', 'hle_func', + 'hle_dispatch', 'entry_hook'): + for pc, value in getattr(cfg, name).items(): + if aliases(pc): + getattr(dst, name).setdefault(pc, deepcopy(value)) + for name in ('terminal_jsr', 'noreturn_jsr', 'live_operand', 'brk_rti'): + getattr(dst, name).update(pc for pc in getattr(cfg, name) if aliases(pc)) + for name in ('exclude_ranges', 'authority_data'): + for start, end in getattr(cfg, name): + clipped = span(start, end) + if clipped and clipped not in getattr(dst, name): + getattr(dst, name).append(clipped) + sites = {d['site_pc16'] for d in dst.indirect_dispatch} + for site in cfg.indirect_dispatch: + if aliases(site['site_pc16']) and site['site_pc16'] not in sites: + dst.indirect_dispatch.append(deepcopy(site)) + sites.add(site['site_pc16']) + for pc, value in cfg.force_variant_at.items(): + if pc >> 16 == bank and aliases(pc & 0xFFFF): + dst.force_variant_at.setdefault((target << 16) | (pc & 0xFFFF), value) + # Absolute declarations can live in any bank file. Expand their physical + # addresses, while retaining their owning file and explicit execution PCs. + for _bank, (_path, cfg) in result.items(): + cfg.force_lle = {b << 16 | (pc & 0xFFFF) for pc in cfg.force_lle + for b in (rom_bank_aliases(pc >> 16, pc & 0xFFFF) + or (pc >> 16,))} + for name, key_size in exit_keys.items(): + rows = [] + for bank, pc, *rest in getattr(cfg, name): + for b in rom_bank_aliases(bank, pc) or (bank,): + row = (b, pc, *rest) + if b != bank and row[:key_size] in explicit_exits[name]: + continue + if row not in rows: + rows.append(row) + setattr(cfg, name, rows) + regions = [] + for bank, start, end in cfg.data_regions: + for b in rom_bank_aliases(bank, max(start, 0x8000)) or (bank,): + lo = max(start, 0x8000 if b & 0x7F < 0x40 else 0) + if lo < end and (b, lo, end) not in regions: + regions.append((b, lo, end)) + cfg.data_regions = regions + # Preserve authored symbol ownership; synthetic banks follow originals. + order = list(originals) + sorted(set(result) - set(originals)) + return [(bank, *result[bank]) for bank in order] diff --git a/recompiler/v2/program_emit.py b/recompiler/v2/program_emit.py index 3ecca953..705afaf7 100644 --- a/recompiler/v2/program_emit.py +++ b/recompiler/v2/program_emit.py @@ -454,7 +454,7 @@ def discover_profile_roots(manifest_paths: Iterable[pathlib.Path], *, expected_rom=None, expected_module=None, legacy_rom=None, historical_paths=()) -> tuple[VariantKey, ...]: """Observe -> analyze -> validate. A profile never supplies decoding facts.""" - from .coverage_profile import load_profiles, promotion_reason, canonical_pc, pc + from .coverage_profile import load_profiles, promotion_reason, execution_pc as canonical_pc, pc # Historical builds retain their own capture identities and eligibility # checks. Only their analysis seeds and conservative exclusions survive; # costs/outcomes are never combined across different executable builds. diff --git a/runner/src/cpu_state.c b/runner/src/cpu_state.c index d87e4e7e..99b7afa5 100644 --- a/runner/src/cpu_state.c +++ b/runner/src/cpu_state.c @@ -1077,7 +1077,14 @@ int cpu_aot_rom_mapping_matches(uint32 pc24, uint32 offset, unsigned length) { static int dispatch_has_rom_mirror(uint32 pc24) { unsigned bank = pc24 >> 16; - if (!((bank < 0x40 || (bank >= 0x80 && bank < 0xC0)) && (pc24 & 0xFFFF) >= 0x8000)) return 0; + int low_pair = (bank < 0x40 || (bank >= 0x80 && bank < 0xC0)) && + (pc24 & 0xFFFF) >= 0x8000; + /* Full HiROM banks pair with full banks only. Sharing the upper ROM + * bytes with a low bank does not make its short calls/fetches equivalent. + * The generator emits separately analyzed low/full-bank entries. */ + int full_pair = g_snes && g_snes->cart && g_snes->cart->type == CART_HIROM && + ((bank >= 0x40 && bank < 0x7E) || (bank >= 0xC0 && bank < 0xFE)); + if (!low_pair && !full_pair) return 0; if (!g_snes || !g_snes->cart) return 1; /* isolated dispatch fixtures */ uint8 *a = cart_getRomPtr(g_snes->cart, bank, (uint16)pc24); uint8 *b = cart_getRomPtr(g_snes->cart, bank ^ 0x80, (uint16)pc24); diff --git a/tests/runtime_dispatch/known_lle_entry_test.c b/tests/runtime_dispatch/known_lle_entry_test.c index aedd6cb6..c5370205 100644 --- a/tests/runtime_dispatch/known_lle_entry_test.c +++ b/tests/runtime_dispatch/known_lle_entry_test.c @@ -315,6 +315,33 @@ int main(void) { cpu_dispatch_call_pc_pushed(&cpu, 0xa7808au, 0xa78000u, 2, &continuation); fails += check(g_aot_pb == 0xa7 && g_aot_hrv == 2 && cpu.PB == 0xa7, "pushed near call retains caller PB"); + /* Full HiROM pairs share the entire ROM bank. Low/full aliases only + * share bytes: a short jump below $8000 has different meaning. */ + const DispatchEntry hirom_entries[] = { + {0xC01000u, {fake_aot, NULL, NULL, NULL}, 0}, + {0xC09000u, {fake_aot, NULL, NULL, NULL}, 0}, + {0xFE9000u, {fake_aot, NULL, NULL, NULL}, 0}, + }; + int previous_cart_type = cart.type; + cart.type = CART_HIROM; + cpu_select_program(hirom_entries, 3, NULL, 0); + fails += check(cpu_dispatch_has_entry(&cpu, 0x401000u), + "full HiROM lower-half mirror finds compiled entry"); + fails += check(cpu_dispatch_has_entry(&cpu, 0x409000u), + "full HiROM upper-half mirror finds compiled entry"); + fails += check(!cpu_dispatch_has_entry(&cpu, 0x009000u) && + !cpu_dispatch_has_entry(&cpu, 0x809000u), + "low HiROM banks cannot reuse a full-bank compiled body"); + fails += check(!cpu_dispatch_has_entry(&cpu, 0x7E9000u), + "WRAM never aliases bank FE code"); + memset(&cpu, 0, sizeof cpu); + cpu.S = 0x1C0; + fails += check(cpu_dispatch_pc_from(&cpu, 0x401000u, 0x1C2, 0x409000u) + == RECOMP_RETURN_NORMAL && cpu.PB == 0x40 && cpu.A == 1, + "full HiROM mirror executes with requested architectural PB"); + --g_aot_calls; + cart.type = previous_cart_type; + cpu_select_program(NULL, 0, NULL, 0); const DispatchEntry alternate[] = {{0x008300u, {NULL, NULL, NULL, NULL}, 3}}; cpu_select_program(alternate, 1, NULL, 0); diff --git a/tests/test_coverage_feedback.py b/tests/test_coverage_feedback.py index fb53e43c..d4b90c5c 100644 --- a/tests/test_coverage_feedback.py +++ b/tests/test_coverage_feedback.py @@ -123,7 +123,7 @@ def test_aliases_require_mapper_and_rom_window(): assert canonical_pc(0x808100, "lorom") == 0x008100 assert canonical_pc(0x807100, "lorom") == 0x807100 assert canonical_pc(0x808100, "sa1") == 0x808100 - assert canonical_pc(0x808100, "hirom") == 0x808100 + assert canonical_pc(0x808100, "hirom") == 0xC08100 def test_legacy_identity_is_an_explicit_generation_input(tmp_path): diff --git a/tests/test_hirom_aliases.py b/tests/test_hirom_aliases.py new file mode 100644 index 00000000..e9a263db --- /dev/null +++ b/tests/test_hirom_aliases.py @@ -0,0 +1,175 @@ +"""ROM-free regressions for issue #193, including different bank-local flow.""" +import hashlib +import json +from pathlib import Path +import subprocess +import sys +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools')) +from snes65816 import (get_rom_mapping, set_rom_mapping, rom_bank_aliases, + rom_bank_mirror, rom_offset) +from v2_analyze import _load_cfgs, build_manifest_native +from v2.program_analysis import VariantKey +from v2.coverage_profile import canonical_pc, execution_pc +from disassembly_layout import materialize, export_layout +from ingest_disassembly_authority import ingest +from export_disassembly_authority import export + + +@pytest.fixture +def hirom(tmp_path): + previous = get_rom_mapping() + set_rom_mapping('hirom') + rom = bytearray([0xFF] * 0x10000) + rom[0xFFD5] = 0x31 + rom[0xFFDC:0xFFE0] = bytes.fromhex('cbed3412') + rom[0xFFFC:0xFFFE] = bytes.fromhex('0090') + rom[0x9000:0x9004] = bytes.fromhex('4c001060') # JMP $1000; RTS + rom[0x1000] = 0x60 + image = tmp_path / 'fixture.sfc' + image.write_bytes(rom) + cfg = tmp_path / 'cfg' + cfg.mkdir() + (cfg / 'bankc0.cfg').write_text('bank = C0\nfunc Jump 9000\nfunc Lower 1000\n') + yield rom, image, cfg + set_rom_mapping(previous) + + +def test_physical_aliases_are_symmetric_and_exclude_ram(hirom): + for bank in (0, 0x40, 0x80, 0xC0): + aliases = rom_bank_aliases(bank, 0x9000) + assert aliases[0] == bank + assert set(aliases) == {0, 0x40, 0x80, 0xC0} + assert {rom_offset(b, 0x9000) for b in aliases} == {0x9000} + assert set(rom_bank_aliases(0xC0, 0x1000)) == {0x40, 0xC0} + assert rom_bank_aliases(0, 0x1000) == () + assert rom_bank_aliases(0x7E, 0x9000) == () + assert set(rom_bank_aliases(0xFE, 0x9000)) == {0x3E, 0xBE, 0xFE} + assert rom_bank_mirror(0xC0) == 0x40 + assert rom_bank_mirror(0) == 0x80 # never substitute a full-bank body + + +def test_cfg_contracts_reach_aliases_without_low_half_leaks(hirom): + _, _, cfg = hirom + (cfg / 'bankc0.cfg').write_text('bank = C0\nfunc Upper 9000\n' + 'entry_mx_at 9000 0 1\nfunc Lower 1000\nforce_lle C09000\n' + 'authority_data 7000 9000\n') + parsed = {bank: value for bank, _, value in _load_cfgs(cfg)} + assert set(parsed) == {0, 0x40, 0x80, 0xC0} + assert [(e.start, e.entry_m, e.entry_x) for e in parsed[0].entries] == [(0x9000, 0, 1)] + assert {e.start for e in parsed[0x40].entries} == {0x1000, 0x9000} + assert parsed[0].authority_data == [(0x8000, 0x9000)] + assert parsed[0x40].authority_data == [(0x7000, 0x9000)] + assert parsed[0xC0].force_lle == {0x009000, 0x409000, 0x809000, 0xC09000} + (cfg / 'bank00.cfg').write_text('bank = 00\nfunc Explicit 9000 entry_mx:1,0\n') + parsed = {bank: value for bank, _, value in _load_cfgs(cfg)} + assert (parsed[0].entries[0].entry_m, parsed[0].entries[0].entry_x) == (1, 0) + + +def test_coverage_groups_bytes_without_promoting_another_execution_bank(hirom): + assert {canonical_pc(b << 16 | 0x9000, 'hirom') for b in (0, 0x40, 0x80, 0xC0)} == {0xC09000} + assert execution_pc(0x009000, 'hirom') == 0x009000 + assert execution_pc(0xC09000, 'hirom') == 0xC09000 + assert canonical_pc(0x001000, 'hirom') == 0x001000 + assert canonical_pc(0x7E9000, 'hirom') == 0x7E9000 + + +def test_explicit_alias_exit_contract_wins(hirom): + _, _, cfg = hirom + (cfg / 'bankc0.cfg').write_text('bank = C0\nfunc Jump 9000\nexit_mx_at C09000 0 0\n') + (cfg / 'bank00.cfg').write_text('bank = 00\nexit_mx_at 009000 1 1\n') + parsed = _load_cfgs(cfg) + exits = {(b, pc): (m, x) for _, _, c in parsed for b, pc, m, x in c.exit_mx_at} + assert exits[0, 0x9000] == (1, 1) + assert exits[0xC0, 0x9000] == (0, 0) + + +def test_source_export_reads_lower_hirom_bank(hirom, tmp_path): + _, image, _ = hirom + source = tmp_path / 'source' + source.mkdir() + asm = source / 'bankc0.asm' + asm.write_text('|C01000| RTS\n|C09000| JMP $1000\n|C09003| RTS\n') + args = SimpleNamespace(rom=image, assembled_rom=image, source=source, + format='usdasm', symbols=None, source_commit='synthetic', assembler=asm, + classifier=asm, out=tmp_path / 'export.json') + # This adapter test supplies already-classified synthetic source rows. + classifier = SimpleNamespace(strip_label=lambda s: s.strip(), + strip_comment=lambda s: s, statement_kind=lambda *_: 'code') + export(args, classifier) + rows = json.loads(args.out.read_text())['instructions'] + assert {int(r['pc24'], 0): r['bytes'] for r in rows} == { + 0xC01000: '60', 0xC09000: '4c0010', 0xC09003: '60'} + + +def test_layout_and_ingestion_use_physical_hirom_spans(hirom, tmp_path): + rom, image, cfg = hirom + authority = {'schema': 'snesrecomp disassembly authority v1', + 'rom_sha256': hashlib.sha256(rom).hexdigest(), + 'instructions': [{'pc24': 0x009000, 'bytes': '4c0010'}, + {'pc24': 0xC09000, 'bytes': '4c0010'}, + {'pc24': 0xC01000, 'bytes': '60'}]} + assert materialize(rom, export_layout(authority))['instructions'] == authority['instructions'] + source = tmp_path / 'authority.json' + source.write_text(json.dumps(authority)) + result = ingest(image, source, cfg, tmp_path / 'overlay') + assert result['instructions'] == 2 + assert not (tmp_path / 'overlay/bank00.cfg').exists() + assert 'authority_insn 9000 4c0010' in (tmp_path / 'overlay/bankc0.cfg').read_text() + authority['instructions'].append({'pc24': 0x809001, 'bytes': '0010'}) + source.write_text(json.dumps(authority)) + with pytest.raises(ValueError, match='overlapping instruction'): + ingest(image, source, cfg, tmp_path / 'conflict') + authority['instructions'].pop() + authority['data_regions'] = [{'start_pc24': 0x409000, 'end_pc24': 0x409003}] + source.write_text(json.dumps(authority)) + with pytest.raises(ValueError, match='data region overlaps'): + ingest(image, source, cfg, tmp_path / 'data-conflict') + authority.pop('data_regions') + authority['entries'] = [{'pc24': 0x009000, 'm': 1, 'x': 1}, + {'pc24': 0xC09000, 'm': 0, 'x': 0}] + source.write_text(json.dumps(authority)) + ingest(image, source, cfg, tmp_path / 'contexts') + parsed = {bank: c for bank, _, c in _load_cfgs(tmp_path / 'contexts')} + for bank, modes in ((0, (1, 1)), (0xC0, (0, 0))): + entry = next(e for e in parsed[bank].entries if e.start == 0x9000) + assert (entry.entry_m, entry.entry_x) == modes + + +def test_native_analyzes_short_jump_in_its_live_bank(hirom): + _, image, cfg = hirom + manifest, _, _, _ = build_manifest_native(rom_path=image, cfg_dir=cfg, + additional_roots=(VariantKey(0xC09000, 1, 1),), max_insns=64) + low = manifest.nodes[VariantKey(0x009000, 1, 1)] + full = manifest.nodes[VariantKey(0xC09000, 1, 1)] + assert low.disposition.value == 'aot_eligible' + assert {(e.target.pc24, e.resolution.value) for e in low.demands} == {(0x001000, 'lle_exact')} + assert {(e.target.pc24, e.resolution.value) for e in full.demands} == {(0xC01000, 'aot_exact')} + assert full.disposition.value == 'aot_eligible' + (cfg / 'bankc0.cfg').write_text('bank = C0\nfunc Jump 9000\nforce_lle C09000\n') + manifest, _, _, _ = build_manifest_native(rom_path=image, cfg_dir=cfg, max_insns=64) + assert VariantKey(0x009000, 1, 1) not in manifest.nodes # forced roots stay in LLE + + +def test_emitter_and_native_agree_on_hirom_alias_contracts(hirom, tmp_path): + rom, image, cfg = hirom + rom[0x9000:0x9002] = bytes.fromhex('ea60') + image.write_bytes(rom) + (cfg / 'bank00.cfg').write_text('bank = 00\nauto_vectors\n') + (cfg / 'bankc0.cfg').write_text('bank = C0\nfunc Return 9000\n') + output = tmp_path / 'generated' + result = subprocess.run([sys.executable, str(ROOT / 'tools/v2_emit.py'), + '--rom', str(image), '--cfg-dir', str(cfg), '--out-dir', str(output), + '--no-host-root-scan'], text=True, capture_output=True) + assert result.returncode == 0, result.stdout + result.stderr + manifest = json.loads((output / 'program_manifest.json').read_text()) + assert manifest['nodes']['009000:M1X1']['disposition'] == 'aot_eligible' + (cfg / 'bankc0.cfg').write_text('bank = C0\nfunc Return 9000\nforce_lle C09000\n') + result = subprocess.run(result.args, text=True, capture_output=True) + assert result.returncode == 0, result.stdout + result.stderr + manifest = json.loads((output / 'program_manifest.json').read_text()) + assert manifest['nodes'] == {} # reset alias is explicitly interpreter-only diff --git a/tools/disassembly_layout.py b/tools/disassembly_layout.py index 6ea3b626..16cd4f3f 100644 --- a/tools/disassembly_layout.py +++ b/tools/disassembly_layout.py @@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parents[1] sys.path[:0] = [str(ROOT / "recompiler"), str(ROOT / "tools")] -from snes65816 import detect_rom_mapping, load_rom, lorom_offset, set_rom_mapping +from snes65816 import detect_rom_mapping, load_rom, rom_offset, set_rom_mapping, is_rom_address from ingest_disassembly_authority import ingest @@ -50,21 +50,19 @@ def materialize(rom, layout): raise ValueError("disassembly layout ROM identity mismatch") if type(layout.get("probe_entry_modes")) is not bool: raise ValueError("disassembly layout requires boolean probe_entry_modes") - # Current source adapters were qualified for LoROM only. Do not infer - # authority aliases for other cartridge mappings. - if detect_rom_mapping(rom) != "lorom": - raise ValueError("disassembly layout requires a LoROM image") - set_rom_mapping("lorom") + mapping = detect_rom_mapping(rom) + if mapping not in ("lorom", "hirom"): + raise ValueError("disassembly layout requires a standard LoROM or HiROM image") + set_rom_mapping(mapping) instructions = [] for address, length in layout["instructions"]: address = int(address, 0) if isinstance(address, str) else address if (type(address) is not int or not 0 <= address <= 0xFFFFFF or type(length) is not int or not 1 <= length <= 4 or (address & 0xFFFF) + length > 0x10000 - or address >> 16 in (0x7E, 0x7F) - or address & 0xFFFF < 0x8000): + or not is_rom_address(address >> 16, address & 0xFFFF)): raise ValueError("invalid disassembly layout instruction span") - offset = lorom_offset(address >> 16, address & 0xFFFF) + offset = rom_offset(address >> 16, address & 0xFFFF) if offset < 0 or offset + length > len(rom): raise ValueError("disassembly layout instruction is outside the ROM") instructions.append({"pc24": address, diff --git a/tools/export_disassembly_authority.py b/tools/export_disassembly_authority.py index a5a91e60..a0912e55 100644 --- a/tools/export_disassembly_authority.py +++ b/tools/export_disassembly_authority.py @@ -1,4 +1,4 @@ -"""Export byte-verified LoROM instruction authority from matching disassembly. +"""Export byte-verified standard LoROM/HiROM authority from matching disassembly. Reassemble first with the pinned assembler and locally owned ROM assets. The complete assembled image must match --rom. --classifier points to SMW's @@ -40,7 +40,7 @@ def assemble_rows(args, d): kind[pc]=d.statement_kind(d.strip_comment(lines[fid][n-1]),'65816',{}, {}) result={} for pc,(fid,n) in rows.items(): - if pc&0xffff<0x8000 or pc==0xffffff:continue + if not snes65816.is_rom_address(pc >> 16, pc & 0xffff) or pc==0xffffff:continue text=d.strip_label(d.strip_comment(lines[fid][n-1])) if fid in lines and 1<=n<=len(lines[fid]) else '' repeat = bool(d.REP_PREFIX_RE.match(text) or re.match(r'^(?:DEX|DEY|INX|INY|NOP|ASL|LSR|ROL|ROR)\s*#',text,re.I)) result[pc] = dict(kind=kind.get(pc,'unknown') if arch.get(pc)=='65816' else 'data',source=f'{paths[fid].name}:{n}',text=text,repeat=repeat) @@ -58,17 +58,19 @@ def annotated_rows(args, d): def export(args, d): rom=args.rom.read_bytes() - if snes65816.detect_rom_mapping(rom) != 'lorom': - raise ValueError('these source adapters require a LoROM image') - snes65816.set_rom_mapping('lorom') + mapping = snes65816.detect_rom_mapping(rom) + if mapping not in ('lorom', 'hirom'): + raise ValueError('these source adapters require standard LoROM or HiROM') + snes65816.set_rom_mapping(mapping) assembled=args.assembled_rom.read_bytes() if len(assembled)==len(rom)+512:assembled=assembled[512:] if assembled!=rom:raise ValueError('assembled reference does not reproduce the complete ROM') rows=annotated_rows(args,d) if args.format=='usdasm' else assemble_rows(args,d) pcs=sorted(rows);instructions=[];data=[];unknown=[] for i,pc in enumerate(pcs): + if not snes65816.is_rom_address(pc >> 16, pc & 0xffff):continue row=rows[pc];end=pcs[i+1] if i+1>16==pc>>16 else ((pc>>16)+1)<<16 - off=snes65816.lorom_offset(pc>>16,pc&0xffff) + off=snes65816.rom_offset(pc>>16,pc&0xffff) if off>=len(rom):continue if row['kind']=='code': # Instruction length comes from assembler/source-address boundaries, diff --git a/tools/ingest_disassembly_authority.py b/tools/ingest_disassembly_authority.py index c134352b..0fb1142c 100644 --- a/tools/ingest_disassembly_authority.py +++ b/tools/ingest_disassembly_authority.py @@ -17,7 +17,7 @@ import sys sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "recompiler")) -from snes65816 import lorom_offset, detect_rom_mapping, set_rom_mapping, rom_bank_mirror +from snes65816 import rom_offset, detect_rom_mapping, set_rom_mapping, rom_bank_aliases, is_rom_address from v2.coverage_profile import pc from v2.cfg_loader import load_bank_cfg @@ -29,7 +29,8 @@ def ingest(rom_path, authority_path, cfg_dir, out_dir): raise ValueError("unsupported disassembly authority schema") if authority.get("rom_sha256") != hashlib.sha256(rom).hexdigest(): raise ValueError("disassembly ROM identity mismatch") - set_rom_mapping(detect_rom_mapping(rom)) + mapping = detect_rom_mapping(rom) + set_rom_mapping(mapping) cfg_dir, out_dir = Path(cfg_dir).resolve(), Path(out_dir).resolve() if out_dir == cfg_dir or cfg_dir in out_dir.parents or out_dir.exists(): raise ValueError("output must be a new directory outside the source cfg directory") @@ -40,53 +41,68 @@ def ingest(rom_path, authority_path, cfg_dir, out_dir): def owner_bank(address): bank = address >> 16 - mirror = rom_bank_mirror(bank) # Keep evidence beside the existing execution contracts. Creating an # otherwise empty mirror cfg would shadow its HLE hooks/boundaries. - if bank not in known and (address & 0xFFFF) >= 0x8000 and mirror in known: - return mirror + if bank not in known: + for alias in rom_bank_aliases(bank, address & 0xFFFF): + if alias in known: + return alias return bank - lines, occupied, starts = {}, {}, set() + lines, occupied, starts = {}, {}, {} def add(address, line): lines.setdefault(owner_bank(address), []).append(line) for insn in authority.get("instructions", []): address, raw = pc(insn["pc24"]), bytes.fromhex(insn["bytes"]) if not 1 <= len(raw) <= 4 or (address & 0xFFFF) + len(raw) > 0x10000: raise ValueError(f"invalid instruction span at {address:06X}") - offset = lorom_offset(address >> 16, address & 0xFFFF) + offset = rom_offset(address >> 16, address & 0xFFFF) if rom[offset:offset + len(raw)] != raw: raise ValueError(f"disassembly bytes differ at {address:06X}") + if offset in starts and starts[offset] == raw: + continue # identical physical evidence, including another CPU alias for n in range(len(raw)): - if address + n in occupied: - raise ValueError(f"overlapping instruction spans at {address+n:06X}") - occupied[address+n] = address - starts.add(address) + if offset + n in occupied: + raise ValueError(f"overlapping instruction spans at {address+n:06X} (ROM {offset+n:06X})") + occupied[offset+n] = offset + starts[offset] = raw add(address, f"authority_insn {address & 0xFFFF:04X} {raw.hex()}") variants = set() for entry in authority.get("entries", []): address, m, x = pc(entry["pc24"]), entry["m"], entry["x"] - if address not in starts or m not in (0, 1) or x not in (0, 1): + if rom_offset(address >> 16, address & 0xFFFF) not in starts or m not in (0, 1) or x not in (0, 1): raise ValueError("entry requires an authoritative instruction and exact M/X") if address in variants: raise ValueError("multiple entry modes require separate authority inputs") variants.add(address) - if address & 0xFFFF not in known.get(owner_bank(address), set()): - add(address, f"func authority_{address:06X} {address & 0xFFFF:04X} entry_mx:{m},{x}") - add(address, f"entry_mx_at {address & 0xFFFF:04X} {m} {x}") + # HiROM entry widths describe an execution context, even when its + # instruction bytes are shared. Preserve a local override instead of + # overwriting another alias's mode through the physical evidence owner. + entry_bank = address >> 16 if mapping == "hirom" else owner_bank(address) + entries = lines.setdefault(entry_bank, []) + if address & 0xFFFF not in known.get(entry_bank, set()): + entries.append(f"func authority_{address:06X} {address & 0xFFFF:04X} entry_mx:{m},{x}") + entries.append(f"entry_mx_at {address & 0xFFFF:04X} {m} {x}") occupied_addresses = sorted(occupied) for region in authority.get("data_regions", []): start, end = pc(region["start_pc24"]), pc(region["end_pc24"]) - index = bisect_left(occupied_addresses, start) - overlaps = index < len(occupied_addresses) and occupied_addresses[index] < end - if end <= start or start >> 16 != (end - 1) >> 16 or overlaps: + if (end <= start or start >> 16 != (end - 1) >> 16 + or not is_rom_address(start >> 16, start & 0xFFFF)): + raise ValueError("data region overlaps code or crosses a bank") + offset = rom_offset(start >> 16, start & 0xFFFF) + limit = offset + end - start + index = bisect_left(occupied_addresses, offset) + overlaps = index < len(occupied_addresses) and occupied_addresses[index] < limit + if limit > len(rom) or overlaps: raise ValueError("data region overlaps code or crosses a bank") add(start, f"data_region {owner_bank(start):02X} {start & 0xFFFF:04X} {end-start+(start & 0xFFFF):04X}") add(start, f"authority_data {start & 0xFFFF:04X} {end-start+(start & 0xFFFF):04X}") for dispatch in authority.get("dispatches", []): site, kind = pc(dispatch["site_pc24"]), dispatch["kind"] targets = sorted({pc(v) for v in dispatch["targets"]}) - if site not in starts or kind not in ("ptrtail", "ptrcall") or not targets or any(t not in starts for t in targets): + if (rom_offset(site >> 16, site & 0xFFFF) not in starts + or kind not in ("ptrtail", "ptrcall") or not targets + or any(rom_offset(t >> 16, t & 0xFFFF) not in starts for t in targets)): raise ValueError("dispatch requires authoritative site and target instructions") add(site, f"indirect_dispatch {site & 0xFFFF:04X} {len(targets)} {kind} targets:" + ",".join(f"{t:06X}" for t in targets)) # Validation is complete before making the reviewable overlay. diff --git a/tools/tier2_ingest.py b/tools/tier2_ingest.py index 24d8548a..812f625f 100644 --- a/tools/tier2_ingest.py +++ b/tools/tier2_ingest.py @@ -9,7 +9,7 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "recompiler")) from v2.ram_coverage import describe_snapshot -from v2.coverage_profile import load_profiles, promotion_reason, canonical_pc, pc +from v2.coverage_profile import load_profiles, promotion_reason, canonical_pc, execution_pc, pc def audit(profile, declared=(), program=None): @@ -17,7 +17,8 @@ def audit(profile, declared=(), program=None): rows = [] for observed in profile.discoveries: row = dict(observed) - target = canonical_pc(row["target_pc24"], profile.identity.get("mapper")) + target = execution_pc(row["target_pc24"], profile.identity.get("mapper")) + row["rom_pc24"] = canonical_pc(row["target_pc24"], profile.identity.get("mapper")) key = f"{target:06X}:{row.get('entry_mx', 'unknown')}" node = nodes.get(key) or nodes.get( f"{pc(row['target_pc24']):06X}:{row.get('entry_mx', 'unknown')}") diff --git a/tools/v2_analyze.py b/tools/v2_analyze.py index 10268d75..cd1152d9 100644 --- a/tools/v2_analyze.py +++ b/tools/v2_analyze.py @@ -184,6 +184,8 @@ def _load_cfgs(cfg_dir: pathlib.Path): load_bank_cfg(str(path)))) if not parsed: raise ValueError(f"no bank*.cfg under {cfg_dir}") + from v2.hirom_cfg import expand_hirom_cfgs + parsed = expand_hirom_cfgs(parsed) from v2.decoder import set_instruction_authority set_instruction_authority(parsed) return parsed From dfeafcc04ecdb22c48390c3efbd0e5ef1662c699 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 08:28:59 -0700 Subject: [PATCH 2/4] Keep inherited HiROM near dispatches in their execution bank --- docs/HIROM_ALIASES.md | 13 +++++++++-- recompiler-rs/src/decoder.rs | 33 +++++++++++++++++++++++++++ recompiler/v2/decoder.py | 13 ++++++++--- tests/test_hirom_aliases.py | 44 ++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/docs/HIROM_ALIASES.md b/docs/HIROM_ALIASES.md index 823d9d98..3b074217 100644 --- a/docs/HIROM_ALIASES.md +++ b/docs/HIROM_ALIASES.md @@ -19,6 +19,13 @@ The runtime may reuse an XOR-80 body within a low-bank pair or a full-bank pair only after verifying the live cartridge ROM pointers. It never uses that check to substitute a full-bank body for a low-bank body. +Inherited explicit indirect-dispatch targets follow the opcode's target width: +near JMP/JSR targets resolve in the executing bank, even when the cfg spells +the address in an authored physical alias. Long transfers retain their absolute +target banks. Null dispatch slots remain null. This distinction is enforced in +both decoders before native analysis or emission, so an alias cannot call a +full-bank body in place of its separately analyzed low-bank continuation. + Coverage reports include `rom_pc24` for physical grouping while retaining the raw target, execution variant, outcomes and qualification provenance. A successful C0 run cannot qualify bank 00. Coverage remains an analysis @@ -34,5 +41,7 @@ boundaries need separate authority inputs, not a silently selected layout. These changes do not generalize aliasing for SA-1, S-DD1 or other banked mapping. Synthetic tests cover mapper boundaries, cfg precedence, short-jump -semantics, native/emitter agreement, source export and authority overlays; -the reported Secret of Mana/SD3 scene still needs its author's title replay. +semantics, inherited near/long dispatches, native/emitter agreement, source +export and authority overlays. The reporter supplied Secret of Mana replay +evidence for the earlier head. Replay and uncapped host timings on the updated +head remain title-level qualification; this does not claim to fix SD3 issue #80. diff --git a/recompiler-rs/src/decoder.rs b/recompiler-rs/src/decoder.rs index a1ce15f9..4c59fb34 100644 --- a/recompiler-rs/src/decoder.rs +++ b/recompiler-rs/src/decoder.rs @@ -698,6 +698,10 @@ fn resolve_indirect_dispatch_targets( // Preserve an explicit null slot instead of turning it // into bank:$0000; every downstream consumer skips zero. Some(0) + } else if mapping == RomMapping::HiRom && !is_long_dispatch(insn, bases) { + // A copied physical contract may name its authored bank; + // a near transfer still executes in this bank's PB. + Some((bank << 16) | (target & 0xFFFF)) } else if target > 0xFFFF { Some(target) } else { @@ -4528,6 +4532,35 @@ mod tests { ); } + #[test] + fn hirom_explicit_dispatch_uses_near_pb_and_preserves_long_banks() { + for bank in [0, 0x40, 0x80, 0xc0] { + for opcode in [0x6c, 0x7c, 0xfc, 0xdc] { + let rom = rom_at_8000(&[opcode, 0x10, 0x80]); + let insn = decode_insn(&rom, 0, 0x8000, bank, 1, 1).unwrap(); + let expected = if opcode == 0xdc { + vec![0, 0xc09010, 0xc19020] + } else { + vec![0, (bank << 16) | 0x9010, (bank << 16) | 0x9020] + }; + assert_eq!( + resolve_indirect_dispatch_targets( + &rom, + RomMapping::HiRom, + bank, + &insn, + 3, + &[], + &[0, 0xc09010, 0xc19020], + &[], + ), + Some(expected), + "bank={bank:02x}, opcode={opcode:02x}" + ); + } + } + } + #[test] fn computed_pei_rts_is_not_a_callable_exit_mode() { let rom = rom_at_8000(&[ diff --git a/recompiler/v2/decoder.py b/recompiler/v2/decoder.py index 8508642a..15fbaddc 100644 --- a/recompiler/v2/decoder.py +++ b/recompiler/v2/decoder.py @@ -42,6 +42,7 @@ from snes65816 import ( # noqa: E402 decode_insn, lorom_offset, rom_bank_mirror, is_materialized_rom_address, Insn, + get_rom_mapping, ABS, INDIR, INDIR_X, LONG, IMM, ) @@ -714,9 +715,15 @@ def _resolve_indirect_dispatch_targets(rom: bytes, bank: int, insn, return None # Zero is a real null dispatch slot, not bank:$0000. Preserve it # so every downstream consumer can skip the slot consistently. - entries.append( - 0 if tv == 0 else ( - tv if tv > 0xFFFF else ((bank << 16) | (tv & 0xFFFF)))) + if tv == 0: + entries.append(0) + elif get_rom_mapping() == 'hirom' and _dispatch_kind(insn, bases) == 'short': + # An inherited contract may spell the physical target in its + # authored bank. Near transfers keep the executing PB, which + # can give the same bytes different successors (ROM vs RAM). + entries.append((bank << 16) | (tv & 0xFFFF)) + else: + entries.append(tv if tv > 0xFFFF else ((bank << 16) | tv)) if len(entries) != count: return None return entries diff --git a/tests/test_hirom_aliases.py b/tests/test_hirom_aliases.py index e9a263db..5c10f849 100644 --- a/tests/test_hirom_aliases.py +++ b/tests/test_hirom_aliases.py @@ -18,6 +18,8 @@ from disassembly_layout import materialize, export_layout from ingest_disassembly_authority import ingest from export_disassembly_authority import export +from snes65816 import decode_insn +from v2.decoder import _resolve_indirect_dispatch_targets @pytest.fixture @@ -173,3 +175,45 @@ def test_emitter_and_native_agree_on_hirom_alias_contracts(hirom, tmp_path): assert result.returncode == 0, result.stdout + result.stderr manifest = json.loads((output / 'program_manifest.json').read_text()) assert manifest['nodes'] == {} # reset alias is explicitly interpreter-only + + +@pytest.mark.parametrize('opcode,mode,long_target', [ + (0x7C, 'ptrtail', False), # JMP (abs,X) + (0x6C, 'ptrtail', False), # JMP (abs) + (0xFC, 'ptrcall', False), # JSR (abs,X) + (0xDC, 'ptrtail', True), # JML [abs]: target bank is part of the pointer +]) +def test_inherited_dispatch_keeps_near_bank_and_absolute_long_target( + hirom, tmp_path, opcode, mode, long_target): + rom, image, cfg = hirom + rom[0x9000:0x9004] = bytes([opcode, 0x20, 0x90, 0x60]) + rom[0x9010:0x9013] = bytes.fromhex('4c0010') # RAM in bank 00, ROM in C0 + image.write_bytes(rom) + (cfg / 'bankc0.cfg').write_text( + 'bank = C0\nfunc Dispatch 9000\nfunc Target 9010\nfunc Lower 1000\n' + f'indirect_dispatch 9000 2 {mode} targets:000000,C09010\n') + for bank, _, row in _load_cfgs(cfg): + insn = decode_insn(rom, 0x9000, 0x9000, bank, 1, 1) + expected = 0xC09010 if long_target else (bank << 16) | 0x9010 + assert _resolve_indirect_dispatch_targets( + rom, bank, insn, row.indirect_dispatch[0]) == [0, expected] + roots = tuple(VariantKey(b << 16 | 0x9000, 1, 1) for b in (0, 0x40, 0x80, 0xC0)) + manifest, _, _, _ = build_manifest_native( + rom_path=image, cfg_dir=cfg, additional_roots=roots, max_insns=64) + for bank in (0, 0x40, 0x80, 0xC0): + node = manifest.nodes[VariantKey(bank << 16 | 0x9000, 1, 1)] + expected = 0xC09010 if long_target else bank << 16 | 0x9010 + assert {edge.target.pc24 for edge in node.demands} == {expected} + if not long_target: + low = manifest.nodes[VariantKey(0x009010, 1, 1)] + full = manifest.nodes[VariantKey(0xC09010, 1, 1)] + assert {(edge.target.pc24, edge.resolution.value) for edge in low.demands} == { + (0x001000, 'lle_exact')} + assert {edge.target.pc24 for edge in full.demands} == {0xC01000} + output = tmp_path / 'dispatch-output' + result = subprocess.run([sys.executable, str(ROOT / 'tools/v2_emit.py'), + '--rom', str(image), '--cfg-dir', str(cfg), '--out-dir', str(output), + '--no-host-root-scan'], text=True, capture_output=True) + assert result.returncode == 0, result.stdout + result.stderr + source = (output / 'bank00_v2.c').read_text() + assert ('_r = Target_M1X1(cpu)' in source) == long_target From 4921a6272d435564a3329bc9888bc95f66e1029c Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Sat, 24 Oct 2026 08:32:41 -0700 Subject: [PATCH 3/4] Preserve low-bank dispatch cost when adding HiROM full-bank mirrors --- runner/src/cpu_state.c | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/runner/src/cpu_state.c b/runner/src/cpu_state.c index 99b7afa5..7ca17cc2 100644 --- a/runner/src/cpu_state.c +++ b/runner/src/cpu_state.c @@ -1075,16 +1075,30 @@ int cpu_aot_rom_mapping_matches(uint32 pc24, uint32 offset, unsigned length) { return 1; } -static int dispatch_has_rom_mirror(uint32 pc24) { +/* Keep full-bank fallback checks out of the established low-bank fast path. + * This helper runs only after the old bank/address predicate rejects a PC. + * Prevent compilers from merging its cart tests into that common path. */ +static NOINLINE int dispatch_has_full_hirom_mirror(uint32 pc24) +#if defined(__GNUC__) || defined(__clang__) + __attribute__((noinline)) +#endif + ; +static int dispatch_has_full_hirom_mirror(uint32 pc24) { unsigned bank = pc24 >> 16; - int low_pair = (bank < 0x40 || (bank >= 0x80 && bank < 0xC0)) && - (pc24 & 0xFFFF) >= 0x8000; /* Full HiROM banks pair with full banks only. Sharing the upper ROM * bytes with a low bank does not make its short calls/fetches equivalent. * The generator emits separately analyzed low/full-bank entries. */ - int full_pair = g_snes && g_snes->cart && g_snes->cart->type == CART_HIROM && - ((bank >= 0x40 && bank < 0x7E) || (bank >= 0xC0 && bank < 0xFE)); - if (!low_pair && !full_pair) return 0; + if (!g_snes || !g_snes->cart || g_snes->cart->type != CART_HIROM || + !((bank >= 0x40 && bank < 0x7E) || (bank >= 0xC0 && bank < 0xFE))) return 0; + uint8 *a = cart_getRomPtr(g_snes->cart, bank, (uint16)pc24); + uint8 *b = cart_getRomPtr(g_snes->cart, bank ^ 0x80, (uint16)pc24); + return a && a == b; +} + +static int dispatch_has_rom_mirror(uint32 pc24) { + unsigned bank = pc24 >> 16; + if (!((bank < 0x40 || (bank >= 0x80 && bank < 0xC0)) && (pc24 & 0xFFFF) >= 0x8000)) + return dispatch_has_full_hirom_mirror(pc24); if (!g_snes || !g_snes->cart) return 1; /* isolated dispatch fixtures */ uint8 *a = cart_getRomPtr(g_snes->cart, bank, (uint16)pc24); uint8 *b = cart_getRomPtr(g_snes->cart, bank ^ 0x80, (uint16)pc24); From 8a409ac482c8d28ccedb29209bbed6d0bc8e41a1 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 26 Oct 2026 15:51:12 -0700 Subject: [PATCH 4/4] Record corrected HiROM replay and runtime qualification --- docs/HIROM_ALIASES.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/HIROM_ALIASES.md b/docs/HIROM_ALIASES.md index 3b074217..d61da532 100644 --- a/docs/HIROM_ALIASES.md +++ b/docs/HIROM_ALIASES.md @@ -42,6 +42,10 @@ boundaries need separate authority inputs, not a silently selected layout. These changes do not generalize aliasing for SA-1, S-DD1 or other banked mapping. Synthetic tests cover mapper boundaries, cfg precedence, short-jump semantics, inherited near/long dispatches, native/emitter agreement, source -export and authority overlays. The reporter supplied Secret of Mana replay -evidence for the earlier head. Replay and uncapped host timings on the updated -head remain title-level qualification; this does not claim to fix SD3 issue #80. +export and authority overlays. The reporter qualified corrected head `0dae9c8` +with Secret of Mana boot, field/flight, cutscene and shop replays: state, video +and master-clock checkpoints matched the hand-alias baseline in compiled and +interpreter-only runs. Matched warmed Release timings were within noise, with +no reported startup or working-set increase. See the +[replay and runtime report](https://github.com/RetroPortingToolKit/snesrecomp/pull/199#issuecomment-6400790503). +This evidence is title-specific and does not claim to fix SD3 issue #80.