use crate::c_parser::SymbolTable;
use crate::script_file::COMMON_SCRIPT_THRESHOLD;
use byteorder::{LittleEndian, ReadBytesExt};
use regex::Regex;
use std::io::{self, Cursor, Read, Seek, SeekFrom};
use std::path::Path;
use std::sync::LazyLock;
const HGSS_TABLE_POINTER_OFFSET: u64 = 0x40164;
const HGSS_TABLE_ENTRY_COUNT: usize = 30;
const HGSS_MEMORY_BASE: u32 = 0x0200_0000;
#[cfg(test)]
const PLATINUM_TABLE_ENTRY_COUNT: usize = 30;
static RE_HGSS_ENTRY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\{\s*([A-Za-z0-9_]+)\s*,\s*([A-Za-z0-9_]+)\s*,\s*([A-Za-z0-9_]+)\s*\}").unwrap()
});
static RE_PLATINUM_TABLE_ENTRY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\bEntry\s*\(\s*([A-Za-z0-9_]+)\s*,\s*([A-Za-z0-9_]+)\s*,\s*([A-Za-z0-9_]+)\s*\)")
.unwrap()
});
fn parse_hgss_narc_member_id(s: &str) -> Option<i64> {
let digits = s
.strip_prefix("NARC_scr_seq_scr_seq_")
.or_else(|| s.strip_prefix("NARC_msg_msg_"))?
.strip_suffix("_bin")?;
if !digits.chars().all(|c| c.is_ascii_digit()) {
return None;
}
digits.parse::<i64>().ok()
}
fn resolve_value(s: &str, symbols: &SymbolTable) -> Option<i64> {
if let Ok(v) = s.parse::<i64>() {
return Some(v);
}
if s.starts_with("0x") || s.starts_with("0X") {
if let Ok(v) = i64::from_str_radix(&s[2..], 16) {
return Some(v);
}
}
symbols
.resolve_constant(s)
.or_else(|| parse_hgss_narc_member_id(s))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GlobalScriptEntry {
pub min_script_id: u16,
pub script_file_id: u16,
pub text_archive_id: u16,
}
impl GlobalScriptEntry {
pub const fn new(min_script_id: u16, script_file_id: u16, text_archive_id: u16) -> Self {
Self {
min_script_id,
script_file_id,
text_archive_id,
}
}
pub fn read_from<R: Read>(reader: &mut R) -> io::Result<Self> {
let min_script_id = reader.read_u16::<LittleEndian>()?;
let script_file_id = reader.read_u16::<LittleEndian>()?;
let text_archive_id = reader.read_u16::<LittleEndian>()?;
Ok(Self {
min_script_id,
script_file_id,
text_archive_id,
})
}
}
#[derive(Debug, Clone, Default)]
pub struct GlobalScriptTable {
entries: Vec<GlobalScriptEntry>,
}
impl GlobalScriptTable {
pub fn new() -> Self {
Self::default()
}
pub fn from_entries(mut entries: Vec<GlobalScriptEntry>) -> Self {
entries.sort_by(|a, b| b.min_script_id.cmp(&a.min_script_id));
Self { entries }
}
pub fn from_hgss_binary<R: Read + Seek>(reader: &mut R) -> io::Result<Self> {
reader.seek(SeekFrom::Start(HGSS_TABLE_POINTER_OFFSET))?;
let table_addr = reader.read_u32::<LittleEndian>()?;
let table_offset = table_addr.saturating_sub(HGSS_MEMORY_BASE) as u64;
reader.seek(SeekFrom::Start(table_offset))?;
let mut entries = Vec::with_capacity(HGSS_TABLE_ENTRY_COUNT);
for _ in 0..HGSS_TABLE_ENTRY_COUNT {
entries.push(GlobalScriptEntry::read_from(reader)?);
}
Ok(Self::from_entries(entries))
}
pub fn from_hgss_binary_file(path: impl AsRef<Path>) -> io::Result<Self> {
let data = std::fs::read(path)?;
let mut cursor = Cursor::new(data);
Self::from_hgss_binary(&mut cursor)
}
pub fn from_hgss_decomp(content: &str, symbols: &SymbolTable) -> Option<Self> {
let start = content.find("sScriptBankMapping")?;
let block_start = content[start..].find('{')?;
let block = &content[start + block_start..];
let mut entries = Vec::new();
for caps in RE_HGSS_ENTRY.captures_iter(block) {
let script_id_sym = caps.get(1)?.as_str();
let script_file_sym = caps.get(2)?.as_str();
let text_archive_sym = caps.get(3)?.as_str();
let min_script_id = resolve_value(script_id_sym, symbols)? as u16;
let script_file_id = resolve_value(script_file_sym, symbols)? as u16;
let text_archive_id = resolve_value(text_archive_sym, symbols)? as u16;
entries.push(GlobalScriptEntry::new(
min_script_id,
script_file_id,
text_archive_id,
));
}
if entries.is_empty() {
return None;
}
Some(Self::from_entries(entries))
}
pub fn from_hgss_decomp_file(
path: impl AsRef<Path>,
symbols: &SymbolTable,
) -> io::Result<Self> {
let content = std::fs::read_to_string(path)?;
Self::from_hgss_decomp(&content, symbols).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"Failed to parse sScriptBankMapping",
)
})
}
pub fn from_platinum_decomp(content: &str, symbols: &SymbolTable) -> Option<Self> {
let start = content.find("SCRIPT_RANGE_TABLE")?;
let block_start = content[start..].find('(')?;
let block = &content[start + block_start..];
let mut entries = Vec::new();
for caps in RE_PLATINUM_TABLE_ENTRY.captures_iter(block) {
let script_id_sym = caps.get(1)?.as_str();
let script_file_sym = caps.get(2)?.as_str();
let text_archive_sym = caps.get(3)?.as_str();
let min_script_id = resolve_value(script_id_sym, symbols)? as u16;
let script_file_id = resolve_value(script_file_sym, symbols)? as u16;
let text_archive_id = resolve_value(text_archive_sym, symbols)? as u16;
entries.push(GlobalScriptEntry::new(
min_script_id,
script_file_id,
text_archive_id,
));
}
if entries.is_empty() {
return None;
}
Some(Self::from_entries(entries))
}
pub fn from_platinum_decomp_file(
path: impl AsRef<Path>,
symbols: &SymbolTable,
) -> io::Result<Self> {
let content = std::fs::read_to_string(path)?;
Self::from_platinum_decomp(&content, symbols).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"Failed to parse SCRIPT_RANGE_TABLE macro",
)
})
}
pub fn platinum_hardcoded() -> Self {
let entries = vec![
GlobalScriptEntry::new(10490, 499, 0x21D), GlobalScriptEntry::new(10450, 500, 0x010), GlobalScriptEntry::new(10400, 419, 0x0CB), GlobalScriptEntry::new(10300, 1051, 0x17B), GlobalScriptEntry::new(10200, 407, 0x17B), GlobalScriptEntry::new(10150, 460, 0x26D), GlobalScriptEntry::new(10100, 459, 0x26E), GlobalScriptEntry::new(10000, 410, 0x17D), GlobalScriptEntry::new(9950, 412, 0x17D), GlobalScriptEntry::new(9900, 397, 0x0D5), GlobalScriptEntry::new(9800, 212, 0x0D9), GlobalScriptEntry::new(9700, 423, 0x1AD), GlobalScriptEntry::new(9600, 413, 0x0D5), GlobalScriptEntry::new(9500, 501, 0x223), GlobalScriptEntry::new(9400, 426, 0x1B0), GlobalScriptEntry::new(9300, 406, 0x176), GlobalScriptEntry::new(9200, 422, 0x1AE), GlobalScriptEntry::new(9100, 0, 0x00B), GlobalScriptEntry::new(9000, 213, 0x0DD), GlobalScriptEntry::new(8970, 425, 0x007), GlobalScriptEntry::new(8950, 498, 0x21B), GlobalScriptEntry::new(8900, 424, 0x1AF), GlobalScriptEntry::new(8800, 405, 0x175), GlobalScriptEntry::new(8000, 408, 0x17C), GlobalScriptEntry::new(7000, 404, 0x171), GlobalScriptEntry::new(5000, 1114, 0x0D5), GlobalScriptEntry::new(3000, 1114, 0x0D5), GlobalScriptEntry::new(2800, 414, 0x18D), GlobalScriptEntry::new(2500, 1, 0x011), GlobalScriptEntry::new(2000, 211, 0x0D5), ];
Self::from_entries(entries)
}
pub fn lookup(&self, script_id: u16) -> Option<&GlobalScriptEntry> {
self.entries.iter().find(|e| script_id >= e.min_script_id)
}
pub fn is_global_script(&self, script_id: u16) -> bool {
script_id >= COMMON_SCRIPT_THRESHOLD
}
pub fn entries(&self) -> &[GlobalScriptEntry] {
&self.entries
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use byteorder::WriteBytesExt;
use proptest::prelude::*;
#[test]
fn test_platinum_hardcoded_table() {
let table = GlobalScriptTable::platinum_hardcoded();
assert_eq!(table.len(), PLATINUM_TABLE_ENTRY_COUNT);
let entry = table.lookup(2000).unwrap();
assert_eq!(entry.min_script_id, 2000);
assert_eq!(entry.script_file_id, 211);
let entry = table.lookup(2050).unwrap();
assert_eq!(entry.min_script_id, 2000);
let entry = table.lookup(3500).unwrap();
assert_eq!(entry.min_script_id, 3000);
assert_eq!(entry.script_file_id, 1114);
let entry = table.lookup(5500).unwrap();
assert_eq!(entry.min_script_id, 5000);
assert_eq!(entry.script_file_id, 1114);
}
#[test]
fn test_global_script_check() {
let table = GlobalScriptTable::platinum_hardcoded();
assert!(!table.is_global_script(1));
assert!(!table.is_global_script(1999));
assert!(table.is_global_script(2000));
assert!(table.is_global_script(10000));
}
#[test]
fn test_entry_read() {
let data: [u8; 6] = [0xD0, 0x07, 0xD3, 0x00, 0xD5, 0x00];
let mut cursor = Cursor::new(data);
let entry = GlobalScriptEntry::read_from(&mut cursor).unwrap();
assert_eq!(entry.min_script_id, 2000);
assert_eq!(entry.script_file_id, 211);
assert_eq!(entry.text_archive_id, 213);
}
#[test]
fn test_hgss_decomp_parsing() {
let mut symbols = SymbolTable::new();
symbols.insert_define("_std_scratch_card".to_string(), 10500);
symbols.insert_define("NARC_scr_seq_scr_seq_0263_bin".to_string(), 263);
symbols.insert_define("NARC_msg_msg_0433_bin".to_string(), 0x1B1);
symbols.insert_define("_std_misc".to_string(), 2000);
symbols.insert_define("NARC_scr_seq_scr_seq_0003_bin".to_string(), 3);
symbols.insert_define("NARC_msg_msg_0040_bin".to_string(), 0x28);
let content = r"
const struct ScriptBankMapping sScriptBankMapping[30] = {
{ _std_scratch_card, NARC_scr_seq_scr_seq_0263_bin, NARC_msg_msg_0433_bin },
{ _std_misc, NARC_scr_seq_scr_seq_0003_bin, NARC_msg_msg_0040_bin },
};
";
let table = GlobalScriptTable::from_hgss_decomp(content, &symbols).unwrap();
assert_eq!(table.len(), 2);
let entry = table.lookup(10500).unwrap();
assert_eq!(entry.min_script_id, 10500);
assert_eq!(entry.script_file_id, 263);
assert_eq!(entry.text_archive_id, 0x1B1);
let entry = table.lookup(2000).unwrap();
assert_eq!(entry.min_script_id, 2000);
assert_eq!(entry.script_file_id, 3);
}
#[test]
fn test_hgss_decomp_parsing_accepts_numeric_script_id_literal() {
let mut symbols = SymbolTable::new();
symbols.insert_define("NARC_scr_seq_scr_seq_0734_bin".to_string(), 734);
symbols.insert_define("NARC_msg_msg_0444_bin".to_string(), 444);
let content = r"
const struct ScriptBankMapping sScriptBankMapping[30] = {
{ 10300, NARC_scr_seq_scr_seq_0734_bin, NARC_msg_msg_0444_bin },
};
";
let table = GlobalScriptTable::from_hgss_decomp(content, &symbols).unwrap();
assert_eq!(table.len(), 1);
let entry = table.lookup(10300).unwrap();
assert_eq!(entry.min_script_id, 10300);
assert_eq!(entry.script_file_id, 734);
assert_eq!(entry.text_archive_id, 444);
}
#[test]
fn test_hgss_decomp_parsing_without_generated_narc_symbols() {
let mut symbols = SymbolTable::new();
symbols.insert_define("_std_scratch_card".to_string(), 10500);
symbols.insert_define("_std_misc".to_string(), 2000);
let content = r"
const struct ScriptBankMapping sScriptBankMapping[30] = {
{ _std_scratch_card, NARC_scr_seq_scr_seq_0263_bin, NARC_msg_msg_0433_bin },
{ _std_misc, NARC_scr_seq_scr_seq_0003_bin, NARC_msg_msg_0040_bin },
};
";
let table = GlobalScriptTable::from_hgss_decomp(content, &symbols).unwrap();
assert_eq!(table.len(), 2);
let entry = table.lookup(10500).unwrap();
assert_eq!(entry.min_script_id, 10500);
assert_eq!(entry.script_file_id, 263);
assert_eq!(entry.text_archive_id, 433);
let entry = table.lookup(2000).unwrap();
assert_eq!(entry.min_script_id, 2000);
assert_eq!(entry.script_file_id, 3);
assert_eq!(entry.text_archive_id, 40);
}
#[test]
fn test_platinum_decomp_parsing() {
let mut symbols = SymbolTable::new();
symbols.insert_define("scripts_unk_0499".to_string(), 499);
symbols.insert_define("TEXT_BANK_SCRATCH_OFF_CARDS".to_string(), 0x21D);
symbols.insert_define("scripts_common".to_string(), 211);
symbols.insert_define("TEXT_BANK_COMMON_STRINGS".to_string(), 0x0D5);
symbols.insert_define("SCRIPT_ID_OFFSET_COMMON_SCRIPTS".to_string(), 2000);
let content = r"
// clang-format off
#define SCRIPT_RANGE_TABLE(Entry) \
Entry(10490, scripts_unk_0499, TEXT_BANK_SCRATCH_OFF_CARDS) \
Entry(SCRIPT_ID_OFFSET_COMMON_SCRIPTS, scripts_common, TEXT_BANK_COMMON_STRINGS)
// clang-format on
";
let table = GlobalScriptTable::from_platinum_decomp(content, &symbols).unwrap();
assert_eq!(table.len(), 2);
let entry = table.lookup(10490).unwrap();
assert_eq!(entry.min_script_id, 10490);
assert_eq!(entry.script_file_id, 499);
assert_eq!(entry.text_archive_id, 0x21D);
let entry = table.lookup(2000).unwrap();
assert_eq!(entry.min_script_id, 2000);
assert_eq!(entry.script_file_id, 211);
assert_eq!(entry.text_archive_id, 0x0D5);
}
#[test]
#[ignore = "requires a real HGSS DSPRE project path via UXIE_TEST_HGSS_DSPRE_PATH"]
fn test_hgss_binary_real_file() {
let Some(path) = crate::test_env::existing_file_under_project_env(
"UXIE_TEST_HGSS_DSPRE_PATH",
&["arm9.bin", "unpacked/arm9.bin", "arm9/arm9.bin"],
"HGSS arm9 integration test (from DSPRE project root)",
) else {
return;
};
let table = GlobalScriptTable::from_hgss_binary_file(path).unwrap();
assert_eq!(table.len(), 30);
let entry = table.lookup(10500).unwrap();
assert!(entry.min_script_id >= 10000);
let entry = table.lookup(2000).unwrap();
assert!(entry.min_script_id <= 2000);
}
#[test]
#[ignore = "requires real HGSS fixture roots via UXIE_TEST_HGSS_DECOMP_PATH and UXIE_TEST_HGSS_DSPRE_PATH"]
fn test_hgss_decomp_real_fixture_matches_hgss_binary() {
let Some(fieldmap_path) = crate::test_env::existing_file_under_project_env(
"UXIE_TEST_HGSS_DECOMP_PATH",
&["src/fieldmap.c"],
"HGSS decomp global-script-table integration test",
) else {
return;
};
let Some(arm9_path) = crate::test_env::existing_file_under_project_env(
"UXIE_TEST_HGSS_DSPRE_PATH",
&["arm9.bin", "unpacked/arm9.bin", "arm9/arm9.bin"],
"HGSS binary global-script-table integration test (from DSPRE project root)",
) else {
return;
};
let decomp_root = fieldmap_path
.parent()
.and_then(std::path::Path::parent)
.expect("fieldmap.c should be under <decomp>/src");
let ws = crate::workspace::Workspace::open_decomp(decomp_root).unwrap();
let decomp_table =
GlobalScriptTable::from_hgss_decomp_file(&fieldmap_path, &ws.symbols).unwrap();
let binary_table = GlobalScriptTable::from_hgss_binary_file(arm9_path).unwrap();
assert_eq!(decomp_table.len(), HGSS_TABLE_ENTRY_COUNT);
assert_eq!(binary_table.len(), HGSS_TABLE_ENTRY_COUNT);
for entry in decomp_table.entries() {
assert_eq!(binary_table.lookup(entry.min_script_id), Some(entry));
}
}
#[test]
#[ignore = "requires a real Platinum decomp path via UXIE_TEST_PLATINUM_DECOMP_PATH"]
fn test_platinum_decomp_real_fixture_matches_hardcoded_table() {
let Some(script_manager_path) = crate::test_env::existing_file_under_project_env(
"UXIE_TEST_PLATINUM_DECOMP_PATH",
&["src/script_manager.c"],
"Platinum decomp global-script-table integration test",
) else {
return;
};
let decomp_root = script_manager_path
.parent()
.and_then(std::path::Path::parent)
.expect("script_manager.c should be under <decomp>/src");
let ws = crate::workspace::Workspace::open_decomp(decomp_root).unwrap();
let decomp_table =
GlobalScriptTable::from_platinum_decomp_file(&script_manager_path, &ws.symbols)
.unwrap();
let hardcoded_table = GlobalScriptTable::platinum_hardcoded();
assert_eq!(decomp_table.len(), PLATINUM_TABLE_ENTRY_COUNT);
assert_eq!(hardcoded_table.len(), PLATINUM_TABLE_ENTRY_COUNT);
for entry in hardcoded_table.entries() {
assert_eq!(decomp_table.lookup(entry.min_script_id), Some(entry));
}
}
fn global_entries_strategy() -> impl Strategy<Value = Vec<GlobalScriptEntry>> {
prop::collection::btree_map(any::<u16>(), (any::<u16>(), any::<u16>()), 0..48).prop_map(
|mapping| {
mapping
.into_iter()
.map(|(min_script_id, (script_file_id, text_archive_id))| {
GlobalScriptEntry::new(min_script_id, script_file_id, text_archive_id)
})
.collect()
},
)
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 64,
.. ProptestConfig::default()
})]
#[test]
fn prop_from_entries_sorts_descending(entries in global_entries_strategy()) {
let table = GlobalScriptTable::from_entries(entries.clone());
let mins: Vec<u16> = table.entries().iter().map(|e| e.min_script_id).collect();
prop_assert!(mins.windows(2).all(|w| w[0] >= w[1]));
prop_assert_eq!(table.len(), entries.len());
}
#[test]
fn prop_lookup_matches_manual_search(entries in global_entries_strategy(), script_id in any::<u16>()) {
let mut expected_entries = entries.clone();
expected_entries.sort_by(|a, b| b.min_script_id.cmp(&a.min_script_id));
let expected = expected_entries
.iter()
.find(|e| script_id >= e.min_script_id)
.copied();
let table = GlobalScriptTable::from_entries(entries);
let actual = table.lookup(script_id).copied();
prop_assert_eq!(actual, expected);
}
#[test]
fn prop_is_global_script_threshold(script_id in any::<u16>()) {
let table = GlobalScriptTable::new();
prop_assert_eq!(
table.is_global_script(script_id),
script_id >= COMMON_SCRIPT_THRESHOLD
);
}
#[test]
fn prop_entry_read_from_roundtrip(
min_script_id in any::<u16>(),
script_file_id in any::<u16>(),
text_archive_id in any::<u16>()
) {
let mut bytes = Vec::new();
bytes.write_u16::<LittleEndian>(min_script_id).unwrap();
bytes.write_u16::<LittleEndian>(script_file_id).unwrap();
bytes.write_u16::<LittleEndian>(text_archive_id).unwrap();
let mut cursor = Cursor::new(bytes);
let parsed = GlobalScriptEntry::read_from(&mut cursor).unwrap();
prop_assert_eq!(parsed.min_script_id, min_script_id);
prop_assert_eq!(parsed.script_file_id, script_file_id);
prop_assert_eq!(parsed.text_archive_id, text_archive_id);
}
}
}