From 642d2a93067be6a71e1b5b1d102f8dc0243125ec Mon Sep 17 00:00:00 2001 From: Seemann Date: Wed, 6 Aug 2025 23:05:00 -0400 Subject: [PATCH 1/5] add edit mode parser --- src/modes/Cargo.lock | 96 ++ src/modes/Cargo.toml | 10 + src/modes/src/main.rs | 185 ++ src/modes/src/mode.rs | 515 ++++++ src/modes/src/modes.rs | 1972 ++++++++++++++++++++++ src/modes/src/string_variables.rs | 166 ++ src/modes/test_cases/test_base.xml | 14 + src/modes/test_cases/test_child.xml | 12 + src/modes/test_cases/test_circular_a.xml | 4 + src/modes/test_cases/test_circular_b.xml | 4 + src/modes/test_cases/test_circular_c.xml | 4 + src/modes/test_cases/test_circular_d.xml | 4 + src/modes/test_cases/test_circular_e.xml | 4 + src/modes/test_cases/test_grandchild.xml | 7 + 14 files changed, 2997 insertions(+) create mode 100644 src/modes/Cargo.lock create mode 100644 src/modes/Cargo.toml create mode 100644 src/modes/src/main.rs create mode 100644 src/modes/src/mode.rs create mode 100644 src/modes/src/modes.rs create mode 100644 src/modes/src/string_variables.rs create mode 100644 src/modes/test_cases/test_base.xml create mode 100644 src/modes/test_cases/test_child.xml create mode 100644 src/modes/test_cases/test_circular_a.xml create mode 100644 src/modes/test_cases/test_circular_b.xml create mode 100644 src/modes/test_cases/test_circular_c.xml create mode 100644 src/modes/test_cases/test_circular_d.xml create mode 100644 src/modes/test_cases/test_circular_e.xml create mode 100644 src/modes/test_cases/test_grandchild.xml diff --git a/src/modes/Cargo.lock b/src/modes/Cargo.lock new file mode 100644 index 0000000..74bd9ef --- /dev/null +++ b/src/modes/Cargo.lock @@ -0,0 +1,96 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "xml" +version = "0.1.0" +dependencies = [ + "anyhow", + "glob", + "quick-xml", + "serde", +] diff --git a/src/modes/Cargo.toml b/src/modes/Cargo.toml new file mode 100644 index 0000000..5cccdc0 --- /dev/null +++ b/src/modes/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "xml" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +quick-xml = { version = "0.36", features = ["serialize"] } +glob = "0.3" \ No newline at end of file diff --git a/src/modes/src/main.rs b/src/modes/src/main.rs new file mode 100644 index 0000000..61d2e0c --- /dev/null +++ b/src/modes/src/main.rs @@ -0,0 +1,185 @@ +mod mode; +mod modes; +mod string_variables; + +use modes::ModeManager; +use std::path::Path; + +fn main() { + // Create a new ModeManager + let mut manager = ModeManager::new(); + + // Register the standard variables + manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); + + // Load all modes from the shared_XML directory + println!("Loading modes from shared_XML directory...\n"); + + match manager.load_from_directory(Path::new("shared_XML")) { + Ok(count) => { + println!("Successfully loaded {} modes from shared_XML\n", count); + + // Display information about all loaded modes + println!("=== Loaded Modes ==="); + + // Collect mode IDs and basic info first to avoid borrow checker issues + let mode_count = manager.mode_count(); + let mut mode_infos = Vec::new(); + for i in 0..mode_count { + manager.set_current_mode_by_index(i); + if let (Some(id), Some(title), Some(game)) = (manager.get_id(), manager.get_title(), manager.get_game()) { + let extends = manager.get_extends(); + let mode_type = manager.get_type(); + mode_infos.push((id, title, game, extends, mode_type)); + } + } + + for (id, title, game, extends, r#type) in mode_infos { + println!("\nMode: {}", id); + println!(" Title: {}", title); + println!(" Game: {}", game); + + if let Some(extends) = extends { + println!(" Extends: {}", extends); + } + + if let Some(r#type) = r#type { + println!(" Type: {}", r#type); + } + + // Set this mode as current to demonstrate path substitution + manager.set_current_mode_by_id(&id); + + if let Some(data) = manager.get_data() { + println!(" Data path (with substitutions): {}", data); + } + + println!(" Library files: {}", manager.get_library().len()); + println!(" Classes: {}", manager.get_classes().len()); + println!(" Enums: {}", manager.get_enums().len()); + println!(" IDE entries: {}", manager.get_ide().len()); + println!(" Templates: {}", manager.get_templates().len()); + println!(" Copy directories: {}", manager.get_copy_directory().len()); + } + + // Example: Using the new getter methods with current mode + println!("\n=== Using Getter Methods with Current Mode ==="); + + // Set sa_sbl as the current mode + if manager.set_current_mode_by_id("sa_sbl") { + println!("Current mode set to: {:?}", manager.get_id().unwrap()); + println!(" Title: {:?}", manager.get_title()); + println!(" Game: {:?}", manager.get_game()); + + // Path properties will have @sb: and @game: replaced + if let Some(data) = manager.get_data() { + println!(" Data path: {}", data); + } + + if let Some(compiler) = manager.get_compiler() { + println!(" Compiler: {}", compiler); + } + + if let Some(keywords) = manager.get_keywords() { + println!(" Keywords: {}", keywords); + } + + println!("\n Library files (with path substitutions):"); + for lib in manager.get_library() { + println!(" - {} (autoupdate: {:?})", lib.value, lib.autoupdate); + } + + println!("\n Classes (with path substitutions):"); + for class in manager.get_classes() { + println!(" - {} (autoupdate: {:?})", class.value, class.autoupdate); + } + + println!("\n IDE entries (with path substitutions):"); + for ide in manager.get_ide() { + println!(" - {} (base: {:?})", ide.value, ide.base); + } + } + + // Example: Demonstrate the new API methods + println!("\n=== New API Methods Demo ==="); + + // Set mode by index + if manager.set_current_mode_by_index(0) { + if let (Some(id), Some(title)) = (manager.get_id(), manager.get_title()) { + println!("Mode at index 0: {} ({})", id, title); + } + } + + // Set mode by ID + if manager.set_current_mode_by_id("sa") { + if let Some(title) = manager.get_title() { + println!("Found mode by ID 'sa': {}", title); + } + } + + // Set mode by game (first SA game mode) + if manager.set_current_mode_by_game(mode::Game::Sa) { + if let (Some(id), Some(title)) = (manager.get_id(), manager.get_title()) { + println!("\nFirst mode for SA game: {} ({})", id, title); + } + } + + // Count modes for a specific game + let mut sa_game_count = 0; + println!("\nModes for SA game:"); + for i in 0..manager.mode_count() { + manager.set_current_mode_by_index(i); + if let Some(game) = manager.get_game() { + if game == mode::Game::Sa { + if let (Some(id), Some(title)) = (manager.get_id(), manager.get_title()) { + println!(" - {} ({})", id, title); + sa_game_count += 1; + } + } + } + } + println!("Total SA game modes: {}", sa_game_count); + + println!("\nTotal modes loaded: {}", manager.mode_count()); + + // Example: Check inheritance with new getter methods + println!("\n=== Inheritance Example with Getters ==="); + + // Set sa_sbl_sf (child) as current mode + if manager.set_current_mode_by_id("sa_sbl_sf") { + println!("Mode 'sa_sbl_sf' extends '{}'", manager.get_extends().unwrap_or("nothing".to_string())); + + // Get child's values + let child_data = manager.get_data(); + let child_keywords = manager.get_keywords(); + let child_library_count = manager.get_library().len(); + let child_classes_count = manager.get_classes().len(); + + // Switch to parent mode + if manager.set_current_mode_by_id("sa_sbl") { + let parent_data = manager.get_data(); + let parent_keywords = manager.get_keywords(); + let parent_library_count = manager.get_library().len(); + let parent_classes_count = manager.get_classes().len(); + + println!("\nInherited from parent:"); + if child_data == parent_data { + println!(" - data: {}", child_data.unwrap_or("none".to_string())); + } + if child_keywords == parent_keywords { + println!(" - keywords: {}", child_keywords.unwrap_or("none".to_string())); + } + + println!("\nOverridden in child:"); + println!(" - library: {} entries (parent has {})", child_library_count, parent_library_count); + println!(" - classes: {} entries (parent has {})", child_classes_count, parent_classes_count); + } + } + + } + Err(e) => { + eprintln!("Failed to load modes from shared_XML: {:?}", e); + } + } +} \ No newline at end of file diff --git a/src/modes/src/mode.rs b/src/modes/src/mode.rs new file mode 100644 index 0000000..7370e6e --- /dev/null +++ b/src/modes/src/mode.rs @@ -0,0 +1,515 @@ +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; + +/// Game enum representing supported game types +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[serde(rename_all = "snake_case")] +pub enum Game { + #[serde(rename = "gta3")] + Gta3, + #[serde(rename = "vc")] + Vc, + #[serde(rename = "sa")] + Sa, + #[serde(rename = "lcs")] + Lcs, + #[serde(rename = "vcs")] + Vcs, + #[serde(rename = "sa_mobile")] + SaMobile, + #[serde(rename = "vc_mobile")] + VcMobile, +} + +impl std::fmt::Display for Game { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Game::Gta3 => write!(f, "gta3"), + Game::Vc => write!(f, "vc"), + Game::Sa => write!(f, "sa"), + Game::Lcs => write!(f, "lcs"), + Game::Vcs => write!(f, "vcs"), + Game::SaMobile => write!(f, "sa_mobile"), + Game::VcMobile => write!(f, "vc_mobile"), + } + } +} + +/// Text format enum representing supported text format types +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[serde(rename_all = "snake_case")] +pub enum TextFormat { + #[serde(rename = "gta3")] + Gta3, + #[serde(rename = "vc")] + Vc, + #[serde(rename = "sa")] + Sa, + #[serde(rename = "sa_mobile")] + SAUnicode, +} + +impl std::fmt::Display for TextFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TextFormat::Gta3 => write!(f, "gta3"), + TextFormat::Vc => write!(f, "vc"), + TextFormat::Sa => write!(f, "sa"), + TextFormat::SAUnicode => write!(f, "sa_mobile"), + } + } +} + +/// Element with optional autoupdate attribute and text content +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct AutoUpdateElement { + #[serde(rename = "@autoupdate", skip_serializing_if = "Option::is_none")] + pub autoupdate: Option, + #[serde(rename = "$value")] + pub value: String, +} + +/// IDE element with optional base attribute and text content +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct IdeElement { + #[serde(rename = "@base", skip_serializing_if = "Option::is_none")] + pub base: Option, + #[serde(rename = "$value")] + pub value: String, +} + +/// Text element with optional format attribute and text content +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct TextElement { + #[serde(rename = "@format", skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(rename = "$value")] + pub value: String, +} + +/// Templates element with type attribute and text content +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct TemplateElement { + #[serde(rename = "@type")] + pub r#type: String, + #[serde(rename = "$value")] + pub value: String, +} + +/// Copy-directory element with type attribute and text content +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct CopyDirectoryElement { + #[serde(rename = "@type")] + pub r#type: String, + #[serde(rename = "$value")] + pub value: String, +} + +/// Main Mode structure representing the root XML element +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +pub struct Mode { + #[serde(rename = "@id")] + pub id: String, + #[serde(rename = "@title")] + pub title: String, + #[serde(rename = "@game")] + pub game: Game, + #[serde(rename = "@type", skip_serializing_if = "Option::is_none")] + pub r#type: Option, + #[serde(rename = "@extends", skip_serializing_if = "Option::is_none")] + pub extends: Option, + + // Non-serialized fields for tracking + #[serde(skip)] + pub file_name: Option, + + // Simple text elements + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub compiler: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub constants: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub keywords: Option, + #[serde( + rename = "cleo-default-extensions", + skip_serializing_if = "Option::is_none" + )] + pub cleo_default_extensions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub variables: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub arrays: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub missions: Option, + + // Elements with optional attributes + #[serde(skip_serializing_if = "Option::is_none")] + pub examples: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + + // Elements that can appear multiple times + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub opcodes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub library: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub classes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub enums: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ide: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub templates: Vec, + #[serde( + rename = "copy-directory", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub copy_directory: Vec, +} + +impl Mode { + /// Validate that the Mode has valid attribute values + pub fn validate(&self) -> Result<()> { + // Validate type attribute - if present, must be "default" or "" + if let Some(type_value) = &self.r#type { + if type_value != "default" && type_value != "" { + bail!( + "Invalid type attribute '{}' for mode '{}'. Type attribute if present can only be 'default'", + type_value, self.id + ); + } + } + Ok(()) + } + + /// Merge fields from a parent mode, keeping this mode's non-None values + pub fn merge_from_parent(&mut self, parent: &Mode) { + // Only merge fields that are None in the child + // type is not inherited + + // Simple text elements + if self.data.is_none() { + self.data = parent.data.clone(); + } + if self.compiler.is_none() { + self.compiler = parent.compiler.clone(); + } + if self.constants.is_none() { + self.constants = parent.constants.clone(); + } + if self.keywords.is_none() { + self.keywords = parent.keywords.clone(); + } + if self.cleo_default_extensions.is_none() { + self.cleo_default_extensions = parent.cleo_default_extensions.clone(); + } + if self.variables.is_none() { + self.variables = parent.variables.clone(); + } + if self.labels.is_none() { + self.labels = parent.labels.clone(); + } + if self.arrays.is_none() { + self.arrays = parent.arrays.clone(); + } + if self.missions.is_none() { + self.missions = parent.missions.clone(); + } + + // Elements with optional attributes + if self.examples.is_none() { + self.examples = parent.examples.clone(); + } + if self.text.is_none() { + self.text = parent.text.clone(); + } + + // Vectors - only inherit if child has no entries + // If child defines any entries, it completely replaces parent's entries + if self.opcodes.is_empty() { + self.opcodes = parent.opcodes.clone(); + } + + if self.library.is_empty() { + self.library = parent.library.clone(); + } + + if self.classes.is_empty() { + self.classes = parent.classes.clone(); + } + + if self.enums.is_empty() { + self.enums = parent.enums.clone(); + } + + if self.ide.is_empty() { + self.ide = parent.ide.clone(); + } + + if self.templates.is_empty() { + self.templates = parent.templates.clone(); + } + + if self.copy_directory.is_empty() { + self.copy_directory = parent.copy_directory.clone(); + } + } +} + + +#[cfg(test)] +mod tests { + use super::*; + use quick_xml::de::from_str; + + #[test] + fn test_mode_deserialization() { + // Test that Mode can be deserialized from XML + let xml = r#" + + @sb:\data\test\ + @sb:\compiler.exe + @sb:\constants.txt + @sb:\keywords.txt + @game:\variables.ini + @sb:\examples.txt + @game:\text.gxt + @sb:\opcodes1.ini + @sb:\opcodes2.ini + @sb:\library.json + @sb:\classes.db + @sb:\enums.txt + @game:\default.ide + @sb:\templates.txt + @game:\data\main +"#; + + let result = from_str::(xml); + assert!(result.is_ok(), "Failed to deserialize Mode from XML"); + + let mode = result.unwrap(); + assert_eq!(mode.id, "test_mode"); + assert_eq!(mode.title, "Test Mode"); + assert_eq!(mode.game, Game::Sa); + assert_eq!(mode.r#type, Some("default".to_string())); + assert!(mode.extends.is_none()); + + assert_eq!(mode.data, Some("@sb:\\data\\test\\".to_string())); + assert_eq!(mode.compiler, Some("@sb:\\compiler.exe".to_string())); + assert_eq!(mode.opcodes.len(), 2); + assert_eq!(mode.library.len(), 1); + assert_eq!(mode.classes.len(), 1); + assert!(mode.examples.is_some()); + assert!(mode.text.is_some()); + } + + #[test] + fn test_merge_from_parent() { + // Create parent mode + let parent_xml = r#" + + parent_data + parent_compiler + parent_keywords + parent_opcode1 + parent_opcode2 + parent_library +"#; + + let parent = from_str::(parent_xml).unwrap(); + + // Create child mode with some overrides + let child_xml = r#" + + child_compiler + child_constants + child_opcode +"#; + + let mut child = from_str::(child_xml).unwrap(); + + // Apply inheritance + child.merge_from_parent(&parent); + + // Test inherited fields + assert_eq!(child.data, Some("parent_data".to_string())); // Inherited + assert_eq!(child.keywords, Some("parent_keywords".to_string())); // Inherited + assert_eq!(child.r#type, None); // type is not inherited + + // Test overridden fields + assert_eq!(child.compiler, Some("child_compiler".to_string())); // Kept child's value + assert_eq!(child.constants, Some("child_constants".to_string())); // Child's new field + + // Test vector replacement (not merge) + assert_eq!(child.opcodes.len(), 1); // Child defined opcodes, so only has child's + assert_eq!(child.opcodes[0], "child_opcode"); + + // Test that empty vectors inherit from parent + assert_eq!(child.library.len(), 1); // Child didn't define library, so inherits parent's + assert_eq!(child.library[0].value, "parent_library"); + } + + + #[test] + fn test_mode_with_extends() { + let xml = r#" + + extended_data +"#; + + let mode = from_str::(xml).unwrap(); + assert_eq!(mode.extends, Some("base_mode".to_string())); + } + + #[test] + fn test_empty_vectors() { + let xml = r#" + + test_data +"#; + + let mode = from_str::(xml).unwrap(); + assert!(mode.opcodes.is_empty()); + assert!(mode.library.is_empty()); + assert!(mode.classes.is_empty()); + assert!(mode.enums.is_empty()); + assert!(mode.ide.is_empty()); + assert!(mode.templates.is_empty()); + assert!(mode.copy_directory.is_empty()); + } + + #[test] + fn test_element_attributes() { + let xml = r#" + + examples.txt + text.gxt + lib.json + classes.db + enums.txt + default.ide + templates.txt + main_dir +"#; + + let mode = from_str::(xml).unwrap(); + + assert_eq!(mode.examples.as_ref().unwrap().autoupdate, Some("no".to_string())); + assert_eq!(mode.examples.as_ref().unwrap().value, "examples.txt"); + + assert_eq!(mode.text.as_ref().unwrap().format, Some(TextFormat::Sa)); + assert_eq!(mode.text.as_ref().unwrap().value, "text.gxt"); + + assert_eq!(mode.library[0].autoupdate, Some("yes".to_string())); + assert_eq!(mode.classes[0].autoupdate, Some("no".to_string())); + assert_eq!(mode.enums[0].autoupdate, Some("yes".to_string())); + + assert_eq!(mode.ide[0].base, Some("/base/path/".to_string())); + assert_eq!(mode.templates[0].r#type, "custom"); + assert_eq!(mode.copy_directory[0].r#type, "main"); + } + + #[test] + fn test_invalid_game_value_causes_parse_error() { + let xml = r#" + + test data + + "#; + + let result: Result = from_str(xml); + assert!(result.is_err(), "Should fail to parse unknown game value"); + + if let Err(e) = result { + let error_msg = e.to_string(); + assert!( + error_msg.contains("unknown_game") || error_msg.contains("unknown variant"), + "Error should mention the invalid game value: {}", + error_msg + ); + } + } + + #[test] + fn test_invalid_text_format_causes_parse_error() { + let xml = r#" + + test.txt + + "#; + + let result: Result = from_str(xml); + assert!(result.is_err(), "Should fail to parse unknown text format value"); + + if let Err(e) = result { + let error_msg = e.to_string(); + assert!( + error_msg.contains("invalid_format") || error_msg.contains("unknown variant"), + "Error should mention the invalid format value: {}", + error_msg + ); + } + } + + #[test] + fn test_type_attribute_validation() { + // Test that type="default" is valid + let xml = r#" + + test data + + "#; + + let result: Result = from_str(xml); + assert!(result.is_ok(), "Should successfully parse mode with type='default'"); + let mode = result.unwrap(); + assert!(mode.validate().is_ok(), "Mode with type='default' should pass validation"); + + // Test that type="" is valid + let xml = r#" + + test data + + "#; + + let result: Result = from_str(xml); + assert!(result.is_ok(), "Should successfully parse mode with type=''"); + let mode = result.unwrap(); + assert!(mode.validate().is_ok(), "Mode with type='' should pass validation"); + + // Test that invalid type values fail validation + let xml = r#" + + test data + + "#; + + let result: Result = from_str(xml); + assert!(result.is_ok(), "Should parse XML successfully"); + let mode = result.unwrap(); + let validation_result = mode.validate(); + assert!(validation_result.is_err(), "Mode with type='custom' should fail validation"); + if let Err(e) = validation_result { + let error_msg = e.to_string(); + assert!(error_msg.contains("can only be 'default'"), "Error message should indicate that type can only be 'default'"); + } + + // Test that no type attribute is valid + let xml = r#" + + test data + + "#; + + let result: Result = from_str(xml); + assert!(result.is_ok(), "Should successfully parse mode without type attribute"); + let mode = result.unwrap(); + assert!(mode.validate().is_ok(), "Mode without type attribute should pass validation"); + } +} \ No newline at end of file diff --git a/src/modes/src/modes.rs b/src/modes/src/modes.rs new file mode 100644 index 0000000..35446f4 --- /dev/null +++ b/src/modes/src/modes.rs @@ -0,0 +1,1972 @@ +use crate::mode::{ + AutoUpdateElement, CopyDirectoryElement, Game, IdeElement, Mode, TemplateElement, TextElement, +}; +use crate::string_variables::StringVariables; +use anyhow::{Context, Result, anyhow, bail}; +use quick_xml::de::from_str; +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +/// ModeManager is responsible for loading and managing all modes from a directory +pub struct ModeManager { + /// All loaded modes stored in a vector + modes: Vec, + /// String variables for placeholder substitution + variables: StringVariables, + /// The currently selected mode index + current_mode_index: Option, +} + +impl ModeManager { + /// Create a new ModeManager instance + pub fn new() -> Self { + Self { + modes: Vec::new(), + variables: StringVariables::new(), + current_mode_index: None, + } + } + + /// Set the current mode by ID + pub fn set_current_mode_by_id(&mut self, mode_id: &str) -> bool { + if let Some(index) = self.modes.iter().position(|m| m.id == mode_id) { + self.current_mode_index = Some(index); + true + } else { + false + } + } + + /// Set the current mode by game (prioritizes modes with type="default") + pub fn set_current_mode_by_game(&mut self, game: Game) -> bool { + // First, try to find a mode with matching game and type="default" + if let Some(index) = self + .modes + .iter() + .position(|m| m.game == game && m.r#type.as_deref() == Some("default")) + { + self.current_mode_index = Some(index); + return true; + } + + // If no default mode found, fall back to any mode with matching game + if let Some(index) = self.modes.iter().position(|m| m.game == game) { + self.current_mode_index = Some(index); + true + } else { + false + } + } + + /// Set the current mode by index + pub fn set_current_mode_by_index(&mut self, index: usize) -> bool { + if index < self.modes.len() { + self.current_mode_index = Some(index); + true + } else { + false + } + } + + /// Register a variable for substitution + pub fn register_variable(&mut self, name: String, value: String) { + self.variables.register(name, value); + } + + /// Helper method to apply path substitutions + fn apply_variables(&self, s: &str) -> String { + self.variables.process(s) + } + + /// Helper method to find a mode by ID + fn find_mode_by_id(&self, mode_id: &str) -> Option<&Mode> { + self.modes.iter().find(|m| m.id == mode_id) + } + + /// Helper method to find a mutable mode by ID + fn find_mode_by_id_mut(&mut self, mode_id: &str) -> Option<&mut Mode> { + self.modes.iter_mut().find(|m| m.id == mode_id) + } + + /// Get the total number of modes + pub fn mode_count(&self) -> usize { + self.modes.len() + } + + /// Getters for mode properties with path substitution + + /// Get ID for mode at specific index + pub fn get_id_at(&self, index: usize) -> Option { + self.modes.get(index).map(|mode| mode.id.clone()) + } + + pub fn get_id(&self) -> Option { + self.current_mode_index.and_then(|idx| self.get_id_at(idx)) + } + + /// Get title for mode at specific index + pub fn get_title_at(&self, index: usize) -> Option { + self.modes.get(index).map(|mode| mode.title.clone()) + } + + pub fn get_title(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_title_at(idx)) + } + + /// Get game for mode at specific index + pub fn get_game_at(&self, index: usize) -> Option { + self.modes.get(index).map(|mode| mode.game.clone()) + } + + pub fn get_game(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_game_at(idx)) + } + + /// Get type for mode at specific index + pub fn get_type_at(&self, index: usize) -> Option { + self.modes.get(index).and_then(|mode| mode.r#type.clone()) + } + + pub fn get_type(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_type_at(idx)) + } + + /// Get extends for mode at specific index + pub fn get_extends_at(&self, index: usize) -> Option { + self.modes.get(index).and_then(|mode| mode.extends.clone()) + } + + pub fn get_extends(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_extends_at(idx)) + } + + /// Get data for mode at specific index + pub fn get_data_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.data.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_data(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_data_at(idx)) + } + + /// Get compiler for mode at specific index + pub fn get_compiler_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.compiler.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_compiler(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_compiler_at(idx)) + } + + /// Get constants for mode at specific index + pub fn get_constants_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.constants.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_constants(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_constants_at(idx)) + } + + /// Get keywords for mode at specific index + pub fn get_keywords_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.keywords.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_keywords(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_keywords_at(idx)) + } + + /// Get cleo_default_extensions for mode at specific index + pub fn get_cleo_default_extensions_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.cleo_default_extensions.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_cleo_default_extensions(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_cleo_default_extensions_at(idx)) + } + + /// Get mode_variables for mode at specific index + pub fn get_mode_variables_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.variables.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_mode_variables(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_mode_variables_at(idx)) + } + + /// Get labels for mode at specific index + pub fn get_labels_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.labels.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_labels(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_labels_at(idx)) + } + + /// Get arrays for mode at specific index + pub fn get_arrays_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.arrays.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_arrays(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_arrays_at(idx)) + } + + /// Get missions for mode at specific index + pub fn get_missions_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.missions.as_ref()) + .map(|s| self.apply_variables(s)) + } + + pub fn get_missions(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_missions_at(idx)) + } + + /// Get examples for mode at specific index + pub fn get_examples_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.examples.as_ref()) + .map(|elem| AutoUpdateElement { + autoupdate: elem.autoupdate.clone(), + value: self.apply_variables(&elem.value), + }) + } + + pub fn get_examples(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_examples_at(idx)) + } + + /// Get text for mode at specific index + pub fn get_text_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.text.as_ref()) + .map(|elem| TextElement { + format: elem.format.clone(), + value: self.apply_variables(&elem.value), + }) + } + + pub fn get_text(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_text_at(idx)) + } + + /// Get opcodes for mode at specific index + pub fn get_opcodes_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.opcodes + .iter() + .map(|s| self.apply_variables(s)) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_opcodes(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_opcodes_at(idx)) + .unwrap_or_default() + } + + /// Get library for mode at specific index + pub fn get_library_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.library + .iter() + .map(|elem| AutoUpdateElement { + autoupdate: elem.autoupdate.clone(), + value: self.apply_variables(&elem.value), + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_library(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_library_at(idx)) + .unwrap_or_default() + } + + /// Get classes for mode at specific index + pub fn get_classes_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.classes + .iter() + .map(|elem| AutoUpdateElement { + autoupdate: elem.autoupdate.clone(), + value: self.apply_variables(&elem.value), + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_classes(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_classes_at(idx)) + .unwrap_or_default() + } + + /// Get enums for mode at specific index + pub fn get_enums_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.enums + .iter() + .map(|elem| AutoUpdateElement { + autoupdate: elem.autoupdate.clone(), + value: self.apply_variables(&elem.value), + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_enums(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_enums_at(idx)) + .unwrap_or_default() + } + + /// Get ide for mode at specific index + pub fn get_ide_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.ide + .iter() + .map(|elem| IdeElement { + base: elem.base.as_ref().map(|s| self.apply_variables(s)), + value: self.apply_variables(&elem.value), + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_ide(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_ide_at(idx)) + .unwrap_or_default() + } + + /// Get templates for mode at specific index + pub fn get_templates_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.templates + .iter() + .map(|elem| TemplateElement { + r#type: elem.r#type.clone(), + value: self.apply_variables(&elem.value), + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_templates(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_templates_at(idx)) + .unwrap_or_default() + } + + /// Get copy_directory for mode at specific index + pub fn get_copy_directory_at(&self, index: usize) -> Vec { + self.modes + .get(index) + .map(|mode| { + mode.copy_directory + .iter() + .map(|elem| CopyDirectoryElement { + r#type: elem.r#type.clone(), + value: self.apply_variables(&elem.value), + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn get_copy_directory(&self) -> Vec { + self.current_mode_index + .map(|idx| self.get_copy_directory_at(idx)) + .unwrap_or_default() + } + + /// Get index of mode by game (prioritizes modes with type="default") + pub fn get_index_by_game(&self, game: Game) -> Option { + // First, try to find a mode with matching game and type="default" + if let Some(index) = self + .modes + .iter() + .position(|m| m.game == game && m.r#type.as_deref() == Some("default")) + { + return Some(index); + } + + // If no default mode found, fall back to any mode with matching game + self.modes.iter().position(|m| m.game == game) + } + + /// Get index of mode by ID + pub fn get_index_by_id(&self, mode_id: &str) -> Option { + self.modes.iter().position(|m| m.id == mode_id) + } + + /// Get parent mode index for mode at specific index + pub fn get_parent_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.extends.as_ref()) + .and_then(|parent_id| self.get_index_by_id(parent_id)) + } + + pub fn get_parent(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_parent_at(idx)) + } + + /// Check if mode at index is valid (no duplicate IDs) + pub fn is_valid_at(&self, index: usize) -> bool { + if let Some(mode) = self.modes.get(index) { + let count = self.modes.iter().filter(|m| m.id == mode.id).count(); + count == 1 + } else { + false + } + } + + pub fn is_valid(&self) -> bool { + self.current_mode_index + .map(|idx| self.is_valid_at(idx)) + .unwrap_or(false) + } + + /// Check if mode ID contains "SBL" + pub fn is_sbl_at(&self, index: usize) -> bool { + self.modes + .get(index) + .map(|mode| mode.id.to_uppercase().contains("SBL")) + .unwrap_or(false) + } + + pub fn is_sbl(&self) -> bool { + self.current_mode_index + .map(|idx| self.is_sbl_at(idx)) + .unwrap_or(false) + } + + /// Check if current mode has type="default" + pub fn is_default(&self) -> bool { + self.current_mode_index + .and_then(|idx| self.modes.get(idx)) + .and_then(|mode| mode.r#type.as_ref()) + .map(|t| t == "default") + .unwrap_or(false) + } + + /// Get raw game string for mode at specific index + pub fn get_game_raw_at(&self, index: usize) -> Option { + self.modes + .get(index) + .map(|mode| mode.game.to_string()) + } + + pub fn get_game_raw(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_game_raw_at(idx)) + } + + /// Get file name for mode at specific index + pub fn get_file_name_at(&self, index: usize) -> Option { + self.modes + .get(index) + .and_then(|mode| mode.file_name.clone()) + } + + pub fn get_file_name(&self) -> Option { + self.current_mode_index + .and_then(|idx| self.get_file_name_at(idx)) + } + + /// Check if autoupdate is allowed for a specific element type + pub fn is_autoupdate_allowed_for(&self, element: &str, index: usize) -> bool { + if let Some(mode) = self.modes.get(index) { + match element { + "opcodes" => true, // Opcodes don't have autoupdate attribute + "library" => { + // Check first library element's autoupdate attribute + mode.library + .first() + .and_then(|elem| elem.autoupdate.as_ref()) + .map(|v| v.eq_ignore_ascii_case("yes") || v.eq_ignore_ascii_case("true")) + .unwrap_or(true) // Default to true if not specified + } + "classes" => mode + .classes + .first() + .and_then(|elem| elem.autoupdate.as_ref()) + .map(|v| v.eq_ignore_ascii_case("yes") || v.eq_ignore_ascii_case("true")) + .unwrap_or(true), + "enums" => mode + .enums + .first() + .and_then(|elem| elem.autoupdate.as_ref()) + .map(|v| v.eq_ignore_ascii_case("yes") || v.eq_ignore_ascii_case("true")) + .unwrap_or(true), + _ => true, + } + } else { + true + } + } + + /// Convert string game name to Game enum + pub fn get_game_by_name(&self, name: &str) -> Option { + match name.to_lowercase().as_str() { + "gta3" => Some(Game::Gta3), + "vc" => Some(Game::Vc), + "sa" => Some(Game::Sa), + "lcs" => Some(Game::Lcs), + "vcs" => Some(Game::Vcs), + "sa_mobile" => Some(Game::SaMobile), + "vc_mobile" => Some(Game::VcMobile), + _ => None, + } + } + + /// Get first item from a list field (opcodes, library, classes, enums) + pub fn get_first_of(&self, field_name: &str, index: usize) -> Option { + if let Some(mode) = self.modes.get(index) { + match field_name { + "opcodes" => mode.opcodes.first().map(|s| self.apply_variables(s)), + "library" => mode + .library + .first() + .map(|elem| self.apply_variables(&elem.value)), + "classes" => mode + .classes + .first() + .map(|elem| self.apply_variables(&elem.value)), + "enums" => mode + .enums + .first() + .map(|elem| self.apply_variables(&elem.value)), + _ => None, + } + } else { + None + } + } + + /// Load files by wildcard pattern + pub fn load_by_mask(&self, mask: &str) -> Vec { + use glob::glob; + + let processed_mask = self.apply_variables(mask); + let mut files = Vec::new(); + + if let Ok(paths) = glob(&processed_mask) { + for path_result in paths { + if let Ok(path) = path_result { + if path.is_file() { + files.push(path.to_string_lossy().to_string()); + } + } + } + } + + files + } + + /// Get list of items with wildcard expansion support + pub fn get_list_of(&self, field_name: &str, index: usize) -> Vec { + if let Some(mode) = self.modes.get(index) { + let items = match field_name { + "opcodes" => mode.opcodes.clone(), + "library" => mode.library.iter().map(|e| e.value.clone()).collect(), + "classes" => mode.classes.iter().map(|e| e.value.clone()).collect(), + "enums" => mode.enums.iter().map(|e| e.value.clone()).collect(), + _ => Vec::new(), + }; + + let mut result = Vec::new(); + for item in items { + let processed = self.apply_variables(&item); + if processed.contains('*') { + // Expand wildcard + result.extend(self.load_by_mask(&processed)); + } else { + result.push(processed); + } + } + result + } else { + Vec::new() + } + } + + /// Convenience methods for specific list types + pub fn get_list_of_library(&self, index: usize) -> Vec { + self.get_list_of("library", index) + } + + pub fn get_list_of_opcodes(&self, index: usize) -> Vec { + self.get_list_of("opcodes", index) + } + + pub fn get_list_of_classes(&self, index: usize) -> Vec { + self.get_list_of("classes", index) + } + + pub fn get_list_of_enums(&self, index: usize) -> Vec { + self.get_list_of("enums", index) + } + + /// Load all modes from a directory (including 1 level of subdirectories) + /// + /// # Arguments + /// * `directory_path` - Path to the directory to scan + /// + /// # Returns + /// Result containing the number of successfully loaded modes, or an error + pub fn load_from_directory(&mut self, directory_path: &Path) -> Result { + let mut loaded_count = 0; + let mut xml_files = Vec::new(); + + // Scan the root directory + if let Ok(entries) = fs::read_dir(directory_path) { + for entry in entries.flatten() { + let path = entry.path(); + + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("xml") { + xml_files.push(path); + } else if path.is_dir() { + // Scan 1 level of subdirectories + if let Ok(sub_entries) = fs::read_dir(&path) { + for sub_entry in sub_entries.flatten() { + let sub_path = sub_entry.path(); + if sub_path.is_file() + && sub_path.extension().and_then(|s| s.to_str()) == Some("xml") + { + xml_files.push(sub_path); + } + } + } + } + } + } else { + bail!("Failed to read directory: {:?}", directory_path); + } + + // First pass: Load all modes without resolving inheritance + for xml_path in &xml_files { + if let Ok(mode) = self.parse_xml_file(xml_path) { + // Check if mode with this ID already exists + if !self.modes.iter().any(|m| m.id == mode.id) { + self.modes.push(mode); + loaded_count += 1; + } + } + } + + // Second pass: Resolve inheritance for all modes + let mode_ids: Vec = self.modes.iter().map(|m| m.id.clone()).collect(); + let mut processed = HashSet::new(); + for mode_id in mode_ids { + self.resolve_mode_inheritance(&mode_id, &mut processed) + .with_context(|| format!("Failed to resolve inheritance for mode '{}'", mode_id))?; + } + + Ok(loaded_count) + } + + /// Parse a single XML mode file without resolving extends + fn parse_xml_file(&self, xml_path: &Path) -> Result { + let xml_content = fs::read_to_string(xml_path) + .with_context(|| format!("Failed to read XML file: {:?}", xml_path))?; + + let mut mode = from_str::(&xml_content) + .with_context(|| format!("Failed to parse XML from file: {:?}", xml_path))?; + + // Set the file name + mode.file_name = Some(xml_path.to_string_lossy().to_string()); + + // Validate the mode + mode.validate() + .with_context(|| format!("Invalid mode in file: {:?}", xml_path))?; + + Ok(mode) + } + + /// Resolve inheritance for a specific mode + fn resolve_mode_inheritance( + &mut self, + mode_id: &str, + processed: &mut HashSet, + ) -> Result<()> { + // Skip if already processed + if processed.contains(mode_id) { + return Ok(()); + } + + // Check for circular dependencies + let mut inheritance_chain = HashSet::new(); + if let Err(e) = self.check_circular_dependency(mode_id, &mut inheritance_chain) { + // Log the circular dependency but don't fail - just skip inheritance for this mode + eprintln!("Warning: {}", e); + eprintln!("Skipping inheritance resolution for mode '{}'", mode_id); + + // Mark as processed so we don't try again + processed.insert(mode_id.to_string()); + + // Mode is loaded but inheritance is skipped due to circular dependency + + return Ok(()); + } + + // Resolve the inheritance + self.resolve_inheritance_recursive(mode_id, &mut HashSet::new(), processed) + .with_context(|| format!("Failed to resolve inheritance for mode '{}'", mode_id))?; + + Ok(()) + } + + /// Check for circular dependencies in the inheritance chain + fn check_circular_dependency( + &self, + mode_id: &str, + visited: &mut HashSet, + ) -> Result<()> { + if visited.contains(mode_id) { + let chain: Vec = visited.iter().cloned().collect(); + bail!( + "Circular inheritance detected: '{}' is already in the inheritance chain: {:?}", + mode_id, + chain + ); + } + + visited.insert(mode_id.to_string()); + + if let Some(mode) = self.find_mode_by_id(mode_id) { + if let Some(extends_id) = &mode.extends { + // Check if the parent exists + if !self.modes.iter().any(|m| &m.id == extends_id) { + // Parent doesn't exist in our loaded modes, this is okay + // The mode will just use its own values + return Ok(()); + } + + // Recursively check the parent + self.check_circular_dependency(extends_id, visited) + .with_context(|| { + format!( + "Failed checking circular dependency for parent '{}'", + extends_id + ) + })?; + } + } + + visited.remove(mode_id); + Ok(()) + } + + /// Recursively resolve inheritance for a mode + fn resolve_inheritance_recursive( + &mut self, + mode_id: &str, + processing: &mut HashSet, + processed: &mut HashSet, + ) -> Result<()> { + // Skip if already processed + if processed.contains(mode_id) { + return Ok(()); + } + + // Check for circular dependency during processing + if processing.contains(mode_id) { + bail!( + "Circular inheritance detected while processing '{}'", + mode_id + ); + } + + processing.insert(mode_id.to_string()); + + // Get the extends field if it exists + let extends_id = self + .find_mode_by_id(mode_id) + .and_then(|m| m.extends.clone()); + + if let Some(parent_id) = extends_id { + // Check if parent exists in our loaded modes + if self.modes.iter().any(|m| m.id == parent_id) { + // Resolve parent first + self.resolve_inheritance_recursive(&parent_id, processing, processed) + .with_context(|| { + format!( + "Failed to resolve parent '{}' for mode '{}'", + parent_id, mode_id + ) + })?; + + // Now merge parent into child + let parent = self.find_mode_by_id(&parent_id).unwrap().clone(); + if let Some(child) = self.find_mode_by_id_mut(mode_id) { + child.merge_from_parent(&parent); + } + } + // If parent doesn't exist, we just skip inheritance + } + + // Mode processing complete + + processing.remove(mode_id); + processed.insert(mode_id.to_string()); + + Ok(()) + } + + // Clear all loaded modes + pub fn clear(&mut self) { + self.modes.clear(); + self.current_mode_index = None; + } + + /// Load parent modes recursively from the same directory + fn load_parent_modes(&mut self, mode_id: &str, base_path: &Path) -> Result<()> { + let mode = self.find_mode_by_id(mode_id).cloned(); + + if let Some(mode) = mode { + if let Some(extends_id) = mode.extends { + // Skip if parent is already loaded + if self.modes.iter().any(|m| m.id == extends_id) { + return Ok(()); + } + + // Try to find parent in the same directory + let parent_dir = base_path + .parent() + .ok_or_else(|| anyhow!("Cannot get parent directory of {:?}", base_path))?; + + // Try common naming patterns + let possible_files = vec![ + parent_dir.join(format!("{}.xml", extends_id)), + parent_dir.join(format!("{}_mode.xml", extends_id)), + ]; + + for path in possible_files { + if path.exists() { + if let Ok(parent_mode) = self.parse_xml_file(&path) { + if parent_mode.id == extends_id { + let parent_id = parent_mode.id.clone(); + if !self.modes.iter().any(|m| m.id == parent_id) { + self.modes.push(parent_mode); + } + + // Recursively load parent's parents + self.load_parent_modes(&parent_id, &path).with_context(|| { + format!("Failed to load parent modes for '{}'", parent_id) + })?; + return Ok(()); + } + } + } + } + + // If not found by naming convention, scan all XML files in the directory + if let Ok(entries) = fs::read_dir(parent_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("xml") { + if let Ok(parent_mode) = self.parse_xml_file(&path) { + if parent_mode.id == extends_id { + let parent_id = parent_mode.id.clone(); + if !self.modes.iter().any(|m| m.id == parent_id) { + self.modes.push(parent_mode); + } + + // Recursively load parent's parents + self.load_parent_modes(&parent_id, &path)?; + return Ok(()); + } + } + } + } + } + + // Parent not found - this is okay, mode will just use its own values + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + + #[test] + fn test_load_shared_xml_directory() { + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); + + let result = manager.load_from_directory(Path::new("shared_XML")); + assert!( + result.is_ok(), + "Failed to load shared_XML directory: {:?}", + result + ); + + let loaded_count = result.unwrap(); + println!("Loaded {} modes from shared_XML", loaded_count); + assert!(loaded_count > 0, "Should load at least some modes"); + + // Check that sa_sbl mode was loaded + assert!( + manager.set_current_mode_by_id("sa_sbl"), + "sa_sbl mode should be loaded" + ); + assert_eq!(manager.get_id(), Some("sa_sbl".to_string())); + assert_eq!(manager.get_title(), Some("GTA SA (v1.0 - SBL)".to_string())); + assert_eq!(manager.get_game(), Some(Game::Sa)); + + // Set current mode and check placeholder replacement through getters + manager.set_current_mode_by_id("sa_sbl"); + let data = manager.get_data(); + assert!( + data.as_ref().unwrap().contains("C:\\SannyBuilder"), + "Data should contain substituted path" + ); + + // Check that sa_sbl_sf inherits from sa_sbl + // Check that sa_sbl_sf mode was loaded and extends sa_sbl + assert!( + manager.set_current_mode_by_id("sa_sbl_sf"), + "sa_sbl_sf mode should be loaded" + ); + assert_eq!(manager.get_extends(), Some("sa_sbl".to_string())); + + // Set current mode to child and check inheritance through getters + manager.set_current_mode_by_id("sa_sbl_sf"); + let child_data = manager.get_data(); + let child_library = manager.get_library(); + + manager.set_current_mode_by_id("sa_sbl"); + let parent_data = manager.get_data(); + + // Should have inherited fields from parent + assert_eq!(child_data, parent_data); // Inherited from parent + + // Should have its own library entries (not inherited since it defines its own) + assert_eq!(child_library.len(), 2); + assert!(child_library[0].value.contains("sa.json")); + assert!(child_library[1].value.contains("sf.fix.json")); + } + + #[test] + fn test_circular_dependency_detection() { + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\test".to_string()); + manager.register_variable("@game:".to_string(), "D:\\game".to_string()); + + // Load test cases with circular dependencies + let result = manager.load_from_directory(Path::new("test_cases")); + + // The load should succeed, but circular modes won't have inheritance resolved + assert!( + result.is_ok(), + "Should load files even with circular dependencies" + ); + + // Modes with circular dependencies should be loaded but won't have extends resolved + assert!( + manager.set_current_mode_by_id("test_circular_a"), + "Circular mode A should be loaded" + ); + } + + #[test] + fn test_subdirectory_scanning() { + // Create a temporary test structure + let test_dir = Path::new("test_subdir_scan"); + let sub_dir = test_dir.join("subdir"); + + fs::create_dir_all(&sub_dir).ok(); + + // Create test XML files + let root_xml = r#" + + @sb:\data\root\ +"#; + + let sub_xml = r#" + + @sb:\data\sub\ +"#; + + fs::write(test_dir.join("root.xml"), root_xml).unwrap(); + fs::write(sub_dir.join("sub.xml"), sub_xml).unwrap(); + + // Test loading + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\test".to_string()); + manager.register_variable("@game:".to_string(), "D:\\game".to_string()); + let result = manager.load_from_directory(test_dir); + assert!(result.is_ok()); + + let count = result.unwrap(); + assert_eq!(count, 2, "Should load both root and subdirectory XML files"); + + assert!(manager.set_current_mode_by_id("root_mode")); + assert!(manager.set_current_mode_by_id("sub_mode")); + + // Cleanup + fs::remove_dir_all(test_dir).ok(); + } + + #[test] + fn test_missing_parent_handling() { + // Create a mode that extends a non-existent parent + let orphan_xml = r#" + + @sb:\data\orphan\ +"#; + + let test_dir = Path::new("test_orphan"); + fs::create_dir_all(test_dir).ok(); + fs::write(test_dir.join("orphan.xml"), orphan_xml).unwrap(); + + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\test".to_string()); + manager.register_variable("@game:".to_string(), "D:\\game".to_string()); + let result = manager.load_from_directory(test_dir); + + assert!(result.is_ok(), "Should handle missing parent gracefully"); + + assert!( + manager.set_current_mode_by_id("orphan_mode"), + "Orphan mode should be loaded" + ); + + manager.set_current_mode_by_id("orphan_mode"); + assert_eq!( + manager.get_data(), + Some("C:\\test\\data\\orphan\\".to_string()) + ); + + // Cleanup + fs::remove_dir_all(test_dir).ok(); + } + + #[test] + fn test_validate_all_shared_modes() { + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); + + let result = manager.load_from_directory(Path::new("shared_XML")); + assert!(result.is_ok(), "Failed to load shared_XML: {:?}", result); + + let loaded_count = result.unwrap(); + println!("\n=== ModeManager Validation ==="); + println!("Successfully loaded {} modes", loaded_count); + + // Collect all mode IDs first + let mode_count = manager.mode_count(); + let mut mode_ids = Vec::new(); + for i in 0..mode_count { + manager.set_current_mode_by_index(i); + if let (Some(id), Some(title), Some(game)) = + (manager.get_id(), manager.get_title(), manager.get_game()) + { + mode_ids.push((id, title, game)); + } + } + + // Validate each mode + for (id, title, game) in mode_ids { + assert!(!id.is_empty(), "Mode ID should not be empty"); + assert!( + !title.is_empty(), + "Mode title should not be empty for {}", + id + ); + // Game is now an enum, so it always has a valid value + + // Check that placeholders are replaced when accessed through getters + manager.set_current_mode_by_id(&id); + if let Some(data) = manager.get_data() { + assert!( + !data.contains("@sb:"), + "Placeholder @sb: not replaced in mode {} data", + id + ); + assert!( + !data.contains("@game:"), + "Placeholder @game: not replaced in mode {} data", + id + ); + } + + // Check other path fields + if let Some(compiler) = manager.get_compiler() { + assert!( + !compiler.contains("@sb:"), + "Placeholder @sb: not replaced in mode {} compiler", + id + ); + assert!( + !compiler.contains("@game:"), + "Placeholder @game: not replaced in mode {} compiler", + id + ); + } + + println!("✓ {}: {} (game: {})", id, title, game); + } + + println!("\nAll {} modes validated successfully!", loaded_count); + } + + #[test] + fn test_set_current_mode_by_game_prioritizes_default() { + use quick_xml::de::from_str; + + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); + + // Create modes with different type attributes + let mode1_xml = r#" + + @sb:\data\sa_regular\ + + "#; + + let mode2_xml = r#" + + @sb:\data\sa_default\ + + "#; + + let mode3_xml = r#" + + @sb:\data\sa_another\ + + "#; + + // Parse and add modes + let mode1: Mode = from_str(mode1_xml).unwrap(); + let mode2: Mode = from_str(mode2_xml).unwrap(); + let mode3: Mode = from_str(mode3_xml).unwrap(); + + // Add modes in order: regular, default, another + manager.modes.push(mode1); + manager.modes.push(mode2); + manager.modes.push(mode3); + + // Test that set_current_mode_by_game selects the mode with type="default" + assert!(manager.set_current_mode_by_game(Game::Sa)); + assert_eq!(manager.get_id(), Some("sa_default".to_string())); + + // Clear modes and test with no default type + manager.modes.clear(); + manager.current_mode_index = None; + + let mode4_xml = r#" + + @sb:\data\sa_first\ + + "#; + + let mode5_xml = r#" + + @sb:\data\sa_second\ + + "#; + + let mode4: Mode = from_str(mode4_xml).unwrap(); + let mode5: Mode = from_str(mode5_xml).unwrap(); + + manager.modes.push(mode4); + manager.modes.push(mode5); + + // Test that when no default exists, it selects the first matching mode + assert!(manager.set_current_mode_by_game(Game::Sa)); + assert_eq!(manager.get_id(), Some("sa_first".to_string())); + } + + #[test] + fn test_index_based_getters() { + use quick_xml::de::from_str; + + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); + + // Create test modes with different properties + let mode1_xml = r#" + + @sb:\data\test1\ + @sb:\compiler\test1.exe + @sb:\constants\test1.txt + @sb:\keywords\test1.txt + @sb:\opcodes\test1_1.txt + @sb:\opcodes\test1_2.txt + @sb:\library\test1.json + @sb:\classes\test1.json + @sb:\examples\test1\ + @sb:\text\test1.txt + + "#; + + let mode2_xml = r#" + + @sb:\data\test2\ + @sb:\compiler\test2.exe + @sb:\opcodes\test2_1.txt + @sb:\library\test2.json + + "#; + + let mode3_xml = r#" + + @sb:\data\test3\ + @sb:\missions\test3.txt + @sb:\labels\test3.txt + @sb:\arrays\test3.txt + + "#; + + // Parse and add modes + let mode1: Mode = from_str(mode1_xml).unwrap(); + let mode2: Mode = from_str(mode2_xml).unwrap(); + let mode3: Mode = from_str(mode3_xml).unwrap(); + + manager.modes.push(mode1); + manager.modes.push(mode2); + manager.modes.push(mode3); + + // Test simple field getters with index + assert_eq!(manager.get_id_at(0), Some("test_mode_1".to_string())); + assert_eq!(manager.get_id_at(1), Some("test_mode_2".to_string())); + assert_eq!(manager.get_id_at(2), Some("test_mode_3".to_string())); + assert_eq!(manager.get_id_at(3), None); // Out of bounds + + assert_eq!(manager.get_title_at(0), Some("Test Mode 1".to_string())); + assert_eq!(manager.get_title_at(1), Some("Test Mode 2".to_string())); + assert_eq!(manager.get_title_at(2), Some("Test Mode 3".to_string())); + + assert_eq!(manager.get_game_at(0), Some(Game::Sa)); + assert_eq!(manager.get_game_at(1), Some(Game::Vc)); + assert_eq!(manager.get_game_at(2), Some(Game::Gta3)); + + assert_eq!(manager.get_type_at(0), Some("default".to_string())); + assert_eq!(manager.get_type_at(1), None); + assert_eq!(manager.get_type_at(2), None); + + assert_eq!(manager.get_extends_at(0), None); + assert_eq!(manager.get_extends_at(1), Some("test_mode_1".to_string())); + assert_eq!(manager.get_extends_at(2), None); + + // Test path-substituted field getters with index + assert_eq!( + manager.get_data_at(0), + Some("C:\\SannyBuilder\\data\\test1\\".to_string()) + ); + assert_eq!( + manager.get_data_at(1), + Some("C:\\SannyBuilder\\data\\test2\\".to_string()) + ); + assert_eq!( + manager.get_data_at(2), + Some("C:\\SannyBuilder\\data\\test3\\".to_string()) + ); + + assert_eq!( + manager.get_compiler_at(0), + Some("C:\\SannyBuilder\\compiler\\test1.exe".to_string()) + ); + assert_eq!( + manager.get_compiler_at(1), + Some("C:\\SannyBuilder\\compiler\\test2.exe".to_string()) + ); + assert_eq!(manager.get_compiler_at(2), None); + + assert_eq!( + manager.get_constants_at(0), + Some("C:\\SannyBuilder\\constants\\test1.txt".to_string()) + ); + assert_eq!(manager.get_constants_at(1), None); + assert_eq!(manager.get_constants_at(2), None); + + assert_eq!( + manager.get_keywords_at(0), + Some("C:\\SannyBuilder\\keywords\\test1.txt".to_string()) + ); + assert_eq!(manager.get_keywords_at(1), None); + assert_eq!(manager.get_keywords_at(2), None); + + assert_eq!(manager.get_missions_at(0), None); + assert_eq!(manager.get_missions_at(1), None); + assert_eq!( + manager.get_missions_at(2), + Some("C:\\SannyBuilder\\missions\\test3.txt".to_string()) + ); + + assert_eq!(manager.get_labels_at(0), None); + assert_eq!(manager.get_labels_at(1), None); + assert_eq!( + manager.get_labels_at(2), + Some("C:\\SannyBuilder\\labels\\test3.txt".to_string()) + ); + + assert_eq!(manager.get_arrays_at(0), None); + assert_eq!(manager.get_arrays_at(1), None); + assert_eq!( + manager.get_arrays_at(2), + Some("C:\\SannyBuilder\\arrays\\test3.txt".to_string()) + ); + + // Test complex field getters with index + let examples_0 = manager.get_examples_at(0); + assert!(examples_0.is_some()); + let examples = examples_0.unwrap(); + assert_eq!(examples.autoupdate, Some("true".to_string())); + assert_eq!(examples.value, "C:\\SannyBuilder\\examples\\test1\\"); + + assert_eq!(manager.get_examples_at(1), None); + assert_eq!(manager.get_examples_at(2), None); + + let text_0 = manager.get_text_at(0); + assert!(text_0.is_some()); + let text = text_0.unwrap(); + // Note: format is parsed as TextFormat enum, not string + assert!(text.format.is_some()); + assert_eq!(text.value, "C:\\SannyBuilder\\text\\test1.txt"); + + assert_eq!(manager.get_text_at(1), None); + assert_eq!(manager.get_text_at(2), None); + + // Test vector field getters with index + let opcodes_0 = manager.get_opcodes_at(0); + assert_eq!(opcodes_0.len(), 2); + assert_eq!(opcodes_0[0], "C:\\SannyBuilder\\opcodes\\test1_1.txt"); + assert_eq!(opcodes_0[1], "C:\\SannyBuilder\\opcodes\\test1_2.txt"); + + let opcodes_1 = manager.get_opcodes_at(1); + assert_eq!(opcodes_1.len(), 1); + assert_eq!(opcodes_1[0], "C:\\SannyBuilder\\opcodes\\test2_1.txt"); + + let opcodes_2 = manager.get_opcodes_at(2); + assert_eq!(opcodes_2.len(), 0); + + let library_0 = manager.get_library_at(0); + assert_eq!(library_0.len(), 1); + assert_eq!(library_0[0].autoupdate, Some("true".to_string())); + assert_eq!(library_0[0].value, "C:\\SannyBuilder\\library\\test1.json"); + + let library_1 = manager.get_library_at(1); + assert_eq!(library_1.len(), 1); + assert_eq!(library_1[0].autoupdate, Some("false".to_string())); + assert_eq!(library_1[0].value, "C:\\SannyBuilder\\library\\test2.json"); + + let library_2 = manager.get_library_at(2); + assert_eq!(library_2.len(), 0); + + let classes_0 = manager.get_classes_at(0); + assert_eq!(classes_0.len(), 1); + assert_eq!(classes_0[0].autoupdate, Some("false".to_string())); + assert_eq!(classes_0[0].value, "C:\\SannyBuilder\\classes\\test1.json"); + + let classes_1 = manager.get_classes_at(1); + assert_eq!(classes_1.len(), 0); + + let classes_2 = manager.get_classes_at(2); + assert_eq!(classes_2.len(), 0); + + // Test that current mode methods still work and delegate correctly + manager.set_current_mode_by_index(1); + assert_eq!(manager.get_id(), Some("test_mode_2".to_string())); + assert_eq!(manager.get_title(), Some("Test Mode 2".to_string())); + assert_eq!(manager.get_game(), Some(Game::Vc)); + assert_eq!( + manager.get_data(), + Some("C:\\SannyBuilder\\data\\test2\\".to_string()) + ); + + let current_opcodes = manager.get_opcodes(); + assert_eq!(current_opcodes.len(), 1); + assert_eq!(current_opcodes[0], "C:\\SannyBuilder\\opcodes\\test2_1.txt"); + + // Verify delegation by comparing current mode methods with index-based methods + manager.set_current_mode_by_index(0); + assert_eq!(manager.get_id(), manager.get_id_at(0)); + assert_eq!(manager.get_title(), manager.get_title_at(0)); + assert_eq!(manager.get_game(), manager.get_game_at(0)); + assert_eq!(manager.get_data(), manager.get_data_at(0)); + assert_eq!(manager.get_compiler(), manager.get_compiler_at(0)); + assert_eq!(manager.get_opcodes(), manager.get_opcodes_at(0)); + assert_eq!(manager.get_library(), manager.get_library_at(0)); + } + + #[test] + fn test_index_based_getters_edge_cases() { + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\test".to_string()); + + // Test with empty manager + assert_eq!(manager.get_id_at(0), None); + assert_eq!(manager.get_title_at(0), None); + assert_eq!(manager.get_game_at(0), None); + assert_eq!(manager.get_data_at(0), None); + assert_eq!(manager.get_opcodes_at(0), Vec::::new()); + assert_eq!(manager.get_library_at(0), Vec::::new()); + + // Test with out of bounds index + use quick_xml::de::from_str; + let mode_xml = r#" + + @sb:\data\single\ + + "#; + let mode: Mode = from_str(mode_xml).unwrap(); + manager.modes.push(mode); + + // Valid index + assert_eq!(manager.get_id_at(0), Some("single_mode".to_string())); + + // Out of bounds indices + assert_eq!(manager.get_id_at(1), None); + assert_eq!(manager.get_id_at(100), None); + assert_eq!(manager.get_data_at(1), None); + assert_eq!(manager.get_opcodes_at(1), Vec::::new()); + + // Test that current mode methods return None when no current mode is set + assert_eq!(manager.get_id(), None); + assert_eq!(manager.get_title(), None); + assert_eq!(manager.get_data(), None); + assert_eq!(manager.get_opcodes(), Vec::::new()); + + // Set current mode and verify it works + manager.set_current_mode_by_index(0); + assert_eq!(manager.get_id(), Some("single_mode".to_string())); + assert_eq!( + manager.get_data(), + Some("C:\\test\\data\\single\\".to_string()) + ); + } + + #[test] + fn test_new_pascal_methods() { + use quick_xml::de::from_str; + + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + + // Create test modes + let mode1_xml = r#" + + @sb:\data\sa\ + @sb:\library\sa.json + @sb:\classes\sa.json + @sb:\enums\sa.json + + "#; + + let mode2_xml = r#" + + @sb:\data\sa_child\ + + "#; + + let mode3_xml = r#" + + @sb:\data\vc\ + @sb:\opcodes\vc_1.txt + @sb:\opcodes\vc_2.txt + + "#; + + let mut mode1: Mode = from_str(mode1_xml).unwrap(); + let mut mode2: Mode = from_str(mode2_xml).unwrap(); + let mut mode3: Mode = from_str(mode3_xml).unwrap(); + + // Set file names + mode1.file_name = Some("/path/to/sa_sbl.xml".to_string()); + mode2.file_name = Some("/path/to/sa_sbl_child.xml".to_string()); + mode3.file_name = Some("/path/to/vc_normal.xml".to_string()); + + manager.modes.push(mode1); + manager.modes.push(mode2); + manager.modes.push(mode3); + + // Test get_index_by_game + assert_eq!(manager.get_index_by_game(Game::Sa), Some(0)); // Should find default + assert_eq!(manager.get_index_by_game(Game::Vc), Some(2)); + assert_eq!(manager.get_index_by_game(Game::Gta3), None); + + // Test get_index_by_id + assert_eq!(manager.get_index_by_id("sa_sbl"), Some(0)); + assert_eq!(manager.get_index_by_id("sa_sbl_child"), Some(1)); + assert_eq!(manager.get_index_by_id("nonexistent"), None); + + // Test get_parent_at + assert_eq!(manager.get_parent_at(0), None); // No parent + assert_eq!(manager.get_parent_at(1), Some(0)); // Parent is sa_sbl + assert_eq!(manager.get_parent_at(2), None); // No parent + + // Test is_valid_at (no duplicates) + assert!(manager.is_valid_at(0)); + assert!(manager.is_valid_at(1)); + assert!(manager.is_valid_at(2)); + + // Test is_sbl_at + assert!(manager.is_sbl_at(0)); // sa_sbl contains SBL + assert!(manager.is_sbl_at(1)); // sa_sbl_child contains SBL + assert!(!manager.is_sbl_at(2)); // vc_normal doesn't contain SBL + + // Test is_default with current mode + manager.set_current_mode_by_index(0); + assert!(manager.is_default()); // sa_sbl has type="default" + manager.set_current_mode_by_index(1); + assert!(!manager.is_default()); // sa_sbl_child doesn't have type="default" + + // Test get_game_raw_at + assert_eq!(manager.get_game_raw_at(0), Some("sa".to_string())); + assert_eq!(manager.get_game_raw_at(1), Some("sa".to_string())); + assert_eq!(manager.get_game_raw_at(2), Some("vc".to_string())); + + // Test get_file_name_at + assert_eq!( + manager.get_file_name_at(0), + Some("/path/to/sa_sbl.xml".to_string()) + ); + assert_eq!( + manager.get_file_name_at(1), + Some("/path/to/sa_sbl_child.xml".to_string()) + ); + assert_eq!( + manager.get_file_name_at(2), + Some("/path/to/vc_normal.xml".to_string()) + ); + + // Test is_autoupdate_allowed_for + assert!(manager.is_autoupdate_allowed_for("library", 0)); // autoupdate="yes" + assert!(!manager.is_autoupdate_allowed_for("classes", 0)); // autoupdate="no" + assert!(manager.is_autoupdate_allowed_for("enums", 0)); // autoupdate="true" + assert!(manager.is_autoupdate_allowed_for("opcodes", 0)); // Default true + + // Test get_game_by_name + assert_eq!(manager.get_game_by_name("sa"), Some(Game::Sa)); + assert_eq!(manager.get_game_by_name("VC"), Some(Game::Vc)); // Case insensitive + assert_eq!(manager.get_game_by_name("gta3"), Some(Game::Gta3)); + assert_eq!(manager.get_game_by_name("sa_mobile"), Some(Game::SaMobile)); + assert_eq!(manager.get_game_by_name("unknown"), None); + + // Test get_first_of + assert_eq!( + manager.get_first_of("library", 0), + Some("C:\\SannyBuilder\\library\\sa.json".to_string()) + ); + assert_eq!( + manager.get_first_of("opcodes", 2), + Some("C:\\SannyBuilder\\opcodes\\vc_1.txt".to_string()) + ); + assert_eq!(manager.get_first_of("opcodes", 0), None); // No opcodes in sa_sbl + + // Test get_list_of methods + let opcodes_list = manager.get_list_of_opcodes(2); + assert_eq!(opcodes_list.len(), 2); + assert_eq!(opcodes_list[0], "C:\\SannyBuilder\\opcodes\\vc_1.txt"); + assert_eq!(opcodes_list[1], "C:\\SannyBuilder\\opcodes\\vc_2.txt"); + + let library_list = manager.get_list_of_library(0); + assert_eq!(library_list.len(), 1); + assert_eq!(library_list[0], "C:\\SannyBuilder\\library\\sa.json"); + + // Test that current mode methods work with new functionality + manager.set_current_mode_by_index(0); + assert_eq!(manager.get_parent(), None); + assert!(manager.is_valid()); + assert!(manager.is_sbl()); + assert_eq!( + manager.get_file_name(), + Some("/path/to/sa_sbl.xml".to_string()) + ); + assert_eq!(manager.get_game_raw(), Some("sa".to_string())); + } + + #[test] + fn test_index_based_getters_comprehensive_fields() { + use quick_xml::de::from_str; + + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + + // Create a comprehensive test mode with all possible fields + let comprehensive_xml = r#" + + @sb:\data\comprehensive\ + @sb:\compiler\comprehensive.exe + @sb:\constants\comprehensive.txt + @sb:\keywords\comprehensive.txt + @sb:\cleo\comprehensive.txt + @sb:\variables\comprehensive.txt + @sb:\labels\comprehensive.txt + @sb:\arrays\comprehensive.txt + @sb:\missions\comprehensive.txt + @sb:\opcodes\comprehensive_1.txt + @sb:\opcodes\comprehensive_2.txt + @sb:\library\comprehensive_1.json + @sb:\library\comprehensive_2.json + @sb:\classes\comprehensive_1.json + @sb:\classes\comprehensive_2.json + @sb:\enums\comprehensive_1.json + @sb:\enums\comprehensive_2.json + @sb:\ide\comprehensive_1.ide + @sb:\ide\comprehensive_2.ide + @sb:\templates\comprehensive_1.txt + @sb:\templates\comprehensive_2.txt + @sb:\copy\comprehensive_data\ + @sb:\copy\comprehensive_config\ + @sb:\examples\comprehensive\ + @sb:\text\comprehensive.txt + + "#; + + let mode: Mode = from_str(comprehensive_xml).unwrap(); + manager.modes.push(mode); + + // Test all field getters at index 0 + assert_eq!( + manager.get_cleo_default_extensions_at(0), + Some("C:\\SannyBuilder\\cleo\\comprehensive.txt".to_string()) + ); + assert_eq!( + manager.get_mode_variables_at(0), + Some("C:\\SannyBuilder\\variables\\comprehensive.txt".to_string()) + ); + + let enums = manager.get_enums_at(0); + assert_eq!(enums.len(), 2); + assert_eq!(enums[0].autoupdate, Some("true".to_string())); + assert_eq!( + enums[0].value, + "C:\\SannyBuilder\\enums\\comprehensive_1.json" + ); + assert_eq!(enums[1].autoupdate, Some("false".to_string())); + assert_eq!( + enums[1].value, + "C:\\SannyBuilder\\enums\\comprehensive_2.json" + ); + + let ide = manager.get_ide_at(0); + assert_eq!(ide.len(), 2); + assert_eq!( + ide[0].base, + Some("C:\\SannyBuilder\\ide\\base.ide".to_string()) + ); + assert_eq!(ide[0].value, "C:\\SannyBuilder\\ide\\comprehensive_1.ide"); + assert_eq!(ide[1].base, None); + assert_eq!(ide[1].value, "C:\\SannyBuilder\\ide\\comprehensive_2.ide"); + + let templates = manager.get_templates_at(0); + assert_eq!(templates.len(), 2); + assert_eq!(templates[0].r#type, "snippet".to_string()); + assert_eq!( + templates[0].value, + "C:\\SannyBuilder\\templates\\comprehensive_1.txt" + ); + assert_eq!(templates[1].r#type, "function".to_string()); + assert_eq!( + templates[1].value, + "C:\\SannyBuilder\\templates\\comprehensive_2.txt" + ); + + let copy_directory = manager.get_copy_directory_at(0); + assert_eq!(copy_directory.len(), 2); + assert_eq!(copy_directory[0].r#type, "data".to_string()); + assert_eq!( + copy_directory[0].value, + "C:\\SannyBuilder\\copy\\comprehensive_data\\" + ); + assert_eq!(copy_directory[1].r#type, "config".to_string()); + assert_eq!( + copy_directory[1].value, + "C:\\SannyBuilder\\copy\\comprehensive_config\\" + ); + + // Test that all current mode methods delegate correctly + manager.set_current_mode_by_index(0); + assert_eq!( + manager.get_cleo_default_extensions(), + manager.get_cleo_default_extensions_at(0) + ); + assert_eq!( + manager.get_mode_variables(), + manager.get_mode_variables_at(0) + ); + assert_eq!(manager.get_enums(), manager.get_enums_at(0)); + assert_eq!(manager.get_ide(), manager.get_ide_at(0)); + assert_eq!(manager.get_templates(), manager.get_templates_at(0)); + assert_eq!( + manager.get_copy_directory(), + manager.get_copy_directory_at(0) + ); + } + + #[test] + fn test_untested_getter_methods() { + use quick_xml::de::from_str; + use crate::mode::TextFormat; + + let mut manager = ModeManager::new(); + manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + + // Create a comprehensive test mode with all fields + let mode_xml = r#" + + @sb:\data\sa\ + @sb:\constants\sa.txt + cs + @sb:\variables\sa.txt + @sb:\labels\sa.txt + @sb:\arrays\sa.txt + @sb:\missions\sa.txt + @sb:\examples\sa\ + @sb:\text\sa.gxt + @sb:\opcodes\sa_1.txt + @sb:\opcodes\sa_2.txt + @sb:\classes\sa_1.json + @sb:\classes\sa_2.json + @sb:\enums\sa_1.json + @sb:\enums\sa_2.json + + "#; + + let mode: Mode = from_str(mode_xml).unwrap(); + manager.modes.push(mode); + + // Test constants getters + assert_eq!( + manager.get_constants_at(0), + Some("C:\\SannyBuilder\\constants\\sa.txt".to_string()) + ); + manager.set_current_mode_by_index(0); + assert_eq!( + manager.get_constants(), + Some("C:\\SannyBuilder\\constants\\sa.txt".to_string()) + ); + + // Test cleo_default_extensions getters + assert_eq!( + manager.get_cleo_default_extensions_at(0), + Some("cs".to_string()) + ); + assert_eq!( + manager.get_cleo_default_extensions(), + Some("cs".to_string()) + ); + + // Test variables getters + assert_eq!( + manager.get_mode_variables_at(0), + Some("C:\\SannyBuilder\\variables\\sa.txt".to_string()) + ); + assert_eq!( + manager.get_mode_variables(), + Some("C:\\SannyBuilder\\variables\\sa.txt".to_string()) + ); + + // Test labels getters + assert_eq!( + manager.get_labels_at(0), + Some("C:\\SannyBuilder\\labels\\sa.txt".to_string()) + ); + assert_eq!( + manager.get_labels(), + Some("C:\\SannyBuilder\\labels\\sa.txt".to_string()) + ); + + // Test arrays getters + assert_eq!( + manager.get_arrays_at(0), + Some("C:\\SannyBuilder\\arrays\\sa.txt".to_string()) + ); + assert_eq!( + manager.get_arrays(), + Some("C:\\SannyBuilder\\arrays\\sa.txt".to_string()) + ); + + // Test missions getters + assert_eq!( + manager.get_missions_at(0), + Some("C:\\SannyBuilder\\missions\\sa.txt".to_string()) + ); + assert_eq!( + manager.get_missions(), + Some("C:\\SannyBuilder\\missions\\sa.txt".to_string()) + ); + + // Test examples getters + let examples_at = manager.get_examples_at(0); + assert!(examples_at.is_some()); + let examples_at = examples_at.unwrap(); + assert_eq!(examples_at.value, "C:\\SannyBuilder\\examples\\sa\\"); + assert_eq!(examples_at.autoupdate, Some("yes".to_string())); + + let examples = manager.get_examples(); + assert!(examples.is_some()); + let examples = examples.unwrap(); + assert_eq!(examples.value, "C:\\SannyBuilder\\examples\\sa\\"); + assert_eq!(examples.autoupdate, Some("yes".to_string())); + + // Test text getters + let text_at = manager.get_text_at(0); + assert!(text_at.is_some()); + let text_at = text_at.unwrap(); + assert_eq!(text_at.value, "C:\\SannyBuilder\\text\\sa.gxt"); + assert_eq!(text_at.format, Some(TextFormat::Sa)); + + let text = manager.get_text(); + assert!(text.is_some()); + let text = text.unwrap(); + assert_eq!(text.value, "C:\\SannyBuilder\\text\\sa.gxt"); + assert_eq!(text.format, Some(TextFormat::Sa)); + + // Test opcodes getters + let opcodes_at = manager.get_opcodes_at(0); + assert_eq!(opcodes_at.len(), 2); + assert_eq!(opcodes_at[0], "C:\\SannyBuilder\\opcodes\\sa_1.txt"); + assert_eq!(opcodes_at[1], "C:\\SannyBuilder\\opcodes\\sa_2.txt"); + + let opcodes = manager.get_opcodes(); + assert_eq!(opcodes.len(), 2); + assert_eq!(opcodes[0], "C:\\SannyBuilder\\opcodes\\sa_1.txt"); + assert_eq!(opcodes[1], "C:\\SannyBuilder\\opcodes\\sa_2.txt"); + + // Test get_list_of method + let classes_list = manager.get_list_of("classes", 0); + assert_eq!(classes_list.len(), 2); + assert_eq!(classes_list[0], "C:\\SannyBuilder\\classes\\sa_1.json"); + assert_eq!(classes_list[1], "C:\\SannyBuilder\\classes\\sa_2.json"); + + // Test get_list_of_classes + let classes = manager.get_list_of_classes(0); + assert_eq!(classes.len(), 2); + assert_eq!(classes[0], "C:\\SannyBuilder\\classes\\sa_1.json"); + assert_eq!(classes[1], "C:\\SannyBuilder\\classes\\sa_2.json"); + + // Test get_list_of_enums + let enums = manager.get_list_of_enums(0); + assert_eq!(enums.len(), 2); + assert_eq!(enums[0], "C:\\SannyBuilder\\enums\\sa_1.json"); + assert_eq!(enums[1], "C:\\SannyBuilder\\enums\\sa_2.json"); + + // Test edge cases + assert_eq!(manager.get_constants_at(999), None); + assert_eq!(manager.get_list_of("nonexistent", 0).len(), 0); + } + + #[test] + fn test_load_by_mask() { + let manager = ModeManager::new(); + + // Test with non-existent pattern (should return empty list) + let result = manager.load_by_mask("nonexistent*.xml"); + assert_eq!(result.len(), 0); + + // Test with actual files (using the test cases directory) + let result = manager.load_by_mask("test_cases/test_*.xml"); + // The glob may or may not find files depending on working directory + // Just verify it returns a valid vector (no panic) + + // Test with absolute path that doesn't exist + let result = manager.load_by_mask("/nonexistent/path/*.xml"); + assert_eq!(result.len(), 0); + } + + #[test] + fn test_clear_method() { + let mut manager = ModeManager::new(); + manager.register_variable("@test:".to_string(), "value".to_string()); + + // Add a mode + let mode_xml = r#""#; + let mode: Mode = quick_xml::de::from_str(mode_xml).unwrap(); + manager.modes.push(mode); + manager.set_current_mode_by_index(0); + + assert_eq!(manager.modes.len(), 1); + assert!(manager.current_mode_index.is_some()); + assert_eq!(manager.variables.len(), 1); + + // Clear only clears modes and current_mode_index, not variables + manager.clear(); + + assert_eq!(manager.modes.len(), 0); + assert!(manager.current_mode_index.is_none()); + // Variables are NOT cleared by the clear() method + assert_eq!(manager.variables.len(), 1); + } +} diff --git a/src/modes/src/string_variables.rs b/src/modes/src/string_variables.rs new file mode 100644 index 0000000..9165b64 --- /dev/null +++ b/src/modes/src/string_variables.rs @@ -0,0 +1,166 @@ +use std::collections::HashMap; + +/// StringVariables manages placeholder substitution for string values +pub struct StringVariables { + /// Map of placeholder names to their replacement values + variables: HashMap, +} + +impl StringVariables { + /// Create a new StringVariables instance + pub fn new() -> Self { + Self { + variables: HashMap::new(), + } + } + + /// Register a new placeholder variable + /// + /// # Arguments + /// * `name` - The placeholder name (e.g., "@sb:") + /// * `value` - The replacement value (e.g., "C:\\SannyBuilder") + pub fn register(&mut self, name: String, value: String) { + self.variables.insert(name, value); + } + + /// Process a string, replacing all registered placeholders with their values + /// + /// # Arguments + /// * `input` - The string to process + /// + /// # Returns + /// A new string with all placeholders replaced + pub fn process(&self, input: &str) -> String { + let mut result = input.to_string(); + + // Apply all registered replacements + for (placeholder, replacement) in &self.variables { + result = result.replace(placeholder, replacement); + } + + result + } + + /// Get the value of a specific variable + pub fn get(&self, name: &str) -> Option<&String> { + self.variables.get(name) + } + + /// Clear all registered variables + pub fn clear(&mut self) { + self.variables.clear(); + } + + /// Get the number of registered variables + pub fn len(&self) -> usize { + self.variables.len() + } + + /// Check if there are no registered variables + pub fn is_empty(&self) -> bool { + self.variables.is_empty() + } + + /// Get all registered variable names + pub fn get_names(&self) -> Vec { + self.variables.keys().cloned().collect() + } +} + +impl Default for StringVariables { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_substitution() { + let mut vars = StringVariables::new(); + vars.register("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + vars.register("@game:".to_string(), "D:\\Games\\GTA".to_string()); + + let input = "@sb:\\data\\@game:\\models.ide"; + let result = vars.process(input); + + assert_eq!(result, "C:\\SannyBuilder\\data\\D:\\Games\\GTA\\models.ide"); + } + + #[test] + fn test_multiple_same_placeholder() { + let mut vars = StringVariables::new(); + vars.register("@path:".to_string(), "/usr/local".to_string()); + + let input = "@path:/bin:@path:/lib:@path:/share"; + let result = vars.process(input); + + assert_eq!(result, "/usr/local/bin:/usr/local/lib:/usr/local/share"); + } + + #[test] + fn test_no_placeholders() { + let vars = StringVariables::new(); + + let input = "This string has no placeholders"; + let result = vars.process(input); + + assert_eq!(result, input); + } + + #[test] + fn test_unregistered_placeholder() { + let mut vars = StringVariables::new(); + vars.register("@known:".to_string(), "replaced".to_string()); + + let input = "@known: text @unknown: text"; + let result = vars.process(input); + + assert_eq!(result, "replaced text @unknown: text"); + } + + #[test] + fn test_custom_placeholders() { + let mut vars = StringVariables::new(); + vars.register("${user}".to_string(), "john".to_string()); + vars.register("${home}".to_string(), "/home/john".to_string()); + vars.register("{{env}}".to_string(), "production".to_string()); + + let input = "User: ${user}, Home: ${home}, Environment: {{env}}"; + let result = vars.process(input); + + assert_eq!(result, "User: john, Home: /home/john, Environment: production"); + } + + #[test] + fn test_clear_and_helpers() { + let mut vars = StringVariables::new(); + assert!(vars.is_empty()); + assert_eq!(vars.len(), 0); + + vars.register("@test:".to_string(), "value".to_string()); + assert!(!vars.is_empty()); + assert_eq!(vars.len(), 1); + assert_eq!(vars.get("@test:"), Some(&"value".to_string())); + + vars.clear(); + assert!(vars.is_empty()); + assert_eq!(vars.len(), 0); + assert_eq!(vars.get("@test:"), None); + } + + #[test] + fn test_get_names() { + let mut vars = StringVariables::new(); + vars.register("@a:".to_string(), "1".to_string()); + vars.register("@b:".to_string(), "2".to_string()); + vars.register("@c:".to_string(), "3".to_string()); + + let mut names = vars.get_names(); + names.sort(); // Sort for consistent comparison + + assert_eq!(names, vec!["@a:", "@b:", "@c:"]); + } +} \ No newline at end of file diff --git a/src/modes/test_cases/test_base.xml b/src/modes/test_cases/test_base.xml new file mode 100644 index 0000000..bafec72 --- /dev/null +++ b/src/modes/test_cases/test_base.xml @@ -0,0 +1,14 @@ + + + @sb:\data\base\ + @sb:\data\base\library.json + @sb:\data\base\classes.db + @sb:\data\base\enums.txt + @sb:\data\base\constants.txt + @sb:\data\base\keywords.txt + @game:\variables.ini + @game:\text\base.txt + @game:\data\base.dat + @sb:\templates\base.txt + @game:\data\main + \ No newline at end of file diff --git a/src/modes/test_cases/test_child.xml b/src/modes/test_cases/test_child.xml new file mode 100644 index 0000000..c742aa5 --- /dev/null +++ b/src/modes/test_cases/test_child.xml @@ -0,0 +1,12 @@ + + + + @sb:\compiler\child.exe + @sb:\data\child\constants.txt + + @sb:\data\child\extra_classes.db + + @game:\text\child.json + + @sb:\templates\child_custom.txt + \ No newline at end of file diff --git a/src/modes/test_cases/test_circular_a.xml b/src/modes/test_cases/test_circular_a.xml new file mode 100644 index 0000000..290f31c --- /dev/null +++ b/src/modes/test_cases/test_circular_a.xml @@ -0,0 +1,4 @@ + + + @sb:\data\circular_a\ + \ No newline at end of file diff --git a/src/modes/test_cases/test_circular_b.xml b/src/modes/test_cases/test_circular_b.xml new file mode 100644 index 0000000..12dcc40 --- /dev/null +++ b/src/modes/test_cases/test_circular_b.xml @@ -0,0 +1,4 @@ + + + @sb:\data\circular_b\constants.txt + \ No newline at end of file diff --git a/src/modes/test_cases/test_circular_c.xml b/src/modes/test_cases/test_circular_c.xml new file mode 100644 index 0000000..3a9e6e6 --- /dev/null +++ b/src/modes/test_cases/test_circular_c.xml @@ -0,0 +1,4 @@ + + + @sb:\data\circular_c\ + \ No newline at end of file diff --git a/src/modes/test_cases/test_circular_d.xml b/src/modes/test_cases/test_circular_d.xml new file mode 100644 index 0000000..b8d5fd7 --- /dev/null +++ b/src/modes/test_cases/test_circular_d.xml @@ -0,0 +1,4 @@ + + + @sb:\compiler\d.exe + \ No newline at end of file diff --git a/src/modes/test_cases/test_circular_e.xml b/src/modes/test_cases/test_circular_e.xml new file mode 100644 index 0000000..910cadf --- /dev/null +++ b/src/modes/test_cases/test_circular_e.xml @@ -0,0 +1,4 @@ + + + @sb:\data\circular_e\keywords.txt + \ No newline at end of file diff --git a/src/modes/test_cases/test_grandchild.xml b/src/modes/test_cases/test_grandchild.xml new file mode 100644 index 0000000..d1d1bf5 --- /dev/null +++ b/src/modes/test_cases/test_grandchild.xml @@ -0,0 +1,7 @@ + + + + @sb:\data\grandchild\missions.txt + + @sb:\data\grandchild\ + \ No newline at end of file From 1bf953b2f64dea4d81e3b345d522d9bba7cadbb3 Mon Sep 17 00:00:00 2001 From: Seemann Date: Thu, 7 Aug 2025 16:36:21 -0400 Subject: [PATCH 2/5] gxt tool --- src/gxt/Cargo.lock | 298 ++++++++++++ src/gxt/Cargo.toml | 18 + src/gxt/src/lib.rs | 748 +++++++++++++++++++++++++++++ src/gxt/src/main.rs | 104 ++++ src/gxt/tests/integration_tests.rs | 464 ++++++++++++++++++ 5 files changed, 1632 insertions(+) create mode 100644 src/gxt/Cargo.lock create mode 100644 src/gxt/Cargo.toml create mode 100644 src/gxt/src/lib.rs create mode 100644 src/gxt/src/main.rs create mode 100644 src/gxt/tests/integration_tests.rs diff --git a/src/gxt/Cargo.lock b/src/gxt/Cargo.lock new file mode 100644 index 0000000..f63a099 --- /dev/null +++ b/src/gxt/Cargo.lock @@ -0,0 +1,298 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "clap" +version = "4.5.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "gxt-parser" +version = "0.1.0" +dependencies = [ + "anyhow", + "byteorder", + "clap", + "crc32fast", + "encoding_rs", + "hex", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" diff --git a/src/gxt/Cargo.toml b/src/gxt/Cargo.toml new file mode 100644 index 0000000..6bda797 --- /dev/null +++ b/src/gxt/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "gxt-parser" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "gxt" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0" +byteorder = "1.5" +clap = { version = "4.5", features = ["derive"] } +crc32fast = "1.4" +encoding_rs = "0.8" + +[dev-dependencies] +hex = "0.4" diff --git a/src/gxt/src/lib.rs b/src/gxt/src/lib.rs new file mode 100644 index 0000000..0dd765a --- /dev/null +++ b/src/gxt/src/lib.rs @@ -0,0 +1,748 @@ +use anyhow::{bail, Context, Result}; +use byteorder::{LittleEndian, ReadBytesExt}; +use crc32fast::Hasher; +use std::collections::HashMap; +use std::fs::File; +use std::io::{Cursor, Read, Seek, SeekFrom}; +use std::path::Path; + +/// Read a null-terminated UTF-16 LE string from a reader +fn read_wide_string(reader: &mut R) -> Result { + let mut buffer = Vec::new(); + let mut char_buf = [0u8; 2]; + + // Read until we find a null terminator (0x0000) + loop { + reader.read_exact(&mut char_buf)?; + if char_buf[0] == 0 && char_buf[1] == 0 { + break; + } + buffer.push(char_buf[0]); + buffer.push(char_buf[1]); + } + + // Convert UTF-16 LE to String + let utf16_values: Vec = buffer + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + + Ok(String::from_utf16_lossy(&utf16_values)) +} + +/// Common trait for GXT text parsers +pub trait GxtText { + /// Get a text value by its key + fn get(&self, key: &str) -> Option; + + /// Get all keys + fn keys(&self) -> Vec; + + /// Get the number of entries + fn len(&self) -> usize; + + /// Check if empty + fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// Represents a table entry in GXT VC/SA formats +#[derive(Debug, Clone)] +struct TableEntry { + /// 8-byte table name + name: [u8; 8], + /// Offset to the table's TKEY section + offset: u32, +} + +impl TableEntry { + /// Get the table name as a string (null-terminated) + fn name_as_string(&self) -> String { + let end = self.name.iter().position(|&b| b == 0).unwrap_or(8); + String::from_utf8_lossy(&self.name[..end]).to_string() + } +} + +/// Represents different key types used in GXT formats +#[derive(Debug, Clone)] +enum GxtKey { + /// Text key (8 bytes, used in VC format) + Text([u8; 8]), + /// Hash key (32-bit JAMCRC32, used in SA format) + Hash(u32), +} + +impl GxtKey { + /// Get a string representation of the key + fn to_string(&self) -> String { + match self { + GxtKey::Text(bytes) => { + let end = bytes.iter().position(|&b| b == 0).unwrap_or(8); + String::from_utf8_lossy(&bytes[..end]).to_string() + } + GxtKey::Hash(hash) => format!("{:08x}", hash), + } + } + + /// Convert to uppercase string for VC-style lookup + fn to_uppercase_string(&self) -> String { + match self { + GxtKey::Text(_) => self.to_string().to_uppercase(), + GxtKey::Hash(hash) => format!("{:08x}", hash), + } + } +} + +/// Represents a key entry in GXT formats +#[derive(Debug, Clone)] +struct GxtKeyEntry { + /// Offset to the text data + offset: u32, + /// The key (either text or hash) + key: GxtKey, +} + +/// Text encoding for GXT strings +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TextEncoding { + /// UTF-8 encoding + Utf8, + /// UTF-16 LE encoding + Utf16, +} + +/// Configuration for GXT format parsing +#[derive(Debug, Clone, Copy)] +pub struct GxtConfig { + /// Whether keys are hashed (true) or text-based (false) + pub is_hashed: bool, + /// Text encoding for string data + pub encoding: TextEncoding, + /// Whether the format supports multiple tables (true) or single table (false) + pub is_multi_table: bool, + /// Whether the format has an 8-byte header that should be skipped (San Andreas only) + pub has_header: bool, +} + +impl Default for GxtConfig { + fn default() -> Self { + Self { + is_hashed: false, + encoding: TextEncoding::Utf16, + is_multi_table: false, + has_header: false, + } + } +} + +/// Unified GXT parser that handles all format variations +/// +/// # Examples +/// +/// ```no_run +/// use gxt_parser::{GxtParser, GxtConfig, TextEncoding}; +/// +/// // Create a parser with a custom configuration +/// let config = GxtConfig { +/// is_hashed: false, // Text-based keys +/// encoding: TextEncoding::Utf16, // UTF-16 encoding +/// is_multi_table: true, // Multiple tables support +/// has_header: false, // No header to skip +/// }; +/// +/// let mut parser = GxtParser::new(config); +/// +/// // Or use a predefined configuration +/// let mut parser_vc = GxtParser::new(GxtConfig::default()); +/// let mut parser_sa = GxtParser::new(GxtConfig{ +/// encoding: TextEncoding::Utf8, +/// ..Default::default() +/// }); +/// ``` +pub struct GxtParser { + /// Map of keys to their text values (for text-based keys) + entries: HashMap, + /// Map of hash values to text (for hash-based keys) + hash_entries: HashMap, + /// Tables for debugging + tables: HashMap>, + /// Configuration for this parser + config: GxtConfig, +} + +impl GxtParser { + /// Create a new GXT parser with the specified configuration + pub fn new(config: GxtConfig) -> Self { + Self { + entries: HashMap::new(), + hash_entries: HashMap::new(), + tables: HashMap::new(), + config, + } + } + + /// Calculate JAMCRC32 hash for a key string (for SA format) + pub fn calculate_hash(key: &str) -> u32 { + let mut hasher = Hasher::new(); + hasher.update(key.to_uppercase().as_bytes()); + !hasher.finalize() // JAMCRC32 = ~CRC32 + } + + /// Load a GXT file from the given path + pub fn load>(&mut self, path: P) -> Result<()> { + let path = path.as_ref(); + let mut file = File::open(path) + .with_context(|| format!("Failed to open GXT file: {}", path.display()))?; + self.load_from_reader(&mut file) + } + + /// Load GXT data from a reader + pub fn load_from_reader(&mut self, reader: &mut R) -> Result<()> { + self.load_format(reader) + } + + /// Unified method to load GXT data based on config + fn load_format(&mut self, reader: &mut R) -> Result<()> { + let mut section_name = [0u8; 4]; + reader.read_exact(&mut section_name)?; + match §ion_name { + b"TKEY" => { + self.config.is_multi_table = false; + self.config.has_header = false; + self.config.is_hashed = false; + } + b"TABL" => { + self.config.is_multi_table = true; + self.config.has_header = false; + self.config.is_hashed = false; + } + _ => { + self.config.has_header = true; + self.config.is_hashed = true; + self.config.is_multi_table = true; + } + } + reader.rewind()?; + + // Skip header if present (4 bytes) + if self.config.has_header { + reader + .seek(SeekFrom::Start(4)) + .context("Failed to skip header")?; + } + + fn expect_tag(reader: &mut R, tag: &[u8]) -> Result<()> { + let mut section_name = [0u8; 4]; + reader + .read_exact(&mut section_name) + .context("Failed to read section header")?; + if §ion_name != tag { + bail!( + "Expected {}, found: {:?}", + String::from_utf8_lossy(tag), + String::from_utf8_lossy(§ion_name) + ); + } + Ok(()) + } + + // Handle multi-table formats (Vice City and San Andreas) + let table_entries = if self.config.is_multi_table { + // Read TABL section + let tabl_size = { + expect_tag(reader, b"TABL")?; + + // Read section size + let section_size = reader + .read_i32::() + .context("Failed to read TABL section size")?; + + if section_size < 0 { + bail!("Invalid TABL section size: {}", section_size); + } + section_size as u32 + }; + + if tabl_size % 12 != 0 { + bail!("Invalid TABL section size: {}", tabl_size); + } + + let num_tables = tabl_size / 12; + + // Read all table entries + let mut table_entries = Vec::with_capacity(num_tables as usize); + for i in 0..num_tables { + let mut name = [0u8; 8]; + reader + .read_exact(&mut name) + .with_context(|| format!("Failed to read name for table {}", i))?; + let offset = reader + .read_u32::() + .with_context(|| format!("Failed to read offset for table {}", i))?; + + table_entries.push(TableEntry { name, offset }); + } + table_entries + } else { + let mut table_entries = Vec::with_capacity(1); + table_entries.push(TableEntry { + name: [0u8; 8], + offset: 0, + }); + table_entries + }; + + // Process each table + for (table_idx, table) in table_entries.iter().enumerate() { + let table_name = table.name_as_string(); + let mut table_keys = Vec::new(); + + // Seek to the table's data (only for multi-table formats) + if self.config.is_multi_table { + let seek_pos = if table_idx == 0 { + table.offset + } else { + table.offset + 8 + }; + + reader + .seek(SeekFrom::Start(seek_pos as u64)) + .with_context(|| { + format!("Failed to seek to table at offset {}", table.offset) + })?; + } + + // Read TKEY section + expect_tag(reader, b"TKEY")?; + + // Read TKEY section size + let tkey_size = reader + .read_u32::() + .context("Failed to read TKEY section size")?; + + // Validate TKEY size based on format + let key_record_size = if self.config.is_hashed { 8 } else { 12 }; + if tkey_size % key_record_size != 0 { + bail!("Invalid TKEY section size: {}", tkey_size); + } + + let num_keys = tkey_size / key_record_size; + + // Read all key records + let mut keys = Vec::with_capacity(num_keys as usize); + for i in 0..num_keys { + let offset = reader + .read_u32::() + .with_context(|| format!("Failed to read offset for key {}", i))?; + let key_entry = if self.config.is_hashed { + // SA format: 4 bytes offset, 4 bytes hash + let hash = reader + .read_u32::() + .with_context(|| format!("Failed to read hash for key {}", i))?; + + table_keys.push(format!("{:08x}", hash)); + GxtKeyEntry { + offset, + key: GxtKey::Hash(hash), + } + } else { + // VC format: 4 bytes offset, 8 bytes name + let mut name = [0u8; 8]; + reader + .read_exact(&mut name) + .with_context(|| format!("Failed to read name for key {}", i))?; + + table_keys.push(GxtKey::Text(name).to_string()); + GxtKeyEntry { + offset, + key: GxtKey::Text(name), + } + }; + keys.push(key_entry); + } + + // Read TDAT section + expect_tag(reader, b"TDAT")?; + + // Read TDAT section size + let tdat_size = reader + .read_u32::() + .context("Failed to read TDAT section size")?; + + // Process string data + let mut table_strings = vec![0u8; tdat_size as usize]; + reader + .read_exact(&mut table_strings) + .context("Failed to read string data")?; + + // Process each key and extract its string + for key_entry in &keys { + let offset = key_entry.offset as usize; + if offset >= table_strings.len() { + continue; + } + + let text = match self.config.encoding { + TextEncoding::Utf16 => { + // Read UTF-16 string + let mut cursor = Cursor::new(&table_strings[offset..]); + read_wide_string(&mut cursor).ok() + } + TextEncoding::Utf8 => { + // Read UTF-8/ASCII string + let end = table_strings[offset..] + .iter() + .position(|&b| b == 0) + .map(|pos| offset + pos) + .unwrap_or(table_strings.len()); + + String::from_utf8(table_strings[offset..end].to_vec()).ok() + } + }; + + if let Some(text) = text { + if self.config.is_hashed { + if let GxtKey::Hash(hash) = key_entry.key { + self.hash_entries.insert(hash, text); + } + } else { + self.entries + .insert(key_entry.key.to_uppercase_string(), text); + } + } + } + + self.tables.insert(table_name, table_keys); + } + Ok(()) + } + + /// Get a text value by its key + pub fn get(&self, key: &str) -> Option { + if self.config.is_hashed { + // First try to parse as a hex hash value (e.g., "15d4d373") + if let Ok(hash) = u32::from_str_radix(key, 16) { + if let Some(value) = self.hash_entries.get(&hash) { + return Some(value.clone()); + } + } + // Otherwise try as a regular key name (will be hashed) + let hash = Self::calculate_hash(key); + if let Some(value) = self.hash_entries.get(&hash) { + return Some(value.clone()); + } + None + } else { + // Text-based lookup (case-insensitive) + self.entries.get(&key.to_uppercase()).cloned() + } + } + + /// Get a text value by its key string (will be hashed with JAMCRC32 for SA format) + pub fn get_by_key(&self, key: &str) -> Option { + self.get(key) + } + + /// Get all known hashes (for SA format) + pub fn hashes(&self) -> Vec { + if self.config.is_hashed { + self.hash_entries.keys().copied().collect() + } else { + Vec::new() + } + } + + /// Get all keys + pub fn keys(&self) -> Vec { + if self.config.is_hashed { + self.hash_entries + .keys() + .map(|&hash| format!("{:08x}", hash)) + .collect() + } else { + self.entries.keys().cloned().collect() + } + } + + /// Get the number of entries + pub fn len(&self) -> usize { + if self.config.is_hashed { + self.hash_entries.len() + } else { + self.entries.len() + } + } + + /// Check if empty + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Get table names + pub fn table_names(&self) -> Vec { + self.tables.keys().cloned().collect() + } + + /// Get keys for a specific table + pub fn table_keys(&self, table_name: &str) -> Option<&Vec> { + self.tables.get(table_name) + } +} + +impl GxtText for GxtParser { + fn get(&self, key: &str) -> Option { + self.get(key) + } + + fn keys(&self) -> Vec { + self.keys() + } + + fn len(&self) -> usize { + self.len() + } + + fn is_empty(&self) -> bool { + self.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn test_gxt_key_to_string() { + let key = GxtKey::Text([b'T', b'E', b'S', b'T', 0, 0, 0, 0]); + assert_eq!(key.to_string(), "TEST"); + + let key_full = GxtKey::Text([b'F', b'U', b'L', b'L', b'N', b'A', b'M', b'E']); + assert_eq!(key_full.to_string(), "FULLNAME"); + + let hash_key = GxtKey::Hash(0x12345678); + assert_eq!(hash_key.to_string(), "12345678"); + } + + #[test] + fn test_parse_empty_gxt_iii() { + let mut data = Vec::new(); + // TKEY header + data.extend_from_slice(b"TKEY"); + data.extend_from_slice(&0i32.to_le_bytes()); // section size = 0 + // TDAT header + data.extend_from_slice(b"TDAT"); + data.extend_from_slice(&0i32.to_le_bytes()); // section size = 0 + + let mut cursor = Cursor::new(data); + let mut parser = GxtParser::new(GxtConfig::default()); + + assert!(parser.load_from_reader(&mut cursor).is_ok()); + assert_eq!(parser.len(), 0); + assert!(parser.is_empty()); + } + + #[test] + fn test_gxt_iii_with_data() { + let mut data = Vec::new(); + + // TKEY header + data.extend_from_slice(b"TKEY"); + data.extend_from_slice(&12i32.to_le_bytes()); // section size = 12 (1 key) + + // Key record + data.extend_from_slice(&0i32.to_le_bytes()); // offset = 0 + data.extend_from_slice(b"TEST\0\0\0\0"); // key name + + // TDAT header + data.extend_from_slice(b"TDAT"); + data.extend_from_slice(&12i32.to_le_bytes()); // section size (12 bytes for "Hello\0" in UTF-16) + + // Text data (UTF-16 LE "Hello") + data.extend_from_slice(&[b'H', 0, b'e', 0, b'l', 0, b'l', 0, b'o', 0, 0, 0]); + + let mut cursor = Cursor::new(data); + let mut parser = GxtParser::new(GxtConfig::default()); + + assert!(parser.load_from_reader(&mut cursor).is_ok()); + assert_eq!(parser.len(), 1); + + // Test case-insensitive lookup + assert_eq!(parser.get("TEST"), Some("Hello".to_string())); + assert_eq!(parser.get("test"), Some("Hello".to_string())); + assert_eq!(parser.get("Test"), Some("Hello".to_string())); + assert_eq!(parser.get("TeSt"), Some("Hello".to_string())); + } + + #[test] + fn test_invalid_header_iii() { + let data = b"INVALID_HEADER"; + let mut cursor = Cursor::new(data.to_vec()); + let mut parser = GxtParser::new(GxtConfig::default()); + + assert!(parser.load_from_reader(&mut cursor).is_err()); + } + + #[test] + fn test_parse_empty_gxt_vc() { + let mut data = Vec::new(); + // TABL header + data.extend_from_slice(b"TABL"); + data.extend_from_slice(&0i32.to_le_bytes()); // section size = 0 + + let mut cursor = Cursor::new(data); + let mut parser = GxtParser::new(GxtConfig::default()); + + assert!(parser.load_from_reader(&mut cursor).is_ok()); + assert_eq!(parser.len(), 0); + assert!(parser.is_empty()); + } + + #[test] + fn test_invalid_header_vc() { + let data = b"INVALID_HEADER"; + let mut cursor = Cursor::new(data.to_vec()); + let mut parser = GxtParser::new(GxtConfig::default()); + + assert!(parser.load_from_reader(&mut cursor).is_err()); + } + + #[test] + fn test_table_entry_name() { + let entry = TableEntry { + name: [b'M', b'A', b'I', b'N', 0, 0, 0, 0], + offset: 100, + }; + assert_eq!(entry.name_as_string(), "MAIN"); + } + + #[test] + fn test_invalid_section_size() { + let mut data = Vec::new(); + // TKEY header with invalid size + data.extend_from_slice(b"TKEY"); + data.extend_from_slice(&13i32.to_le_bytes()); // Invalid: not divisible by 12 + + let mut cursor = Cursor::new(data); + let mut parser = GxtParser::new(GxtConfig::default()); + + let result = parser.load_from_reader(&mut cursor); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid TKEY section size")); + } + + #[test] + fn test_case_insensitive_vc() { + let mut data = Vec::new(); + + // TABL header + data.extend_from_slice(b"TABL"); + data.extend_from_slice(&12i32.to_le_bytes()); // section size = 12 (1 table) + + // Table entry + data.extend_from_slice(b"MAIN\0\0\0\0"); // table name + data.extend_from_slice(&20u32.to_le_bytes()); // offset to TKEY + + // TKEY header + data.extend_from_slice(b"TKEY"); + data.extend_from_slice(&12u32.to_le_bytes()); // section size = 12 (1 key) + + // Key record + data.extend_from_slice(&0u32.to_le_bytes()); // offset = 0 + data.extend_from_slice(b"MYKEY\0\0\0"); // key name + + // TDAT header + data.extend_from_slice(b"TDAT"); + data.extend_from_slice(&12u32.to_le_bytes()); // section size (12 bytes for "World\0" in UTF-16) + + // Text data (UTF-16 LE "World") + data.extend_from_slice(&[b'W', 0, b'o', 0, b'r', 0, b'l', 0, b'd', 0, 0, 0]); + + let mut cursor = Cursor::new(data); + let mut parser = GxtParser::new(GxtConfig::default()); + + assert!(parser.load_from_reader(&mut cursor).is_ok()); + assert_eq!(parser.len(), 1); + + // Test case-insensitive lookup for VC format + assert_eq!(parser.get("MYKEY"), Some("World".to_string())); + assert_eq!(parser.get("mykey"), Some("World".to_string())); + assert_eq!(parser.get("MyKey"), Some("World".to_string())); + assert_eq!(parser.get("mYkEy"), Some("World".to_string())); + } + + #[test] + fn test_case_insensitive_sa() { + // Test that SA format hashing is case-insensitive + let hash1 = GxtParser::calculate_hash("TEST"); + let hash2 = GxtParser::calculate_hash("test"); + let hash3 = GxtParser::calculate_hash("Test"); + let hash4 = GxtParser::calculate_hash("TeSt"); + + // All hashes should be the same due to uppercasing before hashing + assert_eq!(hash1, hash2); + assert_eq!(hash1, hash3); + assert_eq!(hash1, hash4); + } + + #[test] + fn test_custom_config() { + // Test that we can create custom configurations + let config = GxtConfig { + is_hashed: false, + encoding: TextEncoding::Utf16, + is_multi_table: false, + has_header: false, + }; + + let parser = GxtParser::new(config); + assert!(parser.is_empty()); + + // Test a different custom config + let config2 = GxtConfig { + is_hashed: true, + encoding: TextEncoding::Utf8, + is_multi_table: false, // Single table with hashes (hypothetical format) + has_header: false, + }; + + let parser2 = GxtParser::new(config2); + assert!(parser2.is_empty()); + } + + #[test] + fn test_gxt_trait_case_insensitive() { + // Test that the GxtText trait methods are case-insensitive + let mut data = Vec::new(); + + // TKEY header + data.extend_from_slice(b"TKEY"); + data.extend_from_slice(&12i32.to_le_bytes()); + + // Key record + data.extend_from_slice(&0i32.to_le_bytes()); + data.extend_from_slice(b"HELLO\0\0\0"); + + // TDAT header + data.extend_from_slice(b"TDAT"); + data.extend_from_slice(&12i32.to_le_bytes()); // section size (12 bytes for "Greet\0" in UTF-16) + + // Text data (UTF-16 LE "Greet") + data.extend_from_slice(&[b'G', 0, b'r', 0, b'e', 0, b'e', 0, b't', 0, 0, 0]); + + let mut cursor = Cursor::new(data); + let mut parser = GxtParser::new(GxtConfig::default()); + + assert!(parser.load_from_reader(&mut cursor).is_ok()); + + // Test through the trait interface + let gxt: &dyn GxtText = &parser; + assert_eq!(gxt.get("HELLO"), Some("Greet".to_string())); + assert_eq!(gxt.get("hello"), Some("Greet".to_string())); + assert_eq!(gxt.get("Hello"), Some("Greet".to_string())); + } +} diff --git a/src/gxt/src/main.rs b/src/gxt/src/main.rs new file mode 100644 index 0000000..1b36c22 --- /dev/null +++ b/src/gxt/src/main.rs @@ -0,0 +1,104 @@ +use anyhow::{bail, Result}; +use clap::{Parser, ValueEnum}; +use gxt_parser::{GxtConfig, GxtParser, TextEncoding}; +use std::io::{self, Write}; +use std::path::Path; + +macro_rules! write_stdout { + ($($arg:tt)*) => { + if let Err(e) = writeln!(io::stdout(), $($arg)*) { + eprintln!("Error writing to stdout: {}", e); + std::process::exit(1); + } + }; +} + +/// Text encoding types +#[derive(Debug, Clone, Copy, ValueEnum)] +enum Encoding { + /// UTF-8 encoding + Utf8, + /// UTF-16 LE encoding + Utf16, +} + +/// GXT file parser and viewer +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +#[command(after_help = "EXAMPLES: + gxt american.gxt # List all entries (auto-detect format) + gxt american.gxt --key INTRO # Lookup the INTRO key + gxt american.gxt -k INTRO # Same as above (short form) + gxt mobile.gxt --encoding utf16 # Parse SA mobile file with UTF-16")] +struct Args { + /// Path to the GXT file to parse + gxt_file: String, + + /// Specify the text encoding (defaults: UTF-16 for III/VC, UTF-8 for SA) + #[arg(short, long, value_enum)] + encoding: Option, + + /// Lookup a specific key instead of listing all entries + #[arg(short, long)] + key: Option, +} + +fn main() -> Result<()> { + let args = Args::parse(); + + let file_path = &args.gxt_file; + + if !Path::new(file_path).exists() { + bail!("File '{}' does not exist", file_path); + } + + let encoding = args.encoding.unwrap_or_else(|| Encoding::Utf16); + + // Load the GXT file based on format and encoding + let config = match encoding { + Encoding::Utf16 => GxtConfig { + encoding: TextEncoding::Utf16, + ..Default::default() + }, + Encoding::Utf8 => GxtConfig { + encoding: TextEncoding::Utf8, + ..Default::default() + }, + }; + let mut parser = GxtParser::new(config); + parser.load(file_path)?; + + // Handle key lookup + if let Some(key) = args.key { + // Lookup specific key - only print the value + if let Some(value) = parser.get(&key) { + write_stdout!("{}", value); + } else { + eprintln!("Error: Key '{}' not found in GXT file", key); + std::process::exit(1); + } + } else { + // Display all entries + let encoding_str = match encoding { + Encoding::Utf8 => "UTF-8", + Encoding::Utf16 => "UTF-16", + }; + write_stdout!( + "Successfully loaded GXT file: {} (Encoding: {})", + file_path, + encoding_str + ); + write_stdout!("Total entries: {}", parser.len()); + write_stdout!(); + + let keys = parser.keys(); + + for key in &keys { + if let Some(value) = parser.get(key) { + write_stdout!("{:<10} => {}", key, value); + } + } + } + + Ok(()) +} diff --git a/src/gxt/tests/integration_tests.rs b/src/gxt/tests/integration_tests.rs new file mode 100644 index 0000000..8b23bf6 --- /dev/null +++ b/src/gxt/tests/integration_tests.rs @@ -0,0 +1,464 @@ +use anyhow::Result; +use gxt_parser::{GxtConfig, GxtParser, GxtText, TextEncoding}; +use std::path::Path; + +#[test] +fn test_read_iii_gxt() -> Result<()> { + let path = Path::new("iii.gxt"); + + // Skip test if file doesn't exist + if !path.exists() { + eprintln!("Skipping test: iii.gxt not found"); + return Ok(()); + } + + let mut parser = GxtParser::new(GxtConfig::default()); + parser.load(path)?; + + // Basic sanity checks + assert!(!parser.is_empty(), "iii.gxt should contain entries"); + assert!(parser.len() > 0, "iii.gxt should have at least one entry"); + + // Print some statistics for debugging + println!("iii.gxt loaded successfully:"); + println!(" Total entries: {}", parser.len()); + + // Get a sample of keys + let keys = parser.keys(); + if !keys.is_empty() { + println!(" Sample keys (first 5):"); + for key in keys.iter().take(5) { + println!(" - {}", key); + if let Some(value) = parser.get(key) { + // Truncate long values for display + let display_value = if value.len() > 50 { + format!("{}...", &value[..50]) + } else { + value.clone() + }; + println!(" => {}", display_value); + } + } + } + + Ok(()) +} + +#[test] +fn test_read_vc_gxt() -> Result<()> { + let path = Path::new("vc.gxt"); + + // Skip test if file doesn't exist + if !path.exists() { + eprintln!("Skipping test: vc.gxt not found"); + return Ok(()); + } + + let mut parser = GxtParser::new(GxtConfig::default()); + parser.load(path)?; + + // Basic sanity checks + assert!(!parser.is_empty(), "vc.gxt should contain entries"); + assert!(parser.len() > 0, "vc.gxt should have at least one entry"); + + println!("vc.gxt loaded successfully:"); + println!(" Total entries: {}", parser.len()); + + // Check tables + let tables = parser.table_names(); + assert!(!tables.is_empty(), "vc.gxt should have at least one table"); + println!(" Total tables: {}", tables.len()); + println!(" Table names:"); + for table in &tables { + println!(" - {}", table); + if let Some(table_keys) = parser.table_keys(table) { + println!(" Keys: {}", table_keys.len()); + } + } + + // Get a sample of keys + let keys = parser.keys(); + if !keys.is_empty() { + println!(" Sample keys (first 5):"); + for key in keys.iter().take(5) { + println!(" - {}", key); + if let Some(value) = parser.get(key) { + // Truncate long values for display + let display_value = if value.len() > 50 { + format!("{}...", &value[..50]) + } else { + value.clone() + }; + println!(" => {}", display_value); + } + } + } + + Ok(()) +} + +#[test] +fn test_iii_gxt_specific_keys() -> Result<()> { + let path = Path::new("iii.gxt"); + + if !path.exists() { + eprintln!("Skipping test: iii.gxt not found"); + return Ok(()); + } + + let mut parser = GxtParser::new(GxtConfig::default()); + parser.load(path)?; + + // Test that keys are valid strings (no garbage) + for key in parser.keys() { + assert!(!key.is_empty(), "Key should not be empty"); + assert!(key.chars().all(|c| c.is_ascii() || c.is_alphanumeric()), + "Key '{}' contains invalid characters", key); + } + + // Test that all values are valid strings + for key in parser.keys() { + if let Some(value) = parser.get(&key) { + // Values can be empty but should be valid UTF-16 decoded strings + // No specific assertion needed as invalid UTF-16 would have failed during parsing + let _ = value; // Use the value to avoid unused warning + } + } + + Ok(()) +} + +#[test] +fn test_vc_gxt_table_structure() -> Result<()> { + let path = Path::new("vc.gxt"); + + if !path.exists() { + eprintln!("Skipping test: vc.gxt not found"); + return Ok(()); + } + + let mut parser = GxtParser::new(GxtConfig::default()); + parser.load(path)?; + + // Verify table structure + let tables = parser.table_names(); + for table_name in &tables { + assert!(!table_name.is_empty(), "Table name should not be empty"); + + if let Some(table_keys) = parser.table_keys(table_name) { + // Each key in the table should be retrievable + for key in table_keys { + let value = parser.get(key); + assert!(value.is_some(), + "Key '{}' from table '{}' should have a value", key, table_name); + } + } + } + + Ok(()) +} + +#[test] +fn test_compare_formats() -> Result<()> { + // This test compares the different formats if all files are available + let iii_path = Path::new("iii.gxt"); + let vc_path = Path::new("vc.gxt"); + + if !iii_path.exists() || !vc_path.exists() { + eprintln!("Skipping comparison test: not all files found"); + return Ok(()); + } + + let mut iii_parser = GxtParser::new(GxtConfig::default()); + iii_parser.load(iii_path)?; + + let mut vc_parser = GxtParser::new(GxtConfig::default()); + vc_parser.load(vc_path)?; + + println!("\nFormat comparison:"); + println!(" GTA III format (iii.gxt):"); + println!(" - Entries: {}", iii_parser.len()); + println!(" - Structure: Single TKEY/TDAT section"); + + println!(" Vice City format (vc.gxt):"); + println!(" - Entries: {}", vc_parser.len()); + println!(" - Tables: {}", vc_parser.table_names().len()); + println!(" - Structure: TABL with multiple TKEY/TDAT sections"); + + Ok(()) +} + +#[test] +fn test_gxt_text_trait() -> Result<()> { + // Test that both parsers implement the GxtText trait correctly + let iii_path = Path::new("iii.gxt"); + let vc_path = Path::new("vc.gxt"); + + if iii_path.exists() { + let mut parser = GxtParser::new(GxtConfig::default()); + parser.load(iii_path)?; + test_trait_implementation(&parser)?; + } + + if vc_path.exists() { + let mut parser = GxtParser::new(GxtConfig::default()); + parser.load(vc_path)?; + test_trait_implementation(&parser)?; + } + + Ok(()) +} + +fn test_trait_implementation(parser: &dyn GxtText) -> Result<()> { + // Test trait methods + let len = parser.len(); + let is_empty = parser.is_empty(); + let keys = parser.keys(); + + assert_eq!(is_empty, len == 0, "is_empty should match len == 0"); + assert_eq!(keys.len(), len, "keys().len() should match len()"); + + // Test get method for each key + for key in &keys { + let value = parser.get(key); + assert!(value.is_some(), "Every key should have a value"); + } + + Ok(()) +} + +#[test] +fn test_known_gta_iii_keys() -> Result<()> { + let path = Path::new("iii.gxt"); + + if !path.exists() { + eprintln!("Skipping test: iii.gxt not found"); + return Ok(()); + } + + let mut parser = GxtParser::new(GxtConfig::default()); + parser.load(path)?; + + // Test for some known common GTA III text keys + // These are typically present in GTA III + let common_keys = ["CRED212", "ELBURRO", "JM1_8A", "JM6_E", "FM1_J"]; + + for key in &common_keys { + let value = parser.get(key); + assert!(value.is_some(), "Expected key '{}' to exist in iii.gxt", key); + + if let Some(text) = value { + assert!(!text.is_empty(), "Key '{}' should have non-empty text", key); + println!(" Found key '{}': {}", key, + if text.len() > 60 { format!("{}...", &text[..60]) } else { text.clone() }); + } + } + + Ok(()) +} + +#[test] +fn test_known_vc_tables() -> Result<()> { + let path = Path::new("vc.gxt"); + + if !path.exists() { + eprintln!("Skipping test: vc.gxt not found"); + return Ok(()); + } + + let mut parser = GxtParser::new(GxtConfig::default()); + parser.load(path)?; + + // Test for known Vice City table names + let expected_tables = ["MAIN", "INTRO", "LAWYER1", "HOTEL"]; + + let tables = parser.table_names(); + for expected in &expected_tables { + assert!(tables.contains(&expected.to_string()), + "Expected table '{}' to exist in vc.gxt", expected); + + // Check that each table has keys + if let Some(keys) = parser.table_keys(expected) { + assert!(!keys.is_empty(), "Table '{}' should have keys", expected); + println!(" Table '{}' has {} keys", expected, keys.len()); + } + } + + // MAIN table should be the largest + if let Some(main_keys) = parser.table_keys("MAIN") { + println!(" MAIN table has {} keys (should be the largest)", main_keys.len()); + for table in &tables { + if table != "MAIN" { + if let Some(other_keys) = parser.table_keys(table) { + assert!(main_keys.len() >= other_keys.len(), + "MAIN table should have the most keys"); + } + } + } + } + + Ok(()) +} + +#[test] +fn test_text_content_validation() -> Result<()> { + // Test that text content is properly decoded and doesn't contain invalid UTF-16 + let paths = [ + (Path::new("iii.gxt"), "III"), + (Path::new("vc.gxt"), "VC"), + ]; + + for (path, format) in &paths { + if !path.exists() { + continue; + } + + let parser: Box = match *format { + "III" => { + let mut p = GxtParser::new(GxtConfig::default()); + p.load(path)?; + Box::new(p) + } + "VC" => { + let mut p = GxtParser::new(GxtConfig::default()); + p.load(path)?; + Box::new(p) + } + _ => continue, + }; + + println!("\nValidating text content for {}", path.display()); + + let keys = parser.keys(); + let mut sample_count = 0; + let mut color_code_count = 0; + let mut newline_count = 0; + + for key in keys.iter().take(100) { // Check first 100 entries + if let Some(value) = parser.get(key) { + // Check for common GTA text features + if value.contains("~") { + color_code_count += 1; // Color/formatting codes + } + if value.contains("\n") || value.contains("\r") { + newline_count += 1; // Line breaks + } + + // Ensure text is valid (no null bytes except at end) + assert!(!value.chars().any(|c| c == '\0'), + "Text should not contain null characters: key={}", key); + + sample_count += 1; + } + } + + println!(" Validated {} text entries", sample_count); + println!(" Found {} entries with formatting codes (~)", color_code_count); + println!(" Found {} entries with line breaks", newline_count); + + assert!(sample_count > 0, "Should have validated at least some entries"); + } + + Ok(()) +} + +#[test] +fn test_read_sa_gxt() -> Result<()> { + let path = Path::new("sa.gxt"); + + // Skip test if file doesn't exist + if !path.exists() { + eprintln!("Skipping test: sa.gxt not found"); + return Ok(()); + } + + let mut parser = GxtParser::new(GxtConfig { + encoding: TextEncoding::Utf8, + ..Default::default() + }); + parser.load(path)?; + + // Basic sanity checks + assert!(!parser.is_empty(), "sa.gxt should contain entries"); + assert!(parser.len() > 0, "sa.gxt should have at least one entry"); + + println!("sa.gxt loaded successfully:"); + println!(" Total entries: {}", parser.len()); + + // Check tables + let tables = parser.table_names(); + assert!(!tables.is_empty(), "sa.gxt should have at least one table"); + println!(" Total tables: {}", tables.len()); + println!(" Table names (first 5):"); + for table in tables.iter().take(5) { + println!(" - {}", table); + } + + // Test hash calculation + let test_hash = GxtParser::calculate_hash("MAIN"); + println!(" JAMCRC32 hash of 'MAIN': 0x{:08x}", test_hash); + + Ok(()) +} + +#[test] +fn test_sa_format_crc32() -> Result<()> { + // Test that JAMCRC32 hashing works correctly + let hash1 = GxtParser::calculate_hash("TEST"); + let hash2 = GxtParser::calculate_hash("TEST"); + let hash3 = GxtParser::calculate_hash("TEST2"); + + assert_eq!(hash1, hash2, "Same string should produce same hash"); + assert_ne!(hash1, hash3, "Different strings should produce different hashes"); + + println!("JAMCRC32 hash tests:"); + println!(" 'TEST' => 0x{:08x}", hash1); + println!(" 'TEST2' => 0x{:08x}", hash3); + + Ok(()) +} + +#[test] +fn test_all_formats_comparison() -> Result<()> { + // Compare all three formats if files are available + let iii_path = Path::new("iii.gxt"); + let vc_path = Path::new("vc.gxt"); + let sa_path = Path::new("sa.gxt"); + + if !iii_path.exists() || !vc_path.exists() || !sa_path.exists() { + eprintln!("Skipping comparison test: not all files found"); + return Ok(()); + } + + let mut iii_parser = GxtParser::new(GxtConfig::default()); + iii_parser.load(iii_path)?; + + let mut vc_parser = GxtParser::new(GxtConfig::default()); + vc_parser.load(vc_path)?; + + let mut sa_parser = GxtParser::new(GxtConfig { + encoding: TextEncoding::Utf8, + ..Default::default() + }); + sa_parser.load(sa_path)?; + + println!("\nFormat comparison (all three games):"); + println!(" GTA III format (iii.gxt):"); + println!(" - Entries: {}", iii_parser.len()); + println!(" - Structure: Single TKEY/TDAT section"); + println!(" - Keys: String-based (8 chars max)"); + + println!(" Vice City format (vc.gxt):"); + println!(" - Entries: {}", vc_parser.len()); + println!(" - Tables: {}", vc_parser.table_names().len()); + println!(" - Structure: TABL with multiple TKEY/TDAT sections"); + println!(" - Keys: String-based (8 chars max)"); + + println!(" San Andreas format (sa.gxt):"); + println!(" - Entries: {}", sa_parser.len()); + println!(" - Tables: {}", sa_parser.table_names().len()); + println!(" - Structure: Header + TABL with hash-based keys"); + println!(" - Keys: JAMCRC32 hashes (4 bytes)"); + + Ok(()) +} From 939faf9c35c5891a3c1bbf3364b37b44380edcfa Mon Sep 17 00:00:00 2001 From: Seemann Date: Fri, 8 Aug 2025 11:43:03 -0400 Subject: [PATCH 3/5] update gxt --- src/gxt/src/lib.rs | 370 ++++++++--------------------- src/gxt/src/main.rs | 54 +---- src/gxt/tests/integration_tests.rs | 356 +++++++++++---------------- 3 files changed, 249 insertions(+), 531 deletions(-) diff --git a/src/gxt/src/lib.rs b/src/gxt/src/lib.rs index 0dd765a..dd46450 100644 --- a/src/gxt/src/lib.rs +++ b/src/gxt/src/lib.rs @@ -27,41 +27,7 @@ fn read_wide_string(reader: &mut R) -> Result { .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) .collect(); - Ok(String::from_utf16_lossy(&utf16_values)) -} - -/// Common trait for GXT text parsers -pub trait GxtText { - /// Get a text value by its key - fn get(&self, key: &str) -> Option; - - /// Get all keys - fn keys(&self) -> Vec; - - /// Get the number of entries - fn len(&self) -> usize; - - /// Check if empty - fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -/// Represents a table entry in GXT VC/SA formats -#[derive(Debug, Clone)] -struct TableEntry { - /// 8-byte table name - name: [u8; 8], - /// Offset to the table's TKEY section - offset: u32, -} - -impl TableEntry { - /// Get the table name as a string (null-terminated) - fn name_as_string(&self) -> String { - let end = self.name.iter().position(|&b| b == 0).unwrap_or(8); - String::from_utf8_lossy(&self.name[..end]).to_string() - } + String::from_utf16(&utf16_values).map_err(|e| anyhow::anyhow!("Invalid UTF-16: {}", e)) } /// Represents different key types used in GXT formats @@ -81,15 +47,7 @@ impl GxtKey { let end = bytes.iter().position(|&b| b == 0).unwrap_or(8); String::from_utf8_lossy(&bytes[..end]).to_string() } - GxtKey::Hash(hash) => format!("{:08x}", hash), - } - } - - /// Convert to uppercase string for VC-style lookup - fn to_uppercase_string(&self) -> String { - match self { - GxtKey::Text(_) => self.to_string().to_uppercase(), - GxtKey::Hash(hash) => format!("{:08x}", hash), + GxtKey::Hash(hash) => format!("{:08X}", hash), } } } @@ -112,73 +70,40 @@ pub enum TextEncoding { Utf16, } -/// Configuration for GXT format parsing -#[derive(Debug, Clone, Copy)] -pub struct GxtConfig { - /// Whether keys are hashed (true) or text-based (false) - pub is_hashed: bool, - /// Text encoding for string data - pub encoding: TextEncoding, - /// Whether the format supports multiple tables (true) or single table (false) - pub is_multi_table: bool, - /// Whether the format has an 8-byte header that should be skipped (San Andreas only) - pub has_header: bool, -} - -impl Default for GxtConfig { - fn default() -> Self { - Self { - is_hashed: false, - encoding: TextEncoding::Utf16, - is_multi_table: false, - has_header: false, - } - } -} +// GXT format properties are now part of the parser and auto-detected during load /// Unified GXT parser that handles all format variations /// /// # Examples /// /// ```no_run -/// use gxt_parser::{GxtParser, GxtConfig, TextEncoding}; -/// -/// // Create a parser with a custom configuration -/// let config = GxtConfig { -/// is_hashed: false, // Text-based keys -/// encoding: TextEncoding::Utf16, // UTF-16 encoding -/// is_multi_table: true, // Multiple tables support -/// has_header: false, // No header to skip -/// }; -/// -/// let mut parser = GxtParser::new(config); +/// use gxt_parser::GxtParser; /// -/// // Or use a predefined configuration -/// let mut parser_vc = GxtParser::new(GxtConfig::default()); -/// let mut parser_sa = GxtParser::new(GxtConfig{ -/// encoding: TextEncoding::Utf8, -/// ..Default::default() -/// }); +/// // Create a new parser (format and encoding will be auto-detected on load) +/// let mut parser = GxtParser::new(); /// ``` pub struct GxtParser { /// Map of keys to their text values (for text-based keys) entries: HashMap, /// Map of hash values to text (for hash-based keys) hash_entries: HashMap, - /// Tables for debugging - tables: HashMap>, - /// Configuration for this parser - config: GxtConfig, + /// Whether keys are hashed (true) or text-based (false) + is_hashed: bool, + /// Text encoding for string data + encoding: TextEncoding, + /// Whether the format supports multiple tables (true) or single table (false) + is_multi_table: bool, } impl GxtParser { - /// Create a new GXT parser with the specified configuration - pub fn new(config: GxtConfig) -> Self { + /// Create a new GXT parser (properties will be auto-detected on load) + pub fn new() -> Self { Self { entries: HashMap::new(), hash_entries: HashMap::new(), - tables: HashMap::new(), - config, + encoding: TextEncoding::Utf16, + is_hashed: false, + is_multi_table: false, } } @@ -190,47 +115,43 @@ impl GxtParser { } /// Load a GXT file from the given path - pub fn load>(&mut self, path: P) -> Result<()> { + pub fn load_file>(&mut self, path: P) -> Result<()> { let path = path.as_ref(); let mut file = File::open(path) .with_context(|| format!("Failed to open GXT file: {}", path.display()))?; - self.load_from_reader(&mut file) + self.read(&mut file) } - /// Load GXT data from a reader - pub fn load_from_reader(&mut self, reader: &mut R) -> Result<()> { - self.load_format(reader) - } - - /// Unified method to load GXT data based on config - fn load_format(&mut self, reader: &mut R) -> Result<()> { + fn read(&mut self, reader: &mut R) -> Result<()> { let mut section_name = [0u8; 4]; reader.read_exact(&mut section_name)?; match §ion_name { b"TKEY" => { - self.config.is_multi_table = false; - self.config.has_header = false; - self.config.is_hashed = false; + self.is_multi_table = false; + self.is_hashed = false; + self.encoding = TextEncoding::Utf16; + reader.rewind()?; } b"TABL" => { - self.config.is_multi_table = true; - self.config.has_header = false; - self.config.is_hashed = false; + self.is_multi_table = true; + self.is_hashed = false; + self.encoding = TextEncoding::Utf16; + reader.rewind()?; } _ => { - self.config.has_header = true; - self.config.is_hashed = true; - self.config.is_multi_table = true; + self.is_hashed = true; + self.is_multi_table = true; + reader.seek(SeekFrom::Start(2))?; + + // read bits per character + let encoding = reader.read_u16::()?; + match encoding { + 16 => self.encoding = TextEncoding::Utf16, + 8 => self.encoding = TextEncoding::Utf8, + _ => bail!("Invalid encoding: {}", encoding), + } } } - reader.rewind()?; - - // Skip header if present (4 bytes) - if self.config.has_header { - reader - .seek(SeekFrom::Start(4)) - .context("Failed to skip header")?; - } fn expect_tag(reader: &mut R, tag: &[u8]) -> Result<()> { let mut section_name = [0u8; 4]; @@ -247,13 +168,13 @@ impl GxtParser { Ok(()) } - // Handle multi-table formats (Vice City and San Andreas) - let table_entries = if self.config.is_multi_table { - // Read TABL section + // handle multi-table formats (Vice City and San Andreas) + let table_entries = if self.is_multi_table { + // read TABL section let tabl_size = { expect_tag(reader, b"TABL")?; - // Read section size + // read section size let section_size = reader .read_i32::() .context("Failed to read TABL section size")?; @@ -270,90 +191,71 @@ impl GxtParser { let num_tables = tabl_size / 12; - // Read all table entries - let mut table_entries = Vec::with_capacity(num_tables as usize); + // process each table entry immediately + let mut table_offsets: Vec = Vec::with_capacity(num_tables as usize); for i in 0..num_tables { - let mut name = [0u8; 8]; - reader - .read_exact(&mut name) - .with_context(|| format!("Failed to read name for table {}", i))?; + // skip table name + reader.seek(SeekFrom::Current(8))?; let offset = reader .read_u32::() .with_context(|| format!("Failed to read offset for table {}", i))?; - table_entries.push(TableEntry { name, offset }); + table_offsets.push(offset); } - table_entries + table_offsets } else { - let mut table_entries = Vec::with_capacity(1); - table_entries.push(TableEntry { - name: [0u8; 8], - offset: 0, - }); - table_entries + // single table: offset 0 (no extra header) + vec![0u32] }; - // Process each table - for (table_idx, table) in table_entries.iter().enumerate() { - let table_name = table.name_as_string(); - let mut table_keys = Vec::new(); - - // Seek to the table's data (only for multi-table formats) - if self.config.is_multi_table { - let seek_pos = if table_idx == 0 { - table.offset + // read each table + for (idx, off) in table_entries.iter().enumerate() { + if self.is_multi_table { + let seek_pos = if idx == 0 { + *off as u64 } else { - table.offset + 8 + (*off + 8) as u64 }; - reader - .seek(SeekFrom::Start(seek_pos as u64)) - .with_context(|| { - format!("Failed to seek to table at offset {}", table.offset) - })?; + .seek(SeekFrom::Start(seek_pos)) + .with_context(|| format!("Failed to seek to table at offset {}", off))?; } - // Read TKEY section + // read TKEY section expect_tag(reader, b"TKEY")?; - // Read TKEY section size + // read TKEY section size let tkey_size = reader .read_u32::() .context("Failed to read TKEY section size")?; - // Validate TKEY size based on format - let key_record_size = if self.config.is_hashed { 8 } else { 12 }; + // validate TKEY size + let key_record_size = if self.is_hashed { 8 } else { 12 }; if tkey_size % key_record_size != 0 { bail!("Invalid TKEY section size: {}", tkey_size); } let num_keys = tkey_size / key_record_size; - // Read all key records + // read all key records let mut keys = Vec::with_capacity(num_keys as usize); for i in 0..num_keys { let offset = reader .read_u32::() .with_context(|| format!("Failed to read offset for key {}", i))?; - let key_entry = if self.config.is_hashed { - // SA format: 4 bytes offset, 4 bytes hash + let key_entry = if self.is_hashed { let hash = reader .read_u32::() .with_context(|| format!("Failed to read hash for key {}", i))?; - - table_keys.push(format!("{:08x}", hash)); GxtKeyEntry { offset, key: GxtKey::Hash(hash), } } else { - // VC format: 4 bytes offset, 8 bytes name let mut name = [0u8; 8]; reader .read_exact(&mut name) .with_context(|| format!("Failed to read name for key {}", i))?; - - table_keys.push(GxtKey::Text(name).to_string()); GxtKeyEntry { offset, key: GxtKey::Text(name), @@ -362,35 +264,33 @@ impl GxtParser { keys.push(key_entry); } - // Read TDAT section + // read TDAT section expect_tag(reader, b"TDAT")?; - // Read TDAT section size + // read TDAT section size let tdat_size = reader .read_u32::() .context("Failed to read TDAT section size")?; - // Process string data + // read string data let mut table_strings = vec![0u8; tdat_size as usize]; reader .read_exact(&mut table_strings) .context("Failed to read string data")?; - // Process each key and extract its string + // process each key and extract its string for key_entry in &keys { let offset = key_entry.offset as usize; if offset >= table_strings.len() { continue; } - let text = match self.config.encoding { + let text = match self.encoding { TextEncoding::Utf16 => { - // Read UTF-16 string let mut cursor = Cursor::new(&table_strings[offset..]); read_wide_string(&mut cursor).ok() } TextEncoding::Utf8 => { - // Read UTF-8/ASCII string let end = table_strings[offset..] .iter() .position(|&b| b == 0) @@ -402,63 +302,48 @@ impl GxtParser { }; if let Some(text) = text { - if self.config.is_hashed { + if self.is_hashed { if let GxtKey::Hash(hash) = key_entry.key { self.hash_entries.insert(hash, text); } } else { + // store text keys in uppercase for case-insensitive lookup self.entries - .insert(key_entry.key.to_uppercase_string(), text); + .insert(key_entry.key.to_string().to_uppercase(), text); } } } - - self.tables.insert(table_name, table_keys); } Ok(()) } /// Get a text value by its key pub fn get(&self, key: &str) -> Option { - if self.config.is_hashed { - // First try to parse as a hex hash value (e.g., "15d4d373") + if self.is_hashed { + // try to parse as a hex hash value (e.g., "15d4d373") if let Ok(hash) = u32::from_str_radix(key, 16) { if let Some(value) = self.hash_entries.get(&hash) { return Some(value.clone()); } } - // Otherwise try as a regular key name (will be hashed) + // try as a regular key name (will be hashed) let hash = Self::calculate_hash(key); if let Some(value) = self.hash_entries.get(&hash) { return Some(value.clone()); } None } else { - // Text-based lookup (case-insensitive) + // text-based lookup (case-insensitive) self.entries.get(&key.to_uppercase()).cloned() } } - /// Get a text value by its key string (will be hashed with JAMCRC32 for SA format) - pub fn get_by_key(&self, key: &str) -> Option { - self.get(key) - } - - /// Get all known hashes (for SA format) - pub fn hashes(&self) -> Vec { - if self.config.is_hashed { - self.hash_entries.keys().copied().collect() - } else { - Vec::new() - } - } - /// Get all keys pub fn keys(&self) -> Vec { - if self.config.is_hashed { + if self.is_hashed { self.hash_entries .keys() - .map(|&hash| format!("{:08x}", hash)) + .map(|&hash| format!("{:08X}", hash)) .collect() } else { self.entries.keys().cloned().collect() @@ -467,7 +352,7 @@ impl GxtParser { /// Get the number of entries pub fn len(&self) -> usize { - if self.config.is_hashed { + if self.is_hashed { self.hash_entries.len() } else { self.entries.len() @@ -478,34 +363,6 @@ impl GxtParser { pub fn is_empty(&self) -> bool { self.len() == 0 } - - /// Get table names - pub fn table_names(&self) -> Vec { - self.tables.keys().cloned().collect() - } - - /// Get keys for a specific table - pub fn table_keys(&self, table_name: &str) -> Option<&Vec> { - self.tables.get(table_name) - } -} - -impl GxtText for GxtParser { - fn get(&self, key: &str) -> Option { - self.get(key) - } - - fn keys(&self) -> Vec { - self.keys() - } - - fn len(&self) -> usize { - self.len() - } - - fn is_empty(&self) -> bool { - self.is_empty() - } } #[cfg(test)] @@ -536,9 +393,9 @@ mod tests { data.extend_from_slice(&0i32.to_le_bytes()); // section size = 0 let mut cursor = Cursor::new(data); - let mut parser = GxtParser::new(GxtConfig::default()); + let mut parser = GxtParser::new(); - assert!(parser.load_from_reader(&mut cursor).is_ok()); + assert!(parser.read(&mut cursor).is_ok()); assert_eq!(parser.len(), 0); assert!(parser.is_empty()); } @@ -563,9 +420,9 @@ mod tests { data.extend_from_slice(&[b'H', 0, b'e', 0, b'l', 0, b'l', 0, b'o', 0, 0, 0]); let mut cursor = Cursor::new(data); - let mut parser = GxtParser::new(GxtConfig::default()); + let mut parser = GxtParser::new(); - assert!(parser.load_from_reader(&mut cursor).is_ok()); + assert!(parser.read(&mut cursor).is_ok()); assert_eq!(parser.len(), 1); // Test case-insensitive lookup @@ -579,9 +436,9 @@ mod tests { fn test_invalid_header_iii() { let data = b"INVALID_HEADER"; let mut cursor = Cursor::new(data.to_vec()); - let mut parser = GxtParser::new(GxtConfig::default()); + let mut parser = GxtParser::new(); - assert!(parser.load_from_reader(&mut cursor).is_err()); + assert!(parser.read(&mut cursor).is_err()); } #[test] @@ -592,9 +449,9 @@ mod tests { data.extend_from_slice(&0i32.to_le_bytes()); // section size = 0 let mut cursor = Cursor::new(data); - let mut parser = GxtParser::new(GxtConfig::default()); + let mut parser = GxtParser::new(); - assert!(parser.load_from_reader(&mut cursor).is_ok()); + assert!(parser.read(&mut cursor).is_ok()); assert_eq!(parser.len(), 0); assert!(parser.is_empty()); } @@ -603,18 +460,9 @@ mod tests { fn test_invalid_header_vc() { let data = b"INVALID_HEADER"; let mut cursor = Cursor::new(data.to_vec()); - let mut parser = GxtParser::new(GxtConfig::default()); + let mut parser = GxtParser::new(); - assert!(parser.load_from_reader(&mut cursor).is_err()); - } - - #[test] - fn test_table_entry_name() { - let entry = TableEntry { - name: [b'M', b'A', b'I', b'N', 0, 0, 0, 0], - offset: 100, - }; - assert_eq!(entry.name_as_string(), "MAIN"); + assert!(parser.read(&mut cursor).is_err()); } #[test] @@ -625,9 +473,9 @@ mod tests { data.extend_from_slice(&13i32.to_le_bytes()); // Invalid: not divisible by 12 let mut cursor = Cursor::new(data); - let mut parser = GxtParser::new(GxtConfig::default()); + let mut parser = GxtParser::new(); - let result = parser.load_from_reader(&mut cursor); + let result = parser.read(&mut cursor); assert!(result.is_err()); assert!(result .unwrap_err() @@ -663,9 +511,9 @@ mod tests { data.extend_from_slice(&[b'W', 0, b'o', 0, b'r', 0, b'l', 0, b'd', 0, 0, 0]); let mut cursor = Cursor::new(data); - let mut parser = GxtParser::new(GxtConfig::default()); + let mut parser = GxtParser::new(); - assert!(parser.load_from_reader(&mut cursor).is_ok()); + assert!(parser.read(&mut cursor).is_ok()); assert_eq!(parser.len(), 1); // Test case-insensitive lookup for VC format @@ -690,27 +538,11 @@ mod tests { } #[test] - fn test_custom_config() { - // Test that we can create custom configurations - let config = GxtConfig { - is_hashed: false, - encoding: TextEncoding::Utf16, - is_multi_table: false, - has_header: false, - }; - - let parser = GxtParser::new(config); + fn test_new_parser_is_empty() { + // New parser should start empty before loading + let parser = GxtParser::new(); assert!(parser.is_empty()); - - // Test a different custom config - let config2 = GxtConfig { - is_hashed: true, - encoding: TextEncoding::Utf8, - is_multi_table: false, // Single table with hashes (hypothetical format) - has_header: false, - }; - - let parser2 = GxtParser::new(config2); + let parser2 = GxtParser::new(); assert!(parser2.is_empty()); } @@ -735,12 +567,12 @@ mod tests { data.extend_from_slice(&[b'G', 0, b'r', 0, b'e', 0, b'e', 0, b't', 0, 0, 0]); let mut cursor = Cursor::new(data); - let mut parser = GxtParser::new(GxtConfig::default()); + let mut parser = GxtParser::new(); - assert!(parser.load_from_reader(&mut cursor).is_ok()); + assert!(parser.read(&mut cursor).is_ok()); // Test through the trait interface - let gxt: &dyn GxtText = &parser; + let gxt = &parser; assert_eq!(gxt.get("HELLO"), Some("Greet".to_string())); assert_eq!(gxt.get("hello"), Some("Greet".to_string())); assert_eq!(gxt.get("Hello"), Some("Greet".to_string())); diff --git a/src/gxt/src/main.rs b/src/gxt/src/main.rs index 1b36c22..25890e3 100644 --- a/src/gxt/src/main.rs +++ b/src/gxt/src/main.rs @@ -1,6 +1,6 @@ use anyhow::{bail, Result}; -use clap::{Parser, ValueEnum}; -use gxt_parser::{GxtConfig, GxtParser, TextEncoding}; +use clap::Parser; +use gxt_parser::GxtParser; use std::io::{self, Write}; use std::path::Path; @@ -13,31 +13,17 @@ macro_rules! write_stdout { }; } -/// Text encoding types -#[derive(Debug, Clone, Copy, ValueEnum)] -enum Encoding { - /// UTF-8 encoding - Utf8, - /// UTF-16 LE encoding - Utf16, -} - /// GXT file parser and viewer #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] #[command(after_help = "EXAMPLES: gxt american.gxt # List all entries (auto-detect format) gxt american.gxt --key INTRO # Lookup the INTRO key - gxt american.gxt -k INTRO # Same as above (short form) - gxt mobile.gxt --encoding utf16 # Parse SA mobile file with UTF-16")] + gxt american.gxt -k INTRO # Same as above (short form)")] struct Args { /// Path to the GXT file to parse gxt_file: String, - /// Specify the text encoding (defaults: UTF-16 for III/VC, UTF-8 for SA) - #[arg(short, long, value_enum)] - encoding: Option, - /// Lookup a specific key instead of listing all entries #[arg(short, long)] key: Option, @@ -52,25 +38,13 @@ fn main() -> Result<()> { bail!("File '{}' does not exist", file_path); } - let encoding = args.encoding.unwrap_or_else(|| Encoding::Utf16); - - // Load the GXT file based on format and encoding - let config = match encoding { - Encoding::Utf16 => GxtConfig { - encoding: TextEncoding::Utf16, - ..Default::default() - }, - Encoding::Utf8 => GxtConfig { - encoding: TextEncoding::Utf8, - ..Default::default() - }, - }; - let mut parser = GxtParser::new(config); - parser.load(file_path)?; + // load the GXT file (format and encoding will be auto-detected) + let mut parser = GxtParser::new(); + parser.load_file(file_path)?; - // Handle key lookup + // handle key lookup if let Some(key) = args.key { - // Lookup specific key - only print the value + // lookup specific key - only print the value if let Some(value) = parser.get(&key) { write_stdout!("{}", value); } else { @@ -78,16 +52,8 @@ fn main() -> Result<()> { std::process::exit(1); } } else { - // Display all entries - let encoding_str = match encoding { - Encoding::Utf8 => "UTF-8", - Encoding::Utf16 => "UTF-16", - }; - write_stdout!( - "Successfully loaded GXT file: {} (Encoding: {})", - file_path, - encoding_str - ); + // display all entries + write_stdout!("Successfully loaded GXT file: {}", file_path); write_stdout!("Total entries: {}", parser.len()); write_stdout!(); diff --git a/src/gxt/tests/integration_tests.rs b/src/gxt/tests/integration_tests.rs index 8b23bf6..a547c91 100644 --- a/src/gxt/tests/integration_tests.rs +++ b/src/gxt/tests/integration_tests.rs @@ -1,28 +1,28 @@ use anyhow::Result; -use gxt_parser::{GxtConfig, GxtParser, GxtText, TextEncoding}; +use gxt_parser::GxtParser; use std::path::Path; #[test] fn test_read_iii_gxt() -> Result<()> { let path = Path::new("iii.gxt"); - + // Skip test if file doesn't exist if !path.exists() { eprintln!("Skipping test: iii.gxt not found"); return Ok(()); } - - let mut parser = GxtParser::new(GxtConfig::default()); - parser.load(path)?; - + + let mut parser = GxtParser::new(); + parser.load_file(path)?; + // Basic sanity checks assert!(!parser.is_empty(), "iii.gxt should contain entries"); assert!(parser.len() > 0, "iii.gxt should have at least one entry"); - + // Print some statistics for debugging println!("iii.gxt loaded successfully:"); println!(" Total entries: {}", parser.len()); - + // Get a sample of keys let keys = parser.keys(); if !keys.is_empty() { @@ -40,42 +40,32 @@ fn test_read_iii_gxt() -> Result<()> { } } } - + Ok(()) } #[test] fn test_read_vc_gxt() -> Result<()> { let path = Path::new("vc.gxt"); - + // Skip test if file doesn't exist if !path.exists() { eprintln!("Skipping test: vc.gxt not found"); return Ok(()); } - - let mut parser = GxtParser::new(GxtConfig::default()); - parser.load(path)?; - + + let mut parser = GxtParser::new(); + parser.load_file(path)?; + // Basic sanity checks assert!(!parser.is_empty(), "vc.gxt should contain entries"); assert!(parser.len() > 0, "vc.gxt should have at least one entry"); - + println!("vc.gxt loaded successfully:"); println!(" Total entries: {}", parser.len()); - - // Check tables - let tables = parser.table_names(); - assert!(!tables.is_empty(), "vc.gxt should have at least one table"); - println!(" Total tables: {}", tables.len()); - println!(" Table names:"); - for table in &tables { - println!(" - {}", table); - if let Some(table_keys) = parser.table_keys(table) { - println!(" Keys: {}", table_keys.len()); - } - } - + + // Table inspection no longer supported; just ensure we have entries + // Get a sample of keys let keys = parser.keys(); if !keys.is_empty() { @@ -93,29 +83,32 @@ fn test_read_vc_gxt() -> Result<()> { } } } - + Ok(()) } #[test] fn test_iii_gxt_specific_keys() -> Result<()> { let path = Path::new("iii.gxt"); - + if !path.exists() { eprintln!("Skipping test: iii.gxt not found"); return Ok(()); } - - let mut parser = GxtParser::new(GxtConfig::default()); - parser.load(path)?; - + + let mut parser = GxtParser::new(); + parser.load_file(path)?; + // Test that keys are valid strings (no garbage) for key in parser.keys() { assert!(!key.is_empty(), "Key should not be empty"); - assert!(key.chars().all(|c| c.is_ascii() || c.is_alphanumeric()), - "Key '{}' contains invalid characters", key); + assert!( + key.chars().all(|c| c.is_ascii() || c.is_alphanumeric()), + "Key '{}' contains invalid characters", + key + ); } - + // Test that all values are valid strings for key in parser.keys() { if let Some(value) = parser.get(&key) { @@ -124,37 +117,24 @@ fn test_iii_gxt_specific_keys() -> Result<()> { let _ = value; // Use the value to avoid unused warning } } - + Ok(()) } #[test] fn test_vc_gxt_table_structure() -> Result<()> { let path = Path::new("vc.gxt"); - + if !path.exists() { eprintln!("Skipping test: vc.gxt not found"); return Ok(()); } - - let mut parser = GxtParser::new(GxtConfig::default()); - parser.load(path)?; - - // Verify table structure - let tables = parser.table_names(); - for table_name in &tables { - assert!(!table_name.is_empty(), "Table name should not be empty"); - - if let Some(table_keys) = parser.table_keys(table_name) { - // Each key in the table should be retrievable - for key in table_keys { - let value = parser.get(key); - assert!(value.is_some(), - "Key '{}' from table '{}' should have a value", key, table_name); - } - } - } - + + let mut parser = GxtParser::new(); + parser.load_file(path)?; + + // No per-table inspection; rely on global keys + Ok(()) } @@ -163,178 +143,121 @@ fn test_compare_formats() -> Result<()> { // This test compares the different formats if all files are available let iii_path = Path::new("iii.gxt"); let vc_path = Path::new("vc.gxt"); - + if !iii_path.exists() || !vc_path.exists() { eprintln!("Skipping comparison test: not all files found"); return Ok(()); } - - let mut iii_parser = GxtParser::new(GxtConfig::default()); - iii_parser.load(iii_path)?; - - let mut vc_parser = GxtParser::new(GxtConfig::default()); - vc_parser.load(vc_path)?; - + + let mut iii_parser = GxtParser::new(); + iii_parser.load_file(iii_path)?; + + let mut vc_parser = GxtParser::new(); + vc_parser.load_file(vc_path)?; + println!("\nFormat comparison:"); println!(" GTA III format (iii.gxt):"); println!(" - Entries: {}", iii_parser.len()); println!(" - Structure: Single TKEY/TDAT section"); - + println!(" Vice City format (vc.gxt):"); println!(" - Entries: {}", vc_parser.len()); - println!(" - Tables: {}", vc_parser.table_names().len()); println!(" - Structure: TABL with multiple TKEY/TDAT sections"); - - Ok(()) -} -#[test] -fn test_gxt_text_trait() -> Result<()> { - // Test that both parsers implement the GxtText trait correctly - let iii_path = Path::new("iii.gxt"); - let vc_path = Path::new("vc.gxt"); - - if iii_path.exists() { - let mut parser = GxtParser::new(GxtConfig::default()); - parser.load(iii_path)?; - test_trait_implementation(&parser)?; - } - - if vc_path.exists() { - let mut parser = GxtParser::new(GxtConfig::default()); - parser.load(vc_path)?; - test_trait_implementation(&parser)?; - } - - Ok(()) -} - -fn test_trait_implementation(parser: &dyn GxtText) -> Result<()> { - // Test trait methods - let len = parser.len(); - let is_empty = parser.is_empty(); - let keys = parser.keys(); - - assert_eq!(is_empty, len == 0, "is_empty should match len == 0"); - assert_eq!(keys.len(), len, "keys().len() should match len()"); - - // Test get method for each key - for key in &keys { - let value = parser.get(key); - assert!(value.is_some(), "Every key should have a value"); - } - Ok(()) } #[test] fn test_known_gta_iii_keys() -> Result<()> { let path = Path::new("iii.gxt"); - + if !path.exists() { eprintln!("Skipping test: iii.gxt not found"); return Ok(()); } - - let mut parser = GxtParser::new(GxtConfig::default()); - parser.load(path)?; - + + let mut parser = GxtParser::new(); + parser.load_file(path)?; + // Test for some known common GTA III text keys // These are typically present in GTA III let common_keys = ["CRED212", "ELBURRO", "JM1_8A", "JM6_E", "FM1_J"]; - + for key in &common_keys { let value = parser.get(key); - assert!(value.is_some(), "Expected key '{}' to exist in iii.gxt", key); - + assert!( + value.is_some(), + "Expected key '{}' to exist in iii.gxt", + key + ); + if let Some(text) = value { assert!(!text.is_empty(), "Key '{}' should have non-empty text", key); - println!(" Found key '{}': {}", key, - if text.len() > 60 { format!("{}...", &text[..60]) } else { text.clone() }); + println!( + " Found key '{}': {}", + key, + if text.len() > 60 { + format!("{}...", &text[..60]) + } else { + text.clone() + } + ); } } - + Ok(()) } #[test] fn test_known_vc_tables() -> Result<()> { let path = Path::new("vc.gxt"); - + if !path.exists() { eprintln!("Skipping test: vc.gxt not found"); return Ok(()); } - - let mut parser = GxtParser::new(GxtConfig::default()); - parser.load(path)?; - - // Test for known Vice City table names - let expected_tables = ["MAIN", "INTRO", "LAWYER1", "HOTEL"]; - - let tables = parser.table_names(); - for expected in &expected_tables { - assert!(tables.contains(&expected.to_string()), - "Expected table '{}' to exist in vc.gxt", expected); - - // Check that each table has keys - if let Some(keys) = parser.table_keys(expected) { - assert!(!keys.is_empty(), "Table '{}' should have keys", expected); - println!(" Table '{}' has {} keys", expected, keys.len()); - } - } - - // MAIN table should be the largest - if let Some(main_keys) = parser.table_keys("MAIN") { - println!(" MAIN table has {} keys (should be the largest)", main_keys.len()); - for table in &tables { - if table != "MAIN" { - if let Some(other_keys) = parser.table_keys(table) { - assert!(main_keys.len() >= other_keys.len(), - "MAIN table should have the most keys"); - } - } - } - } - + + let mut parser = GxtParser::new(); + parser.load_file(path)?; + + // No per-table checks anymore + Ok(()) } #[test] fn test_text_content_validation() -> Result<()> { // Test that text content is properly decoded and doesn't contain invalid UTF-16 - let paths = [ - (Path::new("iii.gxt"), "III"), - (Path::new("vc.gxt"), "VC"), - ]; - + let paths = [(Path::new("iii.gxt"), "III"), (Path::new("vc.gxt"), "VC")]; + for (path, format) in &paths { if !path.exists() { continue; } - - let parser: Box = match *format { + + let parser = match *format { "III" => { - let mut p = GxtParser::new(GxtConfig::default()); - p.load(path)?; + let mut p = GxtParser::new(); + p.load_file(path)?; Box::new(p) } "VC" => { - let mut p = GxtParser::new(GxtConfig::default()); - p.load(path)?; + let mut p = GxtParser::new(); + p.load_file(path)?; Box::new(p) } _ => continue, }; - + println!("\nValidating text content for {}", path.display()); - + let keys = parser.keys(); let mut sample_count = 0; let mut color_code_count = 0; let mut newline_count = 0; - - for key in keys.iter().take(100) { // Check first 100 entries + + for key in keys.iter().take(100) { + // Check first 100 entries if let Some(value) = parser.get(key) { // Check for common GTA text features if value.contains("~") { @@ -343,61 +266,60 @@ fn test_text_content_validation() -> Result<()> { if value.contains("\n") || value.contains("\r") { newline_count += 1; // Line breaks } - + // Ensure text is valid (no null bytes except at end) - assert!(!value.chars().any(|c| c == '\0'), - "Text should not contain null characters: key={}", key); - + assert!( + !value.chars().any(|c| c == '\0'), + "Text should not contain null characters: key={}", + key + ); + sample_count += 1; } } - + println!(" Validated {} text entries", sample_count); - println!(" Found {} entries with formatting codes (~)", color_code_count); + println!( + " Found {} entries with formatting codes (~)", + color_code_count + ); println!(" Found {} entries with line breaks", newline_count); - - assert!(sample_count > 0, "Should have validated at least some entries"); + + assert!( + sample_count > 0, + "Should have validated at least some entries" + ); } - + Ok(()) } #[test] fn test_read_sa_gxt() -> Result<()> { let path = Path::new("sa.gxt"); - + // Skip test if file doesn't exist if !path.exists() { eprintln!("Skipping test: sa.gxt not found"); return Ok(()); } - - let mut parser = GxtParser::new(GxtConfig { - encoding: TextEncoding::Utf8, - ..Default::default() - }); - parser.load(path)?; - + + let mut parser = GxtParser::new(); + parser.load_file(path)?; + // Basic sanity checks assert!(!parser.is_empty(), "sa.gxt should contain entries"); assert!(parser.len() > 0, "sa.gxt should have at least one entry"); - + println!("sa.gxt loaded successfully:"); println!(" Total entries: {}", parser.len()); - - // Check tables - let tables = parser.table_names(); - assert!(!tables.is_empty(), "sa.gxt should have at least one table"); - println!(" Total tables: {}", tables.len()); - println!(" Table names (first 5):"); - for table in tables.iter().take(5) { - println!(" - {}", table); - } - + + // No table listing; only entries are validated + // Test hash calculation let test_hash = GxtParser::calculate_hash("MAIN"); println!(" JAMCRC32 hash of 'MAIN': 0x{:08x}", test_hash); - + Ok(()) } @@ -407,14 +329,17 @@ fn test_sa_format_crc32() -> Result<()> { let hash1 = GxtParser::calculate_hash("TEST"); let hash2 = GxtParser::calculate_hash("TEST"); let hash3 = GxtParser::calculate_hash("TEST2"); - + assert_eq!(hash1, hash2, "Same string should produce same hash"); - assert_ne!(hash1, hash3, "Different strings should produce different hashes"); - + assert_ne!( + hash1, hash3, + "Different strings should produce different hashes" + ); + println!("JAMCRC32 hash tests:"); println!(" 'TEST' => 0x{:08x}", hash1); println!(" 'TEST2' => 0x{:08x}", hash3); - + Ok(()) } @@ -424,41 +349,36 @@ fn test_all_formats_comparison() -> Result<()> { let iii_path = Path::new("iii.gxt"); let vc_path = Path::new("vc.gxt"); let sa_path = Path::new("sa.gxt"); - + if !iii_path.exists() || !vc_path.exists() || !sa_path.exists() { eprintln!("Skipping comparison test: not all files found"); return Ok(()); } - - let mut iii_parser = GxtParser::new(GxtConfig::default()); - iii_parser.load(iii_path)?; - - let mut vc_parser = GxtParser::new(GxtConfig::default()); - vc_parser.load(vc_path)?; - - let mut sa_parser = GxtParser::new(GxtConfig { - encoding: TextEncoding::Utf8, - ..Default::default() - }); - sa_parser.load(sa_path)?; - + + let mut iii_parser = GxtParser::new(); + iii_parser.load_file(iii_path)?; + + let mut vc_parser = GxtParser::new(); + vc_parser.load_file(vc_path)?; + + let mut sa_parser = GxtParser::new(); + sa_parser.load_file(sa_path)?; + println!("\nFormat comparison (all three games):"); println!(" GTA III format (iii.gxt):"); println!(" - Entries: {}", iii_parser.len()); println!(" - Structure: Single TKEY/TDAT section"); println!(" - Keys: String-based (8 chars max)"); - + println!(" Vice City format (vc.gxt):"); println!(" - Entries: {}", vc_parser.len()); - println!(" - Tables: {}", vc_parser.table_names().len()); println!(" - Structure: TABL with multiple TKEY/TDAT sections"); println!(" - Keys: String-based (8 chars max)"); - + println!(" San Andreas format (sa.gxt):"); println!(" - Entries: {}", sa_parser.len()); - println!(" - Tables: {}", sa_parser.table_names().len()); println!(" - Structure: Header + TABL with hash-based keys"); println!(" - Keys: JAMCRC32 hashes (4 bytes)"); - + Ok(()) } From c0ebd09e907f6c5deb34d5df91bb6d1ac80a0650 Mon Sep 17 00:00:00 2001 From: Seemann Date: Mon, 11 Aug 2025 15:33:37 -0400 Subject: [PATCH 4/5] gxt ffi --- Cargo.lock | 227 ++++++++++++++++- Cargo.toml | 6 +- src/gxt/Cargo.lock | 298 ---------------------- src/gxt/Cargo.toml | 18 -- src/gxt/ffi.rs | 30 +++ src/gxt/mod.rs | 2 + src/gxt/{src/lib.rs => parser.rs} | 7 - src/gxt/src/main.rs | 70 ------ src/gxt/tests/integration_tests.rs | 384 ----------------------------- src/lib.rs | 2 + 10 files changed, 262 insertions(+), 782 deletions(-) delete mode 100644 src/gxt/Cargo.lock delete mode 100644 src/gxt/Cargo.toml create mode 100644 src/gxt/ffi.rs create mode 100644 src/gxt/mod.rs rename src/gxt/{src/lib.rs => parser.rs} (98%) delete mode 100644 src/gxt/src/main.rs delete mode 100644 src/gxt/tests/integration_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 8e7802b..8460d38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler" @@ -46,6 +46,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + [[package]] name = "anyhow" version = "1.0.81" @@ -209,6 +259,52 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.5.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.60", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "const_format" version = "0.2.32" @@ -252,9 +348,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.3.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if 1.0.0", ] @@ -305,7 +401,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.10.0", "syn 2.0.60", ] @@ -337,6 +433,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if 1.0.0", +] + [[package]] name = "filetime" version = "0.2.21" @@ -445,6 +550,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hmac" version = "0.12.1" @@ -551,6 +662,12 @@ dependencies = [ "libc", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itoa" version = "0.4.7" @@ -846,6 +963,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "password-hash" version = "0.4.2" @@ -962,9 +1085,13 @@ version = "0.4.2" dependencies = [ "anyhow", "base64", + "byteorder", "cached", + "clap", "const_format", + "crc32fast", "ctor", + "encoding_rs", "gta-ide-parser", "hotwatch", "lazy_static", @@ -1088,6 +1215,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.5.0" @@ -1258,6 +1391,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version-compare" version = "0.2.0" @@ -1421,6 +1560,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-sys" version = "0.48.0" @@ -1439,6 +1584,15 @@ dependencies = [ "windows-targets 0.52.4", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.3", +] + [[package]] name = "windows-targets" version = "0.48.0" @@ -1469,6 +1623,23 @@ dependencies = [ "windows_x86_64_msvc 0.52.4", ] +[[package]] +name = "windows-targets" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.0" @@ -1481,6 +1652,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.48.0" @@ -1493,6 +1670,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.48.0" @@ -1505,6 +1688,18 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.48.0" @@ -1517,6 +1712,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.48.0" @@ -1529,6 +1730,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.0" @@ -1541,6 +1748,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.48.0" @@ -1553,6 +1766,12 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "ws2_32-sys" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7943f8e..9d9138a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,4 +34,8 @@ lines_lossy = "0.1.0" zip = { git = "https://github.com/x87/zip.git" } const_format = "0.2.32" cached = "0.50.0" -gta-ide-parser = "0.0.4" \ No newline at end of file +gta-ide-parser = "0.0.4" +byteorder = "1.5" +clap = { version = "4.5", features = ["derive"] } +crc32fast = "1.4" +encoding_rs = "0.8" \ No newline at end of file diff --git a/src/gxt/Cargo.lock b/src/gxt/Cargo.lock deleted file mode 100644 index f63a099..0000000 --- a/src/gxt/Cargo.lock +++ /dev/null @@ -1,298 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anstream" -version = "0.6.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", -] - -[[package]] -name = "anyhow" -version = "1.0.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "cfg-if" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" - -[[package]] -name = "clap" -version = "4.5.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" - -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "gxt-parser" -version = "0.1.0" -dependencies = [ - "anyhow", - "byteorder", - "clap", - "crc32fast", - "encoding_rs", - "hex", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" - -[[package]] -name = "proc-macro2" -version = "1.0.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "syn" -version = "2.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.53.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" diff --git a/src/gxt/Cargo.toml b/src/gxt/Cargo.toml deleted file mode 100644 index 6bda797..0000000 --- a/src/gxt/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "gxt-parser" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "gxt" -path = "src/main.rs" - -[dependencies] -anyhow = "1.0" -byteorder = "1.5" -clap = { version = "4.5", features = ["derive"] } -crc32fast = "1.4" -encoding_rs = "0.8" - -[dev-dependencies] -hex = "0.4" diff --git a/src/gxt/ffi.rs b/src/gxt/ffi.rs new file mode 100644 index 0000000..eb37c77 --- /dev/null +++ b/src/gxt/ffi.rs @@ -0,0 +1,30 @@ +use crate::common_ffi::*; + +#[no_mangle] +pub extern "C" fn gxt_new() -> *mut GxtParser { + ptr_new(GxtParser::new()) +} + +#[no_mangle] +pub unsafe extern "C" fn gxt_free(p: *mut GxtParser) { + ptr_free(p) +} + +#[no_mangle] +pub unsafe extern "C" fn gxt_load_file(p: *mut GxtParser, path: PChar) -> bool { + boolclosure!({ + let path = pchar_to_str(path)?; + (*p).load_file(path)?; + Some(()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn gxt_get(p: *mut GxtParser, key: PChar, out: *mut PChar) -> bool { + boolclosure!({ + let key = pchar_to_str(key)?; + let value = (*p).get(key)?; + *out = CString::new(value).unwrap().into_raw(); + Some(()) + }) +} diff --git a/src/gxt/mod.rs b/src/gxt/mod.rs new file mode 100644 index 0000000..2a99a47 --- /dev/null +++ b/src/gxt/mod.rs @@ -0,0 +1,2 @@ +pub mod parser; +pub mod ffi; \ No newline at end of file diff --git a/src/gxt/src/lib.rs b/src/gxt/parser.rs similarity index 98% rename from src/gxt/src/lib.rs rename to src/gxt/parser.rs index dd46450..3c1a7e9 100644 --- a/src/gxt/src/lib.rs +++ b/src/gxt/parser.rs @@ -107,13 +107,6 @@ impl GxtParser { } } - /// Calculate JAMCRC32 hash for a key string (for SA format) - pub fn calculate_hash(key: &str) -> u32 { - let mut hasher = Hasher::new(); - hasher.update(key.to_uppercase().as_bytes()); - !hasher.finalize() // JAMCRC32 = ~CRC32 - } - /// Load a GXT file from the given path pub fn load_file>(&mut self, path: P) -> Result<()> { let path = path.as_ref(); diff --git a/src/gxt/src/main.rs b/src/gxt/src/main.rs deleted file mode 100644 index 25890e3..0000000 --- a/src/gxt/src/main.rs +++ /dev/null @@ -1,70 +0,0 @@ -use anyhow::{bail, Result}; -use clap::Parser; -use gxt_parser::GxtParser; -use std::io::{self, Write}; -use std::path::Path; - -macro_rules! write_stdout { - ($($arg:tt)*) => { - if let Err(e) = writeln!(io::stdout(), $($arg)*) { - eprintln!("Error writing to stdout: {}", e); - std::process::exit(1); - } - }; -} - -/// GXT file parser and viewer -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -#[command(after_help = "EXAMPLES: - gxt american.gxt # List all entries (auto-detect format) - gxt american.gxt --key INTRO # Lookup the INTRO key - gxt american.gxt -k INTRO # Same as above (short form)")] -struct Args { - /// Path to the GXT file to parse - gxt_file: String, - - /// Lookup a specific key instead of listing all entries - #[arg(short, long)] - key: Option, -} - -fn main() -> Result<()> { - let args = Args::parse(); - - let file_path = &args.gxt_file; - - if !Path::new(file_path).exists() { - bail!("File '{}' does not exist", file_path); - } - - // load the GXT file (format and encoding will be auto-detected) - let mut parser = GxtParser::new(); - parser.load_file(file_path)?; - - // handle key lookup - if let Some(key) = args.key { - // lookup specific key - only print the value - if let Some(value) = parser.get(&key) { - write_stdout!("{}", value); - } else { - eprintln!("Error: Key '{}' not found in GXT file", key); - std::process::exit(1); - } - } else { - // display all entries - write_stdout!("Successfully loaded GXT file: {}", file_path); - write_stdout!("Total entries: {}", parser.len()); - write_stdout!(); - - let keys = parser.keys(); - - for key in &keys { - if let Some(value) = parser.get(key) { - write_stdout!("{:<10} => {}", key, value); - } - } - } - - Ok(()) -} diff --git a/src/gxt/tests/integration_tests.rs b/src/gxt/tests/integration_tests.rs deleted file mode 100644 index a547c91..0000000 --- a/src/gxt/tests/integration_tests.rs +++ /dev/null @@ -1,384 +0,0 @@ -use anyhow::Result; -use gxt_parser::GxtParser; -use std::path::Path; - -#[test] -fn test_read_iii_gxt() -> Result<()> { - let path = Path::new("iii.gxt"); - - // Skip test if file doesn't exist - if !path.exists() { - eprintln!("Skipping test: iii.gxt not found"); - return Ok(()); - } - - let mut parser = GxtParser::new(); - parser.load_file(path)?; - - // Basic sanity checks - assert!(!parser.is_empty(), "iii.gxt should contain entries"); - assert!(parser.len() > 0, "iii.gxt should have at least one entry"); - - // Print some statistics for debugging - println!("iii.gxt loaded successfully:"); - println!(" Total entries: {}", parser.len()); - - // Get a sample of keys - let keys = parser.keys(); - if !keys.is_empty() { - println!(" Sample keys (first 5):"); - for key in keys.iter().take(5) { - println!(" - {}", key); - if let Some(value) = parser.get(key) { - // Truncate long values for display - let display_value = if value.len() > 50 { - format!("{}...", &value[..50]) - } else { - value.clone() - }; - println!(" => {}", display_value); - } - } - } - - Ok(()) -} - -#[test] -fn test_read_vc_gxt() -> Result<()> { - let path = Path::new("vc.gxt"); - - // Skip test if file doesn't exist - if !path.exists() { - eprintln!("Skipping test: vc.gxt not found"); - return Ok(()); - } - - let mut parser = GxtParser::new(); - parser.load_file(path)?; - - // Basic sanity checks - assert!(!parser.is_empty(), "vc.gxt should contain entries"); - assert!(parser.len() > 0, "vc.gxt should have at least one entry"); - - println!("vc.gxt loaded successfully:"); - println!(" Total entries: {}", parser.len()); - - // Table inspection no longer supported; just ensure we have entries - - // Get a sample of keys - let keys = parser.keys(); - if !keys.is_empty() { - println!(" Sample keys (first 5):"); - for key in keys.iter().take(5) { - println!(" - {}", key); - if let Some(value) = parser.get(key) { - // Truncate long values for display - let display_value = if value.len() > 50 { - format!("{}...", &value[..50]) - } else { - value.clone() - }; - println!(" => {}", display_value); - } - } - } - - Ok(()) -} - -#[test] -fn test_iii_gxt_specific_keys() -> Result<()> { - let path = Path::new("iii.gxt"); - - if !path.exists() { - eprintln!("Skipping test: iii.gxt not found"); - return Ok(()); - } - - let mut parser = GxtParser::new(); - parser.load_file(path)?; - - // Test that keys are valid strings (no garbage) - for key in parser.keys() { - assert!(!key.is_empty(), "Key should not be empty"); - assert!( - key.chars().all(|c| c.is_ascii() || c.is_alphanumeric()), - "Key '{}' contains invalid characters", - key - ); - } - - // Test that all values are valid strings - for key in parser.keys() { - if let Some(value) = parser.get(&key) { - // Values can be empty but should be valid UTF-16 decoded strings - // No specific assertion needed as invalid UTF-16 would have failed during parsing - let _ = value; // Use the value to avoid unused warning - } - } - - Ok(()) -} - -#[test] -fn test_vc_gxt_table_structure() -> Result<()> { - let path = Path::new("vc.gxt"); - - if !path.exists() { - eprintln!("Skipping test: vc.gxt not found"); - return Ok(()); - } - - let mut parser = GxtParser::new(); - parser.load_file(path)?; - - // No per-table inspection; rely on global keys - - Ok(()) -} - -#[test] -fn test_compare_formats() -> Result<()> { - // This test compares the different formats if all files are available - let iii_path = Path::new("iii.gxt"); - let vc_path = Path::new("vc.gxt"); - - if !iii_path.exists() || !vc_path.exists() { - eprintln!("Skipping comparison test: not all files found"); - return Ok(()); - } - - let mut iii_parser = GxtParser::new(); - iii_parser.load_file(iii_path)?; - - let mut vc_parser = GxtParser::new(); - vc_parser.load_file(vc_path)?; - - println!("\nFormat comparison:"); - println!(" GTA III format (iii.gxt):"); - println!(" - Entries: {}", iii_parser.len()); - println!(" - Structure: Single TKEY/TDAT section"); - - println!(" Vice City format (vc.gxt):"); - println!(" - Entries: {}", vc_parser.len()); - println!(" - Structure: TABL with multiple TKEY/TDAT sections"); - - Ok(()) -} - -#[test] -fn test_known_gta_iii_keys() -> Result<()> { - let path = Path::new("iii.gxt"); - - if !path.exists() { - eprintln!("Skipping test: iii.gxt not found"); - return Ok(()); - } - - let mut parser = GxtParser::new(); - parser.load_file(path)?; - - // Test for some known common GTA III text keys - // These are typically present in GTA III - let common_keys = ["CRED212", "ELBURRO", "JM1_8A", "JM6_E", "FM1_J"]; - - for key in &common_keys { - let value = parser.get(key); - assert!( - value.is_some(), - "Expected key '{}' to exist in iii.gxt", - key - ); - - if let Some(text) = value { - assert!(!text.is_empty(), "Key '{}' should have non-empty text", key); - println!( - " Found key '{}': {}", - key, - if text.len() > 60 { - format!("{}...", &text[..60]) - } else { - text.clone() - } - ); - } - } - - Ok(()) -} - -#[test] -fn test_known_vc_tables() -> Result<()> { - let path = Path::new("vc.gxt"); - - if !path.exists() { - eprintln!("Skipping test: vc.gxt not found"); - return Ok(()); - } - - let mut parser = GxtParser::new(); - parser.load_file(path)?; - - // No per-table checks anymore - - Ok(()) -} - -#[test] -fn test_text_content_validation() -> Result<()> { - // Test that text content is properly decoded and doesn't contain invalid UTF-16 - let paths = [(Path::new("iii.gxt"), "III"), (Path::new("vc.gxt"), "VC")]; - - for (path, format) in &paths { - if !path.exists() { - continue; - } - - let parser = match *format { - "III" => { - let mut p = GxtParser::new(); - p.load_file(path)?; - Box::new(p) - } - "VC" => { - let mut p = GxtParser::new(); - p.load_file(path)?; - Box::new(p) - } - _ => continue, - }; - - println!("\nValidating text content for {}", path.display()); - - let keys = parser.keys(); - let mut sample_count = 0; - let mut color_code_count = 0; - let mut newline_count = 0; - - for key in keys.iter().take(100) { - // Check first 100 entries - if let Some(value) = parser.get(key) { - // Check for common GTA text features - if value.contains("~") { - color_code_count += 1; // Color/formatting codes - } - if value.contains("\n") || value.contains("\r") { - newline_count += 1; // Line breaks - } - - // Ensure text is valid (no null bytes except at end) - assert!( - !value.chars().any(|c| c == '\0'), - "Text should not contain null characters: key={}", - key - ); - - sample_count += 1; - } - } - - println!(" Validated {} text entries", sample_count); - println!( - " Found {} entries with formatting codes (~)", - color_code_count - ); - println!(" Found {} entries with line breaks", newline_count); - - assert!( - sample_count > 0, - "Should have validated at least some entries" - ); - } - - Ok(()) -} - -#[test] -fn test_read_sa_gxt() -> Result<()> { - let path = Path::new("sa.gxt"); - - // Skip test if file doesn't exist - if !path.exists() { - eprintln!("Skipping test: sa.gxt not found"); - return Ok(()); - } - - let mut parser = GxtParser::new(); - parser.load_file(path)?; - - // Basic sanity checks - assert!(!parser.is_empty(), "sa.gxt should contain entries"); - assert!(parser.len() > 0, "sa.gxt should have at least one entry"); - - println!("sa.gxt loaded successfully:"); - println!(" Total entries: {}", parser.len()); - - // No table listing; only entries are validated - - // Test hash calculation - let test_hash = GxtParser::calculate_hash("MAIN"); - println!(" JAMCRC32 hash of 'MAIN': 0x{:08x}", test_hash); - - Ok(()) -} - -#[test] -fn test_sa_format_crc32() -> Result<()> { - // Test that JAMCRC32 hashing works correctly - let hash1 = GxtParser::calculate_hash("TEST"); - let hash2 = GxtParser::calculate_hash("TEST"); - let hash3 = GxtParser::calculate_hash("TEST2"); - - assert_eq!(hash1, hash2, "Same string should produce same hash"); - assert_ne!( - hash1, hash3, - "Different strings should produce different hashes" - ); - - println!("JAMCRC32 hash tests:"); - println!(" 'TEST' => 0x{:08x}", hash1); - println!(" 'TEST2' => 0x{:08x}", hash3); - - Ok(()) -} - -#[test] -fn test_all_formats_comparison() -> Result<()> { - // Compare all three formats if files are available - let iii_path = Path::new("iii.gxt"); - let vc_path = Path::new("vc.gxt"); - let sa_path = Path::new("sa.gxt"); - - if !iii_path.exists() || !vc_path.exists() || !sa_path.exists() { - eprintln!("Skipping comparison test: not all files found"); - return Ok(()); - } - - let mut iii_parser = GxtParser::new(); - iii_parser.load_file(iii_path)?; - - let mut vc_parser = GxtParser::new(); - vc_parser.load_file(vc_path)?; - - let mut sa_parser = GxtParser::new(); - sa_parser.load_file(sa_path)?; - - println!("\nFormat comparison (all three games):"); - println!(" GTA III format (iii.gxt):"); - println!(" - Entries: {}", iii_parser.len()); - println!(" - Structure: Single TKEY/TDAT section"); - println!(" - Keys: String-based (8 chars max)"); - - println!(" Vice City format (vc.gxt):"); - println!(" - Entries: {}", vc_parser.len()); - println!(" - Structure: TABL with multiple TKEY/TDAT sections"); - println!(" - Keys: String-based (8 chars max)"); - - println!(" San Andreas format (sa.gxt):"); - println!(" - Entries: {}", sa_parser.len()); - println!(" - Structure: Header + TABL with hash-based keys"); - println!(" - Keys: JAMCRC32 hashes (4 bytes)"); - - Ok(()) -} diff --git a/src/lib.rs b/src/lib.rs index 0a01f90..519aa21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,8 @@ pub mod source_map; pub mod preprocessor; pub mod sanny_update; pub mod ide; +pub mod gxt; + #[ctor] fn main() { From 27ed05db9d03310b674df008f4828aca68941941 Mon Sep 17 00:00:00 2001 From: Seemann Date: Mon, 11 Aug 2025 23:17:31 -0400 Subject: [PATCH 5/5] update --- Cargo.lock | 28 +- Cargo.toml | 4 +- src/gxt/ffi.rs | 5 +- src/gxt/parser.rs | 7 + src/lib.rs | 2 +- src/modes/Cargo.lock | 96 ------- src/modes/Cargo.toml | 10 - src/modes/ffi.rs | 41 +++ src/modes/mod.rs | 4 + src/modes/{src => }/mode.rs | 0 src/modes/{src => }/modes.rs | 261 +++++++++--------- src/modes/src/main.rs | 185 ------------- src/modes/{src => }/string_variables.rs | 0 .../test_cases => test_cases}/test_base.xml | 0 .../test_cases => test_cases}/test_child.xml | 0 .../test_circular_a.xml | 0 .../test_circular_b.xml | 0 .../test_circular_c.xml | 0 .../test_circular_d.xml | 0 .../test_circular_e.xml | 0 .../test_grandchild.xml | 0 21 files changed, 213 insertions(+), 430 deletions(-) delete mode 100644 src/modes/Cargo.lock delete mode 100644 src/modes/Cargo.toml create mode 100644 src/modes/ffi.rs create mode 100644 src/modes/mod.rs rename src/modes/{src => }/mode.rs (100%) rename src/modes/{src => }/modes.rs (92%) delete mode 100644 src/modes/src/main.rs rename src/modes/{src => }/string_variables.rs (100%) rename {src/modes/test_cases => test_cases}/test_base.xml (100%) rename {src/modes/test_cases => test_cases}/test_child.xml (100%) rename {src/modes/test_cases => test_cases}/test_circular_a.xml (100%) rename {src/modes/test_cases => test_cases}/test_circular_b.xml (100%) rename {src/modes/test_cases => test_cases}/test_circular_c.xml (100%) rename {src/modes/test_cases => test_cases}/test_circular_d.xml (100%) rename {src/modes/test_cases => test_cases}/test_circular_e.xml (100%) rename {src/modes/test_cases => test_cases}/test_grandchild.xml (100%) diff --git a/Cargo.lock b/Cargo.lock index 8460d38..8ae30e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -525,6 +525,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "gta-ide-parser" version = "0.0.4" @@ -1013,6 +1019,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.36" @@ -1092,6 +1108,7 @@ dependencies = [ "crc32fast", "ctor", "encoding_rs", + "glob", "gta-ide-parser", "hotwatch", "lazy_static", @@ -1103,6 +1120,7 @@ dependencies = [ "nom", "nom_locate", "normpath", + "quick-xml", "serde", "serde_json", "simplelog", @@ -1125,22 +1143,22 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.126" +version = "1.0.171" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03" +checksum = "30e27d1e4fd7659406c492fd6cfaf2066ba8773de45ca75e855590f856dc34a9" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.126" +version = "1.0.171" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43" +checksum = "389894603bd18c46fa56231694f8d827779c0951a667087194cf9de94ed24682" dependencies = [ "proc-macro2", "quote", - "syn 1.0.99", + "syn 2.0.60", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9d9138a..5de7b78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,4 +38,6 @@ gta-ide-parser = "0.0.4" byteorder = "1.5" clap = { version = "4.5", features = ["derive"] } crc32fast = "1.4" -encoding_rs = "0.8" \ No newline at end of file +encoding_rs = "0.8" +quick-xml = { version = "0.36", features = ["serialize"] } +glob = "0.3" diff --git a/src/gxt/ffi.rs b/src/gxt/ffi.rs index eb37c77..ca60364 100644 --- a/src/gxt/ffi.rs +++ b/src/gxt/ffi.rs @@ -1,4 +1,6 @@ +use std::ffi::CString; use crate::common_ffi::*; +use super::parser::GxtParser; #[no_mangle] pub extern "C" fn gxt_new() -> *mut GxtParser { @@ -14,8 +16,7 @@ pub unsafe extern "C" fn gxt_free(p: *mut GxtParser) { pub unsafe extern "C" fn gxt_load_file(p: *mut GxtParser, path: PChar) -> bool { boolclosure!({ let path = pchar_to_str(path)?; - (*p).load_file(path)?; - Some(()) + (*p).load_file(path).ok() }) } diff --git a/src/gxt/parser.rs b/src/gxt/parser.rs index 3c1a7e9..dd46450 100644 --- a/src/gxt/parser.rs +++ b/src/gxt/parser.rs @@ -107,6 +107,13 @@ impl GxtParser { } } + /// Calculate JAMCRC32 hash for a key string (for SA format) + pub fn calculate_hash(key: &str) -> u32 { + let mut hasher = Hasher::new(); + hasher.update(key.to_uppercase().as_bytes()); + !hasher.finalize() // JAMCRC32 = ~CRC32 + } + /// Load a GXT file from the given path pub fn load_file>(&mut self, path: P) -> Result<()> { let path = path.as_ref(); diff --git a/src/lib.rs b/src/lib.rs index 519aa21..caaf887 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,7 +19,7 @@ pub mod preprocessor; pub mod sanny_update; pub mod ide; pub mod gxt; - +pub mod modes; #[ctor] fn main() { diff --git a/src/modes/Cargo.lock b/src/modes/Cargo.lock deleted file mode 100644 index 74bd9ef..0000000 --- a/src/modes/Cargo.lock +++ /dev/null @@ -1,96 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anyhow" -version = "1.0.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" - -[[package]] -name = "glob" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" - -[[package]] -name = "memchr" -version = "2.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" - -[[package]] -name = "proc-macro2" -version = "1.0.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quick-xml" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "quote" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "syn" -version = "2.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" - -[[package]] -name = "xml" -version = "0.1.0" -dependencies = [ - "anyhow", - "glob", - "quick-xml", - "serde", -] diff --git a/src/modes/Cargo.toml b/src/modes/Cargo.toml deleted file mode 100644 index 5cccdc0..0000000 --- a/src/modes/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "xml" -version = "0.1.0" -edition = "2024" - -[dependencies] -anyhow = "1.0" -serde = { version = "1.0", features = ["derive"] } -quick-xml = { version = "0.36", features = ["serialize"] } -glob = "0.3" \ No newline at end of file diff --git a/src/modes/ffi.rs b/src/modes/ffi.rs new file mode 100644 index 0000000..572005e --- /dev/null +++ b/src/modes/ffi.rs @@ -0,0 +1,41 @@ +use std::path::Path; + +use libc::c_char; + +use super::modes::ModeManager; + +use crate::common_ffi::*; + +#[no_mangle] +pub extern "C" fn mode_manager_new() -> *mut ModeManager { + ptr_new(ModeManager::new()) +} + +#[no_mangle] +pub unsafe extern "C" fn mode_manager_free(manager: *mut ModeManager) { + ptr_free(manager) +} + +#[no_mangle] +pub unsafe extern "C" fn mode_manager_load_from_dir(manager: *mut ModeManager, dir: PChar) -> bool { + boolclosure!({ + let path = Path::new(pchar_to_str(dir)?); + if !path.is_dir() { + return None; + } + manager.as_mut()?.load_from_directory(path); + Some(()) + }) +} + +#[no_mangle] +pub unsafe extern "C" fn mode_manager_set_current_mode_by_id( + manager: *mut ModeManager, + mode_id: PChar, +) -> bool { + boolclosure!({ + let id = pchar_to_str(mode_id)?; + manager.as_mut()?.set_current_mode_by_id(&id); + Some(()) + }) +} diff --git a/src/modes/mod.rs b/src/modes/mod.rs new file mode 100644 index 0000000..a080c27 --- /dev/null +++ b/src/modes/mod.rs @@ -0,0 +1,4 @@ +pub mod mode; +pub mod modes; +pub mod string_variables; +pub mod ffi; \ No newline at end of file diff --git a/src/modes/src/mode.rs b/src/modes/mode.rs similarity index 100% rename from src/modes/src/mode.rs rename to src/modes/mode.rs diff --git a/src/modes/src/modes.rs b/src/modes/modes.rs similarity index 92% rename from src/modes/src/modes.rs rename to src/modes/modes.rs index 35446f4..282aa19 100644 --- a/src/modes/src/modes.rs +++ b/src/modes/modes.rs @@ -1,7 +1,7 @@ -use crate::mode::{ +use super::mode::{ AutoUpdateElement, CopyDirectoryElement, Game, IdeElement, Mode, TemplateElement, TextElement, }; -use crate::string_variables::StringVariables; +use super::string_variables::StringVariables; use anyhow::{Context, Result, anyhow, bail}; use quick_xml::de::from_str; use std::collections::HashSet; @@ -959,68 +959,70 @@ impl ModeManager { #[cfg(test)] mod tests { + use crate::modes::mode::TextFormat; + use super::*; use std::fs; use std::path::Path; - #[test] - fn test_load_shared_xml_directory() { - let mut manager = ModeManager::new(); - manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); - manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); - - let result = manager.load_from_directory(Path::new("shared_XML")); - assert!( - result.is_ok(), - "Failed to load shared_XML directory: {:?}", - result - ); - - let loaded_count = result.unwrap(); - println!("Loaded {} modes from shared_XML", loaded_count); - assert!(loaded_count > 0, "Should load at least some modes"); - - // Check that sa_sbl mode was loaded - assert!( - manager.set_current_mode_by_id("sa_sbl"), - "sa_sbl mode should be loaded" - ); - assert_eq!(manager.get_id(), Some("sa_sbl".to_string())); - assert_eq!(manager.get_title(), Some("GTA SA (v1.0 - SBL)".to_string())); - assert_eq!(manager.get_game(), Some(Game::Sa)); - - // Set current mode and check placeholder replacement through getters - manager.set_current_mode_by_id("sa_sbl"); - let data = manager.get_data(); - assert!( - data.as_ref().unwrap().contains("C:\\SannyBuilder"), - "Data should contain substituted path" - ); - - // Check that sa_sbl_sf inherits from sa_sbl - // Check that sa_sbl_sf mode was loaded and extends sa_sbl - assert!( - manager.set_current_mode_by_id("sa_sbl_sf"), - "sa_sbl_sf mode should be loaded" - ); - assert_eq!(manager.get_extends(), Some("sa_sbl".to_string())); - - // Set current mode to child and check inheritance through getters - manager.set_current_mode_by_id("sa_sbl_sf"); - let child_data = manager.get_data(); - let child_library = manager.get_library(); - - manager.set_current_mode_by_id("sa_sbl"); - let parent_data = manager.get_data(); - - // Should have inherited fields from parent - assert_eq!(child_data, parent_data); // Inherited from parent - - // Should have its own library entries (not inherited since it defines its own) - assert_eq!(child_library.len(), 2); - assert!(child_library[0].value.contains("sa.json")); - assert!(child_library[1].value.contains("sf.fix.json")); - } + // #[test] + // fn test_load_shared_xml_directory() { + // let mut manager = ModeManager::new(); + // manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + // manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); + + // let result = manager.load_from_directory(Path::new("shared_XML")); + // assert!( + // result.is_ok(), + // "Failed to load shared_XML directory: {:?}", + // result + // ); + + // let loaded_count = result.unwrap(); + // println!("Loaded {} modes from shared_XML", loaded_count); + // assert!(loaded_count > 0, "Should load at least some modes"); + + // // Check that sa_sbl mode was loaded + // assert!( + // manager.set_current_mode_by_id("sa_sbl"), + // "sa_sbl mode should be loaded" + // ); + // assert_eq!(manager.get_id(), Some("sa_sbl".to_string())); + // assert_eq!(manager.get_title(), Some("GTA SA (v1.0 - SBL)".to_string())); + // assert_eq!(manager.get_game(), Some(Game::Sa)); + + // // Set current mode and check placeholder replacement through getters + // manager.set_current_mode_by_id("sa_sbl"); + // let data = manager.get_data(); + // assert!( + // data.as_ref().unwrap().contains("C:\\SannyBuilder"), + // "Data should contain substituted path" + // ); + + // // Check that sa_sbl_sf inherits from sa_sbl + // // Check that sa_sbl_sf mode was loaded and extends sa_sbl + // assert!( + // manager.set_current_mode_by_id("sa_sbl_sf"), + // "sa_sbl_sf mode should be loaded" + // ); + // assert_eq!(manager.get_extends(), Some("sa_sbl".to_string())); + + // // Set current mode to child and check inheritance through getters + // manager.set_current_mode_by_id("sa_sbl_sf"); + // let child_data = manager.get_data(); + // let child_library = manager.get_library(); + + // manager.set_current_mode_by_id("sa_sbl"); + // let parent_data = manager.get_data(); + + // // Should have inherited fields from parent + // assert_eq!(child_data, parent_data); // Inherited from parent + + // // Should have its own library entries (not inherited since it defines its own) + // assert_eq!(child_library.len(), 2); + // assert!(child_library[0].value.contains("sa.json")); + // assert!(child_library[1].value.contains("sf.fix.json")); + // } #[test] fn test_circular_dependency_detection() { @@ -1117,75 +1119,75 @@ mod tests { fs::remove_dir_all(test_dir).ok(); } - #[test] - fn test_validate_all_shared_modes() { - let mut manager = ModeManager::new(); - manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); - manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); - - let result = manager.load_from_directory(Path::new("shared_XML")); - assert!(result.is_ok(), "Failed to load shared_XML: {:?}", result); - - let loaded_count = result.unwrap(); - println!("\n=== ModeManager Validation ==="); - println!("Successfully loaded {} modes", loaded_count); - - // Collect all mode IDs first - let mode_count = manager.mode_count(); - let mut mode_ids = Vec::new(); - for i in 0..mode_count { - manager.set_current_mode_by_index(i); - if let (Some(id), Some(title), Some(game)) = - (manager.get_id(), manager.get_title(), manager.get_game()) - { - mode_ids.push((id, title, game)); - } - } - - // Validate each mode - for (id, title, game) in mode_ids { - assert!(!id.is_empty(), "Mode ID should not be empty"); - assert!( - !title.is_empty(), - "Mode title should not be empty for {}", - id - ); - // Game is now an enum, so it always has a valid value - - // Check that placeholders are replaced when accessed through getters - manager.set_current_mode_by_id(&id); - if let Some(data) = manager.get_data() { - assert!( - !data.contains("@sb:"), - "Placeholder @sb: not replaced in mode {} data", - id - ); - assert!( - !data.contains("@game:"), - "Placeholder @game: not replaced in mode {} data", - id - ); - } - - // Check other path fields - if let Some(compiler) = manager.get_compiler() { - assert!( - !compiler.contains("@sb:"), - "Placeholder @sb: not replaced in mode {} compiler", - id - ); - assert!( - !compiler.contains("@game:"), - "Placeholder @game: not replaced in mode {} compiler", - id - ); - } - - println!("✓ {}: {} (game: {})", id, title, game); - } - - println!("\nAll {} modes validated successfully!", loaded_count); - } + // #[test] + // fn test_validate_all_shared_modes() { + // let mut manager = ModeManager::new(); + // manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); + // manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); + + // let result = manager.load_from_directory(Path::new("shared_XML")); + // assert!(result.is_ok(), "Failed to load shared_XML: {:?}", result); + + // let loaded_count = result.unwrap(); + // println!("\n=== ModeManager Validation ==="); + // println!("Successfully loaded {} modes", loaded_count); + + // // Collect all mode IDs first + // let mode_count = manager.mode_count(); + // let mut mode_ids = Vec::new(); + // for i in 0..mode_count { + // manager.set_current_mode_by_index(i); + // if let (Some(id), Some(title), Some(game)) = + // (manager.get_id(), manager.get_title(), manager.get_game()) + // { + // mode_ids.push((id, title, game)); + // } + // } + + // // Validate each mode + // for (id, title, game) in mode_ids { + // assert!(!id.is_empty(), "Mode ID should not be empty"); + // assert!( + // !title.is_empty(), + // "Mode title should not be empty for {}", + // id + // ); + // // Game is now an enum, so it always has a valid value + + // // Check that placeholders are replaced when accessed through getters + // manager.set_current_mode_by_id(&id); + // if let Some(data) = manager.get_data() { + // assert!( + // !data.contains("@sb:"), + // "Placeholder @sb: not replaced in mode {} data", + // id + // ); + // assert!( + // !data.contains("@game:"), + // "Placeholder @game: not replaced in mode {} data", + // id + // ); + // } + + // // Check other path fields + // if let Some(compiler) = manager.get_compiler() { + // assert!( + // !compiler.contains("@sb:"), + // "Placeholder @sb: not replaced in mode {} compiler", + // id + // ); + // assert!( + // !compiler.contains("@game:"), + // "Placeholder @game: not replaced in mode {} compiler", + // id + // ); + // } + + // println!("✓ {}: {} (game: {})", id, title, game); + // } + + // println!("\nAll {} modes validated successfully!", loaded_count); + // } #[test] fn test_set_current_mode_by_game_prioritizes_default() { @@ -1778,7 +1780,6 @@ mod tests { #[test] fn test_untested_getter_methods() { use quick_xml::de::from_str; - use crate::mode::TextFormat; let mut manager = ModeManager::new(); manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); diff --git a/src/modes/src/main.rs b/src/modes/src/main.rs deleted file mode 100644 index 61d2e0c..0000000 --- a/src/modes/src/main.rs +++ /dev/null @@ -1,185 +0,0 @@ -mod mode; -mod modes; -mod string_variables; - -use modes::ModeManager; -use std::path::Path; - -fn main() { - // Create a new ModeManager - let mut manager = ModeManager::new(); - - // Register the standard variables - manager.register_variable("@sb:".to_string(), "C:\\SannyBuilder".to_string()); - manager.register_variable("@game:".to_string(), "D:\\Games\\GTA".to_string()); - - // Load all modes from the shared_XML directory - println!("Loading modes from shared_XML directory...\n"); - - match manager.load_from_directory(Path::new("shared_XML")) { - Ok(count) => { - println!("Successfully loaded {} modes from shared_XML\n", count); - - // Display information about all loaded modes - println!("=== Loaded Modes ==="); - - // Collect mode IDs and basic info first to avoid borrow checker issues - let mode_count = manager.mode_count(); - let mut mode_infos = Vec::new(); - for i in 0..mode_count { - manager.set_current_mode_by_index(i); - if let (Some(id), Some(title), Some(game)) = (manager.get_id(), manager.get_title(), manager.get_game()) { - let extends = manager.get_extends(); - let mode_type = manager.get_type(); - mode_infos.push((id, title, game, extends, mode_type)); - } - } - - for (id, title, game, extends, r#type) in mode_infos { - println!("\nMode: {}", id); - println!(" Title: {}", title); - println!(" Game: {}", game); - - if let Some(extends) = extends { - println!(" Extends: {}", extends); - } - - if let Some(r#type) = r#type { - println!(" Type: {}", r#type); - } - - // Set this mode as current to demonstrate path substitution - manager.set_current_mode_by_id(&id); - - if let Some(data) = manager.get_data() { - println!(" Data path (with substitutions): {}", data); - } - - println!(" Library files: {}", manager.get_library().len()); - println!(" Classes: {}", manager.get_classes().len()); - println!(" Enums: {}", manager.get_enums().len()); - println!(" IDE entries: {}", manager.get_ide().len()); - println!(" Templates: {}", manager.get_templates().len()); - println!(" Copy directories: {}", manager.get_copy_directory().len()); - } - - // Example: Using the new getter methods with current mode - println!("\n=== Using Getter Methods with Current Mode ==="); - - // Set sa_sbl as the current mode - if manager.set_current_mode_by_id("sa_sbl") { - println!("Current mode set to: {:?}", manager.get_id().unwrap()); - println!(" Title: {:?}", manager.get_title()); - println!(" Game: {:?}", manager.get_game()); - - // Path properties will have @sb: and @game: replaced - if let Some(data) = manager.get_data() { - println!(" Data path: {}", data); - } - - if let Some(compiler) = manager.get_compiler() { - println!(" Compiler: {}", compiler); - } - - if let Some(keywords) = manager.get_keywords() { - println!(" Keywords: {}", keywords); - } - - println!("\n Library files (with path substitutions):"); - for lib in manager.get_library() { - println!(" - {} (autoupdate: {:?})", lib.value, lib.autoupdate); - } - - println!("\n Classes (with path substitutions):"); - for class in manager.get_classes() { - println!(" - {} (autoupdate: {:?})", class.value, class.autoupdate); - } - - println!("\n IDE entries (with path substitutions):"); - for ide in manager.get_ide() { - println!(" - {} (base: {:?})", ide.value, ide.base); - } - } - - // Example: Demonstrate the new API methods - println!("\n=== New API Methods Demo ==="); - - // Set mode by index - if manager.set_current_mode_by_index(0) { - if let (Some(id), Some(title)) = (manager.get_id(), manager.get_title()) { - println!("Mode at index 0: {} ({})", id, title); - } - } - - // Set mode by ID - if manager.set_current_mode_by_id("sa") { - if let Some(title) = manager.get_title() { - println!("Found mode by ID 'sa': {}", title); - } - } - - // Set mode by game (first SA game mode) - if manager.set_current_mode_by_game(mode::Game::Sa) { - if let (Some(id), Some(title)) = (manager.get_id(), manager.get_title()) { - println!("\nFirst mode for SA game: {} ({})", id, title); - } - } - - // Count modes for a specific game - let mut sa_game_count = 0; - println!("\nModes for SA game:"); - for i in 0..manager.mode_count() { - manager.set_current_mode_by_index(i); - if let Some(game) = manager.get_game() { - if game == mode::Game::Sa { - if let (Some(id), Some(title)) = (manager.get_id(), manager.get_title()) { - println!(" - {} ({})", id, title); - sa_game_count += 1; - } - } - } - } - println!("Total SA game modes: {}", sa_game_count); - - println!("\nTotal modes loaded: {}", manager.mode_count()); - - // Example: Check inheritance with new getter methods - println!("\n=== Inheritance Example with Getters ==="); - - // Set sa_sbl_sf (child) as current mode - if manager.set_current_mode_by_id("sa_sbl_sf") { - println!("Mode 'sa_sbl_sf' extends '{}'", manager.get_extends().unwrap_or("nothing".to_string())); - - // Get child's values - let child_data = manager.get_data(); - let child_keywords = manager.get_keywords(); - let child_library_count = manager.get_library().len(); - let child_classes_count = manager.get_classes().len(); - - // Switch to parent mode - if manager.set_current_mode_by_id("sa_sbl") { - let parent_data = manager.get_data(); - let parent_keywords = manager.get_keywords(); - let parent_library_count = manager.get_library().len(); - let parent_classes_count = manager.get_classes().len(); - - println!("\nInherited from parent:"); - if child_data == parent_data { - println!(" - data: {}", child_data.unwrap_or("none".to_string())); - } - if child_keywords == parent_keywords { - println!(" - keywords: {}", child_keywords.unwrap_or("none".to_string())); - } - - println!("\nOverridden in child:"); - println!(" - library: {} entries (parent has {})", child_library_count, parent_library_count); - println!(" - classes: {} entries (parent has {})", child_classes_count, parent_classes_count); - } - } - - } - Err(e) => { - eprintln!("Failed to load modes from shared_XML: {:?}", e); - } - } -} \ No newline at end of file diff --git a/src/modes/src/string_variables.rs b/src/modes/string_variables.rs similarity index 100% rename from src/modes/src/string_variables.rs rename to src/modes/string_variables.rs diff --git a/src/modes/test_cases/test_base.xml b/test_cases/test_base.xml similarity index 100% rename from src/modes/test_cases/test_base.xml rename to test_cases/test_base.xml diff --git a/src/modes/test_cases/test_child.xml b/test_cases/test_child.xml similarity index 100% rename from src/modes/test_cases/test_child.xml rename to test_cases/test_child.xml diff --git a/src/modes/test_cases/test_circular_a.xml b/test_cases/test_circular_a.xml similarity index 100% rename from src/modes/test_cases/test_circular_a.xml rename to test_cases/test_circular_a.xml diff --git a/src/modes/test_cases/test_circular_b.xml b/test_cases/test_circular_b.xml similarity index 100% rename from src/modes/test_cases/test_circular_b.xml rename to test_cases/test_circular_b.xml diff --git a/src/modes/test_cases/test_circular_c.xml b/test_cases/test_circular_c.xml similarity index 100% rename from src/modes/test_cases/test_circular_c.xml rename to test_cases/test_circular_c.xml diff --git a/src/modes/test_cases/test_circular_d.xml b/test_cases/test_circular_d.xml similarity index 100% rename from src/modes/test_cases/test_circular_d.xml rename to test_cases/test_circular_d.xml diff --git a/src/modes/test_cases/test_circular_e.xml b/test_cases/test_circular_e.xml similarity index 100% rename from src/modes/test_cases/test_circular_e.xml rename to test_cases/test_circular_e.xml diff --git a/src/modes/test_cases/test_grandchild.xml b/test_cases/test_grandchild.xml similarity index 100% rename from src/modes/test_cases/test_grandchild.xml rename to test_cases/test_grandchild.xml