uxie 0.6.3

Data fetching library for Pokemon Gen 4 romhacking - map headers, C parsing, and more
Documentation
use crate::c_parser::defines::CFunctionMacro;
use crate::c_parser::symbol_table::{ConstantFamily, SymbolTable, SymbolTag};
use crate::game::GameFamily;
use bitcode::{Decode, Encode};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::{Path, PathBuf};
use xxhash_rust::xxh3::xxh3_64;

pub const CONSTANT_CACHE_VERSION: u32 = 3;

#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, Eq)]
pub struct ConstantCache {
    pub version: u32,
    pub uxie_version: String,
    pub game_family: String,
    pub file_hashes: HashMap<String, u64>,
    pub snapshot: SymbolSnapshot,
}

#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, PartialEq, Eq)]
pub struct SymbolSnapshot {
    pub symbols: HashMap<String, i64>,
    pub value_to_names: HashMap<i64, Vec<String>>,
    pub pending: HashMap<String, String>,
    pub function_macros: HashMap<String, CFunctionMacro>,
    pub symbol_to_tags: HashMap<String, HashSet<SymbolTag>>,
    pub symbol_to_family: HashMap<String, ConstantFamily>,
}

impl ConstantCache {
    pub fn from_symbols(
        project_root: &Path,
        game_family: GameFamily,
        input_files: &[PathBuf],
        symbols: &SymbolTable,
    ) -> io::Result<Self> {
        let mut file_hashes = HashMap::new();
        for path in input_files {
            let relative = relative_cache_path(project_root, path)?;
            file_hashes.insert(relative, xxh3_64(&std::fs::read(path)?));
        }

        Ok(Self {
            version: CONSTANT_CACHE_VERSION,
            uxie_version: env!("CARGO_PKG_VERSION").to_string(),
            game_family: game_family_key(game_family).to_string(),
            file_hashes,
            snapshot: symbols.to_snapshot(),
        })
    }

    pub fn load(path: &Path) -> io::Result<Self> {
        let bytes = std::fs::read(path)?;
        bitcode::decode(&bytes).map_err(|source| {
            io::Error::new(
                io::ErrorKind::InvalidData,
                format!(
                    "Failed to decode constant cache {}: {source}",
                    path.display()
                ),
            )
        })
    }

    pub fn save(&self, path: &Path) -> io::Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let bytes = bitcode::encode(self);
        std::fs::write(path, bytes)
    }

    pub fn is_current(
        &self,
        project_root: &Path,
        game_family: GameFamily,
        input_files: &[PathBuf],
    ) -> io::Result<bool> {
        if self.version != CONSTANT_CACHE_VERSION
            || self.uxie_version != env!("CARGO_PKG_VERSION")
            || self.game_family != game_family_key(game_family)
        {
            return Ok(false);
        }

        if input_files.len() != self.file_hashes.len() {
            return Ok(false);
        }

        for path in input_files {
            let relative = relative_cache_path(project_root, path)?;
            let Some(expected_hash) = self.file_hashes.get(&relative) else {
                return Ok(false);
            };
            if !path.is_file() || xxh3_64(&std::fs::read(path)?) != *expected_hash {
                return Ok(false);
            }
        }

        Ok(true)
    }
}

fn relative_cache_path(project_root: &Path, path: &Path) -> io::Result<String> {
    let relative = path.strip_prefix(project_root).map_err(|_| {
        io::Error::new(
            io::ErrorKind::InvalidData,
            format!(
                "Cached symbol file '{}' is outside project root '{}'",
                path.display(),
                project_root.display()
            ),
        )
    })?;
    Ok(relative.to_string_lossy().replace('\\', "/"))
}

fn game_family_key(game_family: GameFamily) -> &'static str {
    match game_family {
        GameFamily::DP => "dp",
        GameFamily::Platinum => "platinum",
        GameFamily::HGSS => "hgss",
    }
}

#[cfg(test)]
mod tests {
    use super::{ConstantCache, CONSTANT_CACHE_VERSION};
    use crate::c_parser::defines::CFunctionMacro;
    use crate::c_parser::{ConstantFamily, SourceManager, SymbolSnapshot, SymbolTable, SymbolTag};
    use crate::game::GameFamily;
    use std::collections::{HashMap, HashSet};
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn symbol_snapshot_round_trips_symbol_table_state() {
        let dir = tempdir().unwrap();
        let header = dir.path().join("test.h");
        fs::write(
            &header,
            "#define SPECIES_BULBASAUR 1\n#define OTHER SPECIES_BULBASAUR\n#define SCALE(x) ((x) * 2)\n",
        )
        .unwrap();

        let mut symbols = SymbolTable::with_source_manager(SourceManager::new());
        symbols.load_header(&header).unwrap();
        symbols
            .load_header_str_with_tag("#define MAP_CONST 7\n", &header, SymbolTag::Map(12))
            .unwrap();
        symbols.resolve_all();

        let snapshot = symbols.to_snapshot();
        let restored = SymbolTable::from_snapshot(&snapshot);

        assert_eq!(restored.resolve_constant("SPECIES_BULBASAUR"), Some(1));
        assert_eq!(restored.resolve_constant("OTHER"), Some(1));
        assert!(restored
            .get_symbols_by_tag(&SymbolTag::Map(12))
            .contains(&"MAP_CONST".to_string()));
        assert_eq!(
            restored.constant_family("SPECIES_BULBASAUR"),
            Some(ConstantFamily::Species)
        );
        assert_eq!(
            restored.to_snapshot().function_macros.get("SCALE").cloned(),
            Some(CFunctionMacro {
                name: "SCALE".to_string(),
                params: vec!["x".to_string()],
                value: "((x) * 2)".to_string(),
            })
        );
    }

    #[test]
    fn constant_cache_round_trips_and_uses_relative_paths() {
        let dir = tempdir().unwrap();
        let project_root = dir.path();
        let include_dir = project_root.join("include/constants");
        fs::create_dir_all(&include_dir).unwrap();
        let header = include_dir.join("test.h");
        fs::write(&header, "#define TEST_CONST 42\n").unwrap();

        let mut symbols = SymbolTable::with_source_manager(SourceManager::new());
        symbols
            .load_header_str_with_tag("#define SPECIES_BULBASAUR 1\n", &header, SymbolTag::Global)
            .unwrap();
        let cache = ConstantCache::from_symbols(
            project_root,
            GameFamily::Platinum,
            std::slice::from_ref(&header),
            &symbols,
        )
        .unwrap();
        let cache_path = project_root.join("cache/constants.bin");
        cache.save(&cache_path).unwrap();
        let loaded = ConstantCache::load(&cache_path).unwrap();

        assert_eq!(loaded.version, CONSTANT_CACHE_VERSION);
        assert!(loaded.file_hashes.contains_key("include/constants/test.h"));
        assert!(loaded
            .is_current(
                project_root,
                GameFamily::Platinum,
                std::slice::from_ref(&header)
            )
            .unwrap());
        assert_eq!(
            SymbolTable::from_snapshot(&loaded.snapshot).resolve_constant("SPECIES_BULBASAUR"),
            Some(1)
        );
        assert_eq!(
            SymbolTable::from_snapshot(&loaded.snapshot).constant_family("SPECIES_BULBASAUR"),
            Some(ConstantFamily::Species)
        );
    }

    #[test]
    fn constant_cache_detects_stale_or_corrupt_files() {
        let dir = tempdir().unwrap();
        let project_root = dir.path();
        let include_dir = project_root.join("include/constants");
        fs::create_dir_all(&include_dir).unwrap();
        let header = include_dir.join("test.h");
        fs::write(&header, "#define TEST_CONST 42\n").unwrap();

        let mut symbols = SymbolTable::with_source_manager(SourceManager::new());
        symbols.load_header(&header).unwrap();
        let cache = ConstantCache::from_symbols(
            project_root,
            GameFamily::Platinum,
            std::slice::from_ref(&header),
            &symbols,
        )
        .unwrap();
        let cache_path = project_root.join("cache/constants.bin");
        cache.save(&cache_path).unwrap();

        fs::write(&header, "#define TEST_CONST 43\n").unwrap();
        assert!(!ConstantCache::load(&cache_path)
            .unwrap()
            .is_current(
                project_root,
                GameFamily::Platinum,
                std::slice::from_ref(&header)
            )
            .unwrap());

        fs::write(&cache_path, b"not-bitcode").unwrap();
        assert!(ConstantCache::load(&cache_path).is_err());
    }

    #[test]
    fn constant_cache_metadata_mismatches_are_stale() {
        let dir = tempdir().unwrap();
        let project_root = dir.path();
        let include_dir = project_root.join("include/constants");
        fs::create_dir_all(&include_dir).unwrap();
        let header = include_dir.join("test.h");
        fs::write(&header, "#define TEST_CONST 42\n").unwrap();

        let mut symbols = SymbolTable::with_source_manager(SourceManager::new());
        symbols.load_header(&header).unwrap();
        let cache = ConstantCache::from_symbols(
            project_root,
            GameFamily::Platinum,
            std::slice::from_ref(&header),
            &symbols,
        )
        .unwrap();

        let mut wrong_version = cache.clone();
        wrong_version.version += 1;
        assert!(!wrong_version
            .is_current(
                project_root,
                GameFamily::Platinum,
                std::slice::from_ref(&header)
            )
            .unwrap());

        let mut wrong_uxie_version = cache.clone();
        wrong_uxie_version.uxie_version.push_str("-mutated");
        assert!(!wrong_uxie_version
            .is_current(
                project_root,
                GameFamily::Platinum,
                std::slice::from_ref(&header)
            )
            .unwrap());

        assert!(!cache
            .is_current(
                project_root,
                GameFamily::HGSS,
                std::slice::from_ref(&header)
            )
            .unwrap());
    }

    #[test]
    fn symbol_snapshot_supports_manual_round_trip_shape() {
        let snapshot = SymbolSnapshot {
            symbols: HashMap::from([("VALUE".to_string(), 5)]),
            value_to_names: HashMap::from([(5, vec!["VALUE".to_string()])]),
            pending: HashMap::from([("VALUE".to_string(), "5".to_string())]),
            function_macros: HashMap::new(),
            symbol_to_tags: HashMap::from([(
                "VALUE".to_string(),
                HashSet::from([SymbolTag::Global]),
            )]),
            symbol_to_family: HashMap::from([("VALUE".to_string(), ConstantFamily::Item)]),
        };

        assert_eq!(
            SymbolTable::from_snapshot(&snapshot)
                .to_snapshot()
                .symbols
                .get("VALUE")
                .copied(),
            Some(5)
        );
        assert_eq!(
            SymbolTable::from_snapshot(&snapshot).constant_family("VALUE"),
            Some(ConstantFamily::Item)
        );
    }
}