From e8464d3f74a4f02cd433b1d9dfd84253017c7736 Mon Sep 17 00:00:00 2001 From: Connor Date: Wed, 31 Dec 2025 18:28:45 +0900 Subject: [PATCH] phase 3 --- .claude/settings.local.json | 4 +- Cargo.lock | 78 +++++++ Cargo.toml | 9 + PHASE1_SUMMARY.md | 179 --------------- ROADMAP.md | 62 +++--- src/ecs/mod.rs | 352 +++++++++++++++++++++++++++++ src/error.rs | 36 +++ src/lib.rs | 9 +- src/project/mod.rs | 426 ++++++++++++++++++++++++++++++++++++ src/project/query.rs | 280 ++++++++++++++++++++++++ src/types/mod.rs | 4 + src/types/reference.rs | 291 ++++++++++++++++++++++++ src/types/type_registry.rs | 354 ++++++++++++++++++++++++++++++ 13 files changed, 1871 insertions(+), 213 deletions(-) delete mode 100644 PHASE1_SUMMARY.md create mode 100644 src/ecs/mod.rs create mode 100644 src/project/mod.rs create mode 100644 src/project/query.rs create mode 100644 src/types/reference.rs create mode 100644 src/types/type_registry.rs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 87f8647..11bb0fa 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,9 @@ "Bash(cat:*)", "Bash(cargo build:*)", "Bash(cargo test:*)", - "Bash(cargo run:*)" + "Bash(cargo run:*)", + "Bash(cargo tree:*)", + "WebFetch(domain:docs.rs)" ] } } diff --git a/Cargo.lock b/Cargo.lock index 7a81c0c..81d52fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "atomic_refcell" version = "0.1.13" @@ -23,12 +29,15 @@ version = "0.1.0" dependencies = [ "glam", "indexmap", + "lru", + "once_cell", "pretty_assertions", "regex", "serde", "serde_yaml", "sparsey", "thiserror", + "walkdir", ] [[package]] @@ -43,6 +52,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "glam" version = "0.29.3" @@ -57,6 +72,11 @@ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashbrown" @@ -82,12 +102,27 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -157,6 +192,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "serde" version = "1.0.228" @@ -254,6 +298,40 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 0f0594b..2c3b629 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,15 @@ glam = { version = "0.29", features = ["serde"] } # ECS (Entity Component System) sparsey = "0.13" +# LRU cache for reference resolution +lru = "0.12" + +# Directory traversal for loading projects +walkdir = "2.4" + +# Lazy static initialization for type registry +once_cell = "1.19" + [dev-dependencies] # Testing utilities pretty_assertions = "1.4" diff --git a/PHASE1_SUMMARY.md b/PHASE1_SUMMARY.md deleted file mode 100644 index b934a43..0000000 --- a/PHASE1_SUMMARY.md +++ /dev/null @@ -1,179 +0,0 @@ -# Phase 1 Implementation Summary - -## Overview - -Phase 1 of the Cursebreaker Unity Parser has been successfully completed. The foundation for parsing Unity YAML files is now in place with a robust, well-tested implementation. - -## What Was Implemented - -### 1. Project Structure ✅ - -- Created Cargo workspace with proper dependencies -- Set up module structure (lib.rs, error.rs, model/, parser/) -- Configured Cargo.toml with metadata and feature flags - -### 2. Error Handling ✅ - -- Implemented comprehensive error types using thiserror -- Created custom error variants for: - - IO errors - - YAML parsing errors - - Invalid Unity format - - Missing headers - - Invalid type tags - - Reference errors -- Result type alias for ergonomic error handling - -### 3. Core Data Model ✅ - -**UnityFile:** -- Represents a complete Unity file (.unity, .prefab, .asset) -- Contains path and list of documents -- Methods for querying documents: - - `get_document(file_id)` - Look up by file ID - - `get_documents_by_type(type_id)` - Find by Unity type ID - - `get_documents_by_class(class_name)` - Find by class name - -**UnityDocument:** -- Represents a single YAML document (Unity object) -- Contains: - - `type_id` - Unity type ID (from !u!N tag) - - `file_id` - Anchor ID (from &ID) - - `class_name` - Object class (GameObject, Transform, etc.) - - `properties` - Ordered map of properties (IndexMap) - -### 4. YAML Document Parser ✅ - -**Features:** -- Validates Unity YAML headers (%YAML 1.1, %TAG !u!) -- Splits multi-document YAML files into individual documents -- Handles empty lines and proper document boundaries -- Parses YAML content into serde_yaml::Value structures -- Stores properties in ordered IndexMap for stable iteration - -**Implementation:** -- `split_yaml_documents()` - Splits file on `---` boundaries -- `validate_unity_header()` - Ensures proper Unity format -- `parse_document()` - Converts raw YAML to UnityDocument - -### 5. Unity Tag Parser ✅ - -**Features:** -- Parses Unity type tags: `!u!1`, `!u!224`, etc. -- Extracts type IDs and anchor IDs -- Handles negative file IDs -- Uses compiled regex with caching for performance - -**Implementation:** -- `parse_unity_tag()` - Extracts UnityTag from document string -- Regex pattern: `^---\s+!u!(\d+)\s+&(-?\d+)` -- OnceLock for one-time regex compilation - -### 6. Testing Infrastructure ✅ - -**Test Coverage:** -- **12 unit tests** - Parser components, YAML splitting, tag parsing -- **7 integration tests** - Real Unity file parsing, error handling -- **4 doc tests** - Documentation examples - -**Real-World Testing:** -- Successfully parses PiratePanic sample project files -- Tests against actual Unity scenes and prefabs -- Validates GameObject, Transform, and other Unity types - -### 7. Documentation ✅ - -- Comprehensive rustdoc for all public APIs -- Example code in `examples/basic_parsing.rs` -- Updated README.md with usage guide -- Updated ROADMAP.md with completed tasks -- Implementation notes for future reference - -## Files Created - -``` -cursebreaker-parser-rust/ -├── Cargo.toml # Project configuration -├── README.md # Project documentation -├── PHASE1_SUMMARY.md # This file -├── src/ -│ ├── lib.rs # Public API -│ ├── error.rs # Error types -│ ├── model/ -│ │ └── mod.rs # UnityFile, UnityDocument -│ └── parser/ -│ ├── mod.rs # Main parser -│ ├── unity_tag.rs # Unity tag parser -│ └── yaml.rs # YAML document splitter -├── examples/ -│ └── basic_parsing.rs # Usage example -└── tests/ - └── integration_tests.rs # Integration tests -``` - -## Key Metrics - -- **Lines of Code**: ~800 (excluding tests) -- **Test Coverage**: 23 tests, 100% pass rate -- **Dependencies**: 6 main dependencies (minimal, well-maintained) -- **Performance**: - - Parse 15-doc prefab: ~1ms - - Parse 100+ doc scene: ~10ms - - Memory: ~2x file size - -## Success Criteria Met ✅ - -All Phase 1 success criteria have been met: - -1. ✅ Can read `Scene01MainMenu.unity` and split into individual documents -2. ✅ Each document has correct type ID and file ID -3. ✅ No panics on malformed input (returns errors) -4. ✅ Successfully parses real Unity files from PiratePanic project -5. ✅ Comprehensive test suite passing -6. ✅ Clean, documented public API - -## Next Steps - -Phase 1 provides the foundation for more advanced features: - -**Phase 2** (Next): -- Property parsing and type conversion -- Support for Unity-specific types (Vector3, Color, etc.) -- Nested property access -- GameObject and Component specialized types - -**Future Phases**: -- Reference resolution (Phase 3) -- Performance optimization (Phase 4) -- API polish and documentation (Phase 5) - -## Usage Example - -```rust -use cursebreaker_parser::UnityFile; - -fn main() -> Result<(), Box> { - // Parse a Unity prefab - let file = UnityFile::from_path("CardGrabber.prefab")?; - - println!("Found {} documents", file.documents.len()); - - // Find all GameObjects - let game_objects = file.get_documents_by_class("GameObject"); - println!("GameObjects: {}", game_objects.len()); - - Ok(()) -} -``` - -## Conclusion - -Phase 1 is complete and provides a solid foundation for the Cursebreaker Unity Parser. The implementation is: - -- **Robust**: Comprehensive error handling -- **Well-tested**: 23 passing tests -- **Documented**: rustdoc for all public APIs -- **Performant**: Fast parsing with minimal overhead -- **Extensible**: Clean architecture for future phases - -The parser successfully handles real Unity files and is ready for Phase 2 development. diff --git a/ROADMAP.md b/ROADMAP.md index 5e9372e..0fb1376 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -69,49 +69,49 @@ This roadmap breaks down the development into 5 phases, each building on the pre ### Tasks 1. **Core Data Structures** - - [ ] Implement `UnityDocument` struct - - [ ] Implement `UnityFile` struct - - [ ] Create property storage (PropertyMap using IndexMap) - - [ ] Define FileID and LocalID types + - [x] Implement `UnityDocument` struct + - [x] Implement `UnityFile` struct + - [x] Create property storage (PropertyMap using IndexMap) + - [x] Define FileID and LocalID types 2. **Property Value Types** - - [ ] Implement `PropertyValue` enum (Integer, Float, String, Boolean, etc.) - - [ ] Add Vector3, Color, Quaternion value types - - [ ] Add Array and nested Object support - - [ ] Implement Debug and Display for PropertyValue + - [x] Implement `PropertyValue` enum (Integer, Float, String, Boolean, etc.) + - [x] Add Vector3, Color, Quaternion value types + - [x] Add Array and nested Object support + - [x] Implement Debug and Display for PropertyValue 3. **Property Parser** - - [ ] Parse YAML mappings into PropertyMap - - [ ] Handle nested properties (paths like `m_Component[0].component`) - - [ ] Parse Unity-specific formats: - - [ ] `{fileID: N}` references - - [ ] `{x: 0, y: 0, z: 0}` vectors - - [ ] `{r: 1, g: 1, b: 1, a: 1}` colors - - [ ] `{guid: ..., type: N}` external references + - [x] Parse YAML mappings into PropertyMap + - [x] Handle nested properties (paths like `m_Component[0].component`) + - [x] Parse Unity-specific formats: + - [x] `{fileID: N}` references + - [x] `{x: 0, y: 0, z: 0}` vectors + - [x] `{r: 1, g: 1, b: 1, a: 1}` colors + - [x] `{guid: ..., type: N}` external references 4. **GameObject & Component Models** - - [ ] Create specialized GameObject struct - - [ ] Create base Component trait/struct - - [ ] Add common component types (Transform, RectTransform, etc.) - - [ ] Helper methods for accessing common properties + - [x] Create specialized GameObject struct + - [x] Create base Component trait/struct + - [x] Add common component types (Transform, RectTransform, etc.) + - [x] Helper methods for accessing common properties 5. **Testing** - - [ ] Unit tests for property parsing - - [ ] Test all PropertyValue variants - - [ ] Integration test: parse GameObject with components - - [ ] Snapshot tests using sample Unity files + - [x] Unit tests for property parsing + - [x] Test all PropertyValue variants + - [x] Integration test: parse GameObject with components + - [x] Snapshot tests using sample Unity files ### Deliverables -- [ ] ✓ Complete data model implemented -- [ ] ✓ Properties parsed into type-safe structures -- [ ] ✓ GameObject and Component abstractions working -- [ ] ✓ All property types handled correctly +- [x] ✓ Complete data model implemented +- [x] ✓ Properties parsed into type-safe structures +- [x] ✓ GameObject and Component abstractions working +- [x] ✓ All property types handled correctly ### Success Criteria -- [ ] Parse entire `CardGrabber.prefab` correctly -- [ ] Extract all GameObject properties (name, components list) -- [ ] Extract all Component properties with correct types -- [ ] Can access nested properties programmatically +- [x] Parse entire `CardGrabber.prefab` correctly +- [x] Extract all GameObject properties (name, components list) +- [x] Extract all Component properties with correct types +- [x] Can access nested properties programmatically --- diff --git a/src/ecs/mod.rs b/src/ecs/mod.rs new file mode 100644 index 0000000..cd84c2c --- /dev/null +++ b/src/ecs/mod.rs @@ -0,0 +1,352 @@ +//! ECS world construction from Unity files +//! +//! This module provides functionality to build a Sparsey ECS World from +//! parsed Unity files, creating entities for GameObjects and attaching +//! components with resolved hierarchy. + +use crate::{Error, FileID, GameObject, GenericComponent, RectTransform, Result, Transform, UnityProject}; +use sparsey::{Entity, World}; +use std::collections::HashMap; + +/// Component marker for GameObject data in the ECS world +/// +/// This component holds the basic GameObject properties (name, active state, layer). +#[derive(Debug, Clone)] +pub struct GameObjectComponent { + /// The name of the GameObject + pub name: String, + /// Whether the GameObject is active + pub is_active: bool, + /// The layer the GameObject is on + pub layer: i64, +} + +/// Builder for constructing an ECS World from a UnityProject +/// +/// Uses a three-pass approach: +/// 1. Create entities for all GameObjects +/// 2. Attach components to entities +/// 3. Resolve Transform hierarchy (parent/child relationships) +/// +/// # Examples +/// +/// ```no_run +/// use cursebreaker_parser::{UnityProject, ecs::WorldBuilder}; +/// +/// let mut project = UnityProject::new(100); +/// project.load_file("Assets/Scenes/MainMenu.unity")?; +/// +/// let builder = WorldBuilder::new(project); +/// let (world, entity_map) = builder.build()?; +/// +/// println!("Created {} entities", entity_map.len()); +/// # Ok::<(), cursebreaker_parser::Error>(()) +/// ``` +pub struct WorldBuilder { + project: UnityProject, + file_id_to_entity: HashMap, +} + +impl WorldBuilder { + /// Create a new WorldBuilder with the given UnityProject + /// + /// # Arguments + /// + /// * `project` - The UnityProject to build from + pub fn new(project: UnityProject) -> Self { + Self { + project, + file_id_to_entity: HashMap::new(), + } + } + + /// Build the ECS world + /// + /// Returns the constructed World and a mapping of FileIDs to Entities. + /// + /// # Returns + /// + /// Tuple of (World, FileID to Entity mapping) + pub fn build(self) -> Result<(World, HashMap)> { + // TODO: Investigate correct Sparsey World creation API + // The Sparsey API requires a GroupLayout parameter which needs investigation + // For now, we return an error indicating ECS integration is incomplete + return Err(Error::world_build_error( + "Sparsey ECS integration not yet fully implemented - requires investigation of GroupLayout API" + )); + + // Create World (placeholder - requires GroupLayout) + // let mut world = World::new(&layout)?; + + // Commented out due to Sparsey API investigation needed: + /* + // Pass 1: Create entities for all GameObjects + for file in self.project.files().values() { + for doc in &file.documents { + if doc.is_game_object() { + let entity = self.create_game_object_entity(&mut world, doc)?; + self.file_id_to_entity.insert(doc.file_id, entity); + } + } + } + + // Pass 2: Add components to entities + for file in self.project.files().values() { + for doc in &file.documents { + if !doc.is_game_object() { + self.add_component_to_entity(&mut world, doc)?; + } + } + } + + // Pass 3: Resolve Transform hierarchy (parent/child relationships) + self.resolve_transform_hierarchy(&mut world)?; + + Ok((world, self.file_id_to_entity)) + */ + } + + /// Create an entity for a GameObject + /// + /// Parses the GameObject and creates an entity with GameObjectComponent attached. + #[allow(dead_code)] + fn create_game_object_entity( + &self, + _world: &mut World, + doc: &crate::UnityDocument, + ) -> Result { + let go = GameObject::parse(doc).ok_or_else(|| { + Error::invalid_format("Failed to parse GameObject") + })?; + + let _component = GameObjectComponent { + name: go.name().unwrap_or("Unnamed").to_string(), + is_active: go.is_active(), + layer: go.layer().unwrap_or(0), + }; + + // TODO: Create entity using correct Sparsey API + // let entity = world.spawn(...)?; + // world.insert(entity, component)?; + + Err(Error::world_build_error("Not implemented")) + } + + /// Add a component to an entity + /// + /// Parses the component and attaches it to the appropriate GameObject entity. + #[allow(dead_code)] + fn add_component_to_entity( + &mut self, + _world: &mut World, + doc: &crate::UnityDocument, + ) -> Result<()> { + // Get the GameObject this component belongs to + let game_object_ref = doc + .get(&doc.class_name) + .and_then(|v| v.as_object()) + .and_then(|obj| obj.get("m_GameObject")) + .and_then(|v| v.as_file_ref()); + + let game_object_ref = match game_object_ref { + Some(r) => r, + None => { + // Some components may not have m_GameObject (e.g., PrefabInstance) + return Ok(()); + } + }; + + let entity = match self.file_id_to_entity.get(&game_object_ref.file_id) { + Some(e) => *e, + None => { + // Log warning for missing GameObject reference (graceful degradation) + eprintln!( + "Warning: Component {} references unknown GameObject: {}", + doc.file_id, game_object_ref.file_id + ); + return Ok(()); + } + }; + + // Parse and attach component based on type + match doc.class_name.as_str() { + "Transform" => { + if let Some(_transform) = Transform::parse(doc) { + // Store the transform entity mapping for hierarchy resolution + self.file_id_to_entity.insert(doc.file_id, entity); + + // Note: Actual component attachment depends on Sparsey API + // world.insert(entity, transform)?; + } + } + "RectTransform" => { + if let Some(_rect_transform) = RectTransform::parse(doc) { + self.file_id_to_entity.insert(doc.file_id, entity); + + // Note: Actual component attachment depends on Sparsey API + // world.insert(entity, rect_transform)?; + } + } + _ => { + if let Some(_generic_component) = GenericComponent::parse(doc) { + // Note: Actual component attachment depends on Sparsey API + // world.insert(entity, generic_component)?; + } + } + } + + Ok(()) + } + + /// Resolve Transform hierarchy (parent/child relationships) + /// + /// Iterates through all Transform/RectTransform components and sets up + /// the parent/child Entity references. + #[allow(dead_code)] + fn resolve_transform_hierarchy(&mut self, _world: &mut World) -> Result<()> { + // Collect all transform documents first to avoid borrow checker issues + let mut transform_updates: Vec<(Entity, Option, Vec)> = Vec::new(); + + for file in self.project.files().values() { + for doc in &file.documents { + if doc.is_transform() { + let transform_entity = match self.file_id_to_entity.get(&doc.file_id) { + Some(e) => *e, + None => continue, + }; + + // Get parent reference + let parent_ref = doc + .get(&doc.class_name) + .and_then(|v| v.as_object()) + .and_then(|obj| obj.get("m_Father")) + .and_then(|v| v.as_file_ref()); + + let parent_entity = parent_ref + .and_then(|r| { + if r.file_id.as_i64() == 0 { + None // Null reference + } else { + self.file_id_to_entity.get(&r.file_id).copied() + } + }); + + // Get children references + let children_refs = doc + .get(&doc.class_name) + .and_then(|v| v.as_object()) + .and_then(|obj| obj.get("m_Children")) + .and_then(|v| v.as_array()); + + let children_entities: Vec = children_refs + .map(|arr| { + arr.iter() + .filter_map(|child| { + child + .as_file_ref() + .and_then(|r| self.file_id_to_entity.get(&r.file_id).copied()) + }) + .collect() + }) + .unwrap_or_default(); + + transform_updates.push((transform_entity, parent_entity, children_entities)); + } + } + } + + // TODO: Apply hierarchy updates to Transform components in the world + // This requires mutable access to the Transform components + // Actual implementation depends on Sparsey's component access API + // + // Example pseudo-code: + // for (entity, parent, children) in transform_updates { + // if let Some(mut transform) = world.get_mut::(entity) { + // transform.set_parent(parent); + // transform.set_children(children); + // } + // } + + Ok(()) + } +} + +/// Build a Sparsey World from a UnityProject +/// +/// Convenience function that creates a WorldBuilder and builds the world. +/// +/// # Arguments +/// +/// * `project` - The UnityProject to build from +/// +/// # Returns +/// +/// Tuple of (World, FileID to Entity mapping) +/// +/// # Examples +/// +/// ```no_run +/// use cursebreaker_parser::{UnityProject, ecs::build_world_from_project}; +/// +/// let mut project = UnityProject::new(100); +/// project.load_file("Assets/Scenes/MainMenu.unity")?; +/// +/// let (world, entity_map) = build_world_from_project(project)?; +/// println!("Created {} entities", entity_map.len()); +/// # Ok::<(), cursebreaker_parser::Error>(()) +/// ``` +pub fn build_world_from_project(project: UnityProject) -> Result<(World, HashMap)> { + let builder = WorldBuilder::new(project); + builder.build() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_build_world_from_prefab() { + let mut project = UnityProject::new(100); + let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab"; + + if Path::new(path).exists() { + project.load_file(path).unwrap(); + + let result = build_world_from_project(project); + + // TODO: Remove this assertion once Sparsey integration is complete + // For now, we expect an error since Sparsey API needs investigation + assert!(result.is_err(), "Expected error due to incomplete Sparsey integration"); + + if let Err(Error::WorldBuildError(_)) = result { + // Expected error + } else { + panic!("Expected WorldBuildError"); + } + } + } + + #[test] + fn test_world_builder() { + let mut project = UnityProject::new(100); + let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab"; + + if Path::new(path).exists() { + project.load_file(path).unwrap(); + + let builder = WorldBuilder::new(project); + let result = builder.build(); + + // TODO: Remove this assertion once Sparsey integration is complete + // For now, we expect an error since Sparsey API needs investigation + assert!(result.is_err(), "Expected error due to incomplete Sparsey integration"); + + if let Err(Error::WorldBuildError(_)) = result { + // Expected error + } else { + panic!("Expected WorldBuildError"); + } + } + } +} diff --git a/src/error.rs b/src/error.rs index 0099a8a..95de2ff 100644 --- a/src/error.rs +++ b/src/error.rs @@ -54,6 +54,22 @@ pub enum Error { /// Invalid property path #[error("Invalid property path: {0}")] InvalidPropertyPath(String), + + /// Failed to resolve GUID reference + #[error("Failed to resolve GUID reference: {0}")] + GuidResolutionError(String), + + /// Unknown Unity type ID + #[error("Unknown Unity type ID: {0} (this type is not in the registry)")] + UnknownTypeId(u32), + + /// Circular reference detected + #[error("Circular reference detected in reference chain")] + CircularReference, + + /// ECS world construction error + #[error("Failed to build ECS world: {0}")] + WorldBuildError(String), } impl Error { @@ -71,4 +87,24 @@ impl Error { pub fn property_not_found(msg: impl Into) -> Self { Error::PropertyNotFound(msg.into()) } + + /// Create a GUID resolution error + pub fn guid_resolution_error(msg: impl Into) -> Self { + Error::GuidResolutionError(msg.into()) + } + + /// Create an unknown type ID error + pub fn unknown_type_id(type_id: u32) -> Self { + Error::UnknownTypeId(type_id) + } + + /// Create a circular reference error + pub fn circular_reference() -> Self { + Error::CircularReference + } + + /// Create a world build error + pub fn world_build_error(msg: impl Into) -> Self { + Error::WorldBuildError(msg.into()) + } } diff --git a/src/lib.rs b/src/lib.rs index 47e17bb..bc49d91 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,18 +16,23 @@ //! ``` // Public modules +pub mod ecs; pub mod error; pub mod model; pub mod parser; +pub mod project; pub mod property; pub mod types; // Re-exports +pub use ecs::{build_world_from_project, GameObjectComponent, WorldBuilder}; pub use error::{Error, Result}; pub use model::{UnityDocument, UnityFile}; pub use parser::parse_unity_file; +pub use project::UnityProject; pub use property::PropertyValue; pub use types::{ - Color, Component, ExternalRef, FileID, FileRef, GameObject, GenericComponent, LocalID, - Quaternion, RectTransform, Transform, Vector2, Vector3, + get_class_name, get_type_id, Color, Component, ExternalRef, FileID, FileRef, GameObject, + GenericComponent, LocalID, Quaternion, RectTransform, Transform, UnityReference, Vector2, + Vector3, }; diff --git a/src/project/mod.rs b/src/project/mod.rs new file mode 100644 index 0000000..6b40a84 --- /dev/null +++ b/src/project/mod.rs @@ -0,0 +1,426 @@ +//! Multi-file Unity project container with reference resolution +//! +//! This module provides the UnityProject struct which can load multiple +//! Unity files and resolve references between them. + +mod query; + +use crate::{FileID, Result, UnityDocument, UnityFile, UnityReference}; +use lru::LruCache; +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::path::{Path, PathBuf}; + +/// A Unity project containing multiple files with cross-file reference resolution +/// +/// UnityProject can load multiple Unity files (.unity, .prefab, .asset) and provides +/// reference resolution both within files (eager) and across files (lazy with caching). +/// +/// # Examples +/// +/// ```no_run +/// use cursebreaker_parser::UnityProject; +/// +/// let mut project = UnityProject::new(1000); // LRU cache size +/// +/// // Load a single file +/// project.load_file("Assets/Scenes/MainMenu.unity")?; +/// +/// // Or load an entire directory +/// project.load_directory("Assets/Prefabs")?; +/// +/// println!("Loaded {} files", project.files().len()); +/// # Ok::<(), cursebreaker_parser::Error>(()) +/// ``` +pub struct UnityProject { + /// All loaded Unity files, indexed by their path + files: HashMap, + + /// GUID to file path mapping for cross-file reference resolution + guid_to_path: HashMap, + + /// FileID to (PathBuf, document index) mapping for fast lookups + file_id_index: HashMap, + + /// LRU cache for resolved references + reference_cache: LruCache>, + + /// Maximum cache size + cache_limit: usize, +} + +impl UnityProject { + /// Create a new Unity project with specified LRU cache limit + /// + /// # Arguments + /// + /// * `cache_limit` - Maximum number of resolved references to cache + /// + /// # Examples + /// + /// ``` + /// use cursebreaker_parser::UnityProject; + /// + /// let project = UnityProject::new(1000); + /// ``` + pub fn new(cache_limit: usize) -> Self { + Self { + files: HashMap::new(), + guid_to_path: HashMap::new(), + file_id_index: HashMap::new(), + reference_cache: LruCache::new( + NonZeroUsize::new(cache_limit).unwrap_or(NonZeroUsize::new(1).unwrap()), + ), + cache_limit, + } + } + + /// Load a Unity file into the project + /// + /// Parses the file and builds an index of all FileIDs for fast lookup. + /// + /// # Arguments + /// + /// * `path` - Path to the Unity file (.unity, .prefab, .asset) + /// + /// # Examples + /// + /// ```no_run + /// use cursebreaker_parser::UnityProject; + /// + /// let mut project = UnityProject::new(100); + /// project.load_file("Assets/Scenes/MainMenu.unity")?; + /// # Ok::<(), cursebreaker_parser::Error>(()) + /// ``` + pub fn load_file(&mut self, path: impl Into) -> Result<()> { + let path = path.into(); + let file = UnityFile::from_path(&path)?; + + // Build file ID index for this file + for (idx, doc) in file.documents.iter().enumerate() { + self.file_id_index + .insert(doc.file_id, (path.clone(), idx)); + } + + // TODO: Extract GUID from .meta file for guid_to_path mapping + // For now, we skip GUID mapping as it requires .meta file parsing + + self.files.insert(path, file); + Ok(()) + } + + /// Load all Unity files from a directory (recursive) + /// + /// Walks the directory tree and loads all .unity, .prefab, and .asset files. + /// Gracefully handles parse errors by logging warnings and continuing. + /// + /// # Arguments + /// + /// * `dir` - Directory to search for Unity files + /// + /// # Returns + /// + /// Vec of successfully loaded file paths + /// + /// # Examples + /// + /// ```no_run + /// use cursebreaker_parser::UnityProject; + /// + /// let mut project = UnityProject::new(1000); + /// let loaded = project.load_directory("Assets")?; + /// println!("Loaded {} files", loaded.len()); + /// # Ok::<(), cursebreaker_parser::Error>(()) + /// ``` + pub fn load_directory(&mut self, dir: impl AsRef) -> Result> { + let mut loaded_files = Vec::new(); + + for entry in walkdir::WalkDir::new(dir) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if let Some(ext) = path.extension() { + let ext_str = ext.to_string_lossy(); + if ext_str == "unity" || ext_str == "prefab" || ext_str == "asset" { + match self.load_file(path) { + Ok(_) => loaded_files.push(path.to_path_buf()), + Err(e) => { + // Graceful degradation: log warning and continue + eprintln!("Warning: Failed to load {:?}: {}", path, e); + } + } + } + } + } + + Ok(loaded_files) + } + + /// Get a document by its file ID (eager resolution within same file) + /// + /// This performs instant lookup using the file ID index. + /// + /// # Arguments + /// + /// * `file_id` - The FileID to look up + /// + /// # Returns + /// + /// The UnityDocument if found, None otherwise + /// + /// # Examples + /// + /// ```no_run + /// use cursebreaker_parser::{UnityProject, types::FileID}; + /// + /// let project = UnityProject::new(100); + /// let doc = project.get_document(FileID::from_i64(12345)); + /// # Ok::<(), cursebreaker_parser::Error>(()) + /// ``` + pub fn get_document(&self, file_id: FileID) -> Option<&UnityDocument> { + let (path, idx) = self.file_id_index.get(&file_id)?; + self.files.get(path)?.documents.get(*idx) + } + + /// Resolve a reference (with caching) + /// + /// Handles three types of references: + /// - Null references: Return Ok(None) + /// - Local references: Eager lookup via file_id_index + /// - External references: Lazy resolution via GUID with LRU caching + /// + /// # Arguments + /// + /// * `reference` - The UnityReference to resolve + /// + /// # Returns + /// + /// The resolved UnityDocument if found, None if not found (including null refs) + /// + /// # Examples + /// + /// ```no_run + /// use cursebreaker_parser::{UnityProject, types::{UnityReference, FileID}}; + /// + /// let mut project = UnityProject::new(100); + /// let reference = UnityReference::Local(FileID::from_i64(12345)); + /// let doc = project.resolve_reference(&reference)?; + /// # Ok::<(), cursebreaker_parser::Error>(()) + /// ``` + pub fn resolve_reference( + &mut self, + reference: &UnityReference, + ) -> Result> { + match reference { + UnityReference::Null => Ok(None), + + UnityReference::Local(file_id) => { + // Eager resolution for same-file references (instant lookup) + Ok(self.get_document(*file_id)) + } + + UnityReference::External { guid, .. } => { + // Check cache first + if let Some(cached) = self.reference_cache.get(reference) { + if let Some((path, idx)) = cached { + let file = match self.files.get(path) { + Some(f) => f, + None => return Ok(None), + }; + return Ok(file.documents.get(*idx)); + } else { + return Ok(None); // Cached as unresolved + } + } + + // Lazy resolution for cross-file references + let result = self.resolve_external_guid(guid)?; + + // Cache the result (even if None) + self.reference_cache.put(reference.clone(), result.clone()); + + if let Some((path, idx)) = result { + let file = match self.files.get(&path) { + Some(f) => f, + None => return Ok(None), + }; + Ok(file.documents.get(idx)) + } else { + Ok(None) + } + } + } + } + + /// Resolve an external GUID reference (lazy, on-demand) + /// + /// Looks up the file path by GUID and returns the document index. + /// Logs a warning for unknown GUIDs (graceful degradation per user requirement). + /// + /// # Arguments + /// + /// * `guid` - The GUID to resolve + /// + /// # Returns + /// + /// Optional (PathBuf, document index) tuple + fn resolve_external_guid(&self, guid: &str) -> Result> { + // Look up the file path by GUID + let path = match self.guid_to_path.get(guid) { + Some(p) => p, + None => { + // Log warning for unknown GUID (graceful degradation per user requirement) + eprintln!("Warning: Failed to resolve external GUID: {}", guid); + return Ok(None); + } + }; + + // For now, return the first document in the file + // TODO: Enhance to support specific file ID within external file + Ok(Some((path.clone(), 0))) + } + + /// Get all loaded files in the project + /// + /// # Examples + /// + /// ```no_run + /// use cursebreaker_parser::UnityProject; + /// + /// let project = UnityProject::new(100); + /// println!("Project has {} files", project.files().len()); + /// ``` + pub fn files(&self) -> &HashMap { + &self.files + } + + /// Get a specific file by path + /// + /// # Arguments + /// + /// * `path` - Path to the Unity file + /// + /// # Examples + /// + /// ```no_run + /// use cursebreaker_parser::UnityProject; + /// + /// let project = UnityProject::new(100); + /// if let Some(file) = project.get_file("Assets/Scenes/MainMenu.unity") { + /// println!("File has {} documents", file.documents.len()); + /// } + /// ``` + pub fn get_file(&self, path: impl AsRef) -> Option<&UnityFile> { + self.files.get(path.as_ref()) + } + + /// Get the cache limit + pub fn cache_limit(&self) -> usize { + self.cache_limit + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_project() { + let project = UnityProject::new(100); + assert_eq!(project.cache_limit(), 100); + assert_eq!(project.files().len(), 0); + } + + #[test] + fn test_load_single_file() { + let mut project = UnityProject::new(100); + let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab"; + + if Path::new(path).exists() { + let result = project.load_file(path); + assert!(result.is_ok(), "Failed to load file: {:?}", result.err()); + assert_eq!(project.files().len(), 1); + } + } + + #[test] + fn test_load_directory() { + let mut project = UnityProject::new(100); + let dir = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand"; + + if Path::new(dir).exists() { + let result = project.load_directory(dir); + assert!(result.is_ok(), "Failed to load directory: {:?}", result.err()); + + if let Ok(loaded) = result { + assert!(loaded.len() > 0, "Should have loaded at least one file"); + } + } + } + + #[test] + fn test_get_document() { + let mut project = UnityProject::new(100); + let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab"; + + if Path::new(path).exists() { + project.load_file(path).unwrap(); + + // Get the first document's file ID + if let Some(file) = project.files().values().next() { + if let Some(first_doc) = file.documents.first() { + let file_id = first_doc.file_id; + + // Test get_document + let found = project.get_document(file_id); + assert!(found.is_some(), "Should find document by file ID"); + assert_eq!(found.unwrap().file_id, file_id); + } + } + } + } + + #[test] + fn test_resolve_null_reference() { + let mut project = UnityProject::new(100); + let reference = UnityReference::Null; + + let result = project.resolve_reference(&reference); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_resolve_local_reference() { + let mut project = UnityProject::new(100); + let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab"; + + if Path::new(path).exists() { + project.load_file(path).unwrap(); + + // Get a valid FileID from the loaded file + if let Some(file) = project.files().values().next() { + if let Some(doc) = file.documents.first() { + let file_id = doc.file_id; + let reference = UnityReference::Local(file_id); + + let result = project.resolve_reference(&reference); + assert!(result.is_ok()); + assert!(result.unwrap().is_some()); + } + } + } + } + + #[test] + fn test_resolve_broken_reference() { + let mut project = UnityProject::new(100); + let reference = UnityReference::Local(FileID::from_i64(999999999)); + + let result = project.resolve_reference(&reference); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } +} diff --git a/src/project/query.rs b/src/project/query.rs new file mode 100644 index 0000000..b54e0bb --- /dev/null +++ b/src/project/query.rs @@ -0,0 +1,280 @@ +//! Query helper methods for UnityProject +//! +//! This module extends UnityProject with convenient query methods +//! for finding and filtering Unity objects. + +use crate::{UnityDocument, UnityProject}; + +impl UnityProject { + /// Find all documents of a specific type across all files + /// + /// # Arguments + /// + /// * `type_id` - Unity type ID to search for + /// + /// # Examples + /// + /// ```no_run + /// use cursebreaker_parser::UnityProject; + /// + /// let project = UnityProject::new(100); + /// let game_objects = project.find_all_by_type(1); // GameObject = type 1 + /// println!("Found {} GameObjects", game_objects.len()); + /// ``` + pub fn find_all_by_type(&self, type_id: u32) -> Vec<&UnityDocument> { + self.files + .values() + .flat_map(|file| file.get_documents_by_type(type_id)) + .collect() + } + + /// Find all documents with a specific class name across all files + /// + /// # Arguments + /// + /// * `class_name` - Unity class name to search for (e.g., "GameObject", "Transform") + /// + /// # Examples + /// + /// ```no_run + /// use cursebreaker_parser::UnityProject; + /// + /// let project = UnityProject::new(100); + /// let transforms = project.find_all_by_class("Transform"); + /// println!("Found {} Transforms", transforms.len()); + /// ``` + pub fn find_all_by_class(&self, class_name: &str) -> Vec<&UnityDocument> { + self.files + .values() + .flat_map(|file| file.get_documents_by_class(class_name)) + .collect() + } + + /// Find documents by name (searches for m_Name property) + /// + /// Searches for GameObjects with a specific m_Name value. + /// + /// # Arguments + /// + /// * `name` - The name to search for + /// + /// # Examples + /// + /// ```no_run + /// use cursebreaker_parser::UnityProject; + /// + /// let project = UnityProject::new(100); + /// let players = project.find_by_name("Player"); + /// for player in players { + /// println!("Found Player at FileID: {}", player.file_id); + /// } + /// ``` + pub fn find_by_name(&self, name: &str) -> Vec<&UnityDocument> { + self.files + .values() + .flat_map(|file| &file.documents) + .filter(|doc| { + // Check for m_Name in the root object + if let Some(obj) = doc.get(&doc.class_name).and_then(|v| v.as_object()) { + if let Some(doc_name) = obj.get("m_Name").and_then(|v| v.as_str()) { + return doc_name == name; + } + } + false + }) + .collect() + } + + /// Get a component from a GameObject by component type + /// + /// Searches the GameObject's m_Component array for a component with the specified class name. + /// + /// # Arguments + /// + /// * `game_object` - The GameObject document + /// * `component_type` - The component class name to find (e.g., "Transform", "SpriteRenderer") + /// + /// # Examples + /// + /// ```no_run + /// use cursebreaker_parser::UnityProject; + /// + /// let project = UnityProject::new(100); + /// # let game_object = &project.find_all_by_type(1)[0]; + /// if let Some(transform) = project.get_component(game_object, "Transform") { + /// println!("Found Transform component"); + /// } + /// ``` + pub fn get_component( + &self, + game_object: &UnityDocument, + component_type: &str, + ) -> Option<&UnityDocument> { + if !game_object.is_game_object() { + return None; + } + + // Get the m_Component array + let components = game_object + .get("GameObject") + .and_then(|v| v.as_object())? + .get("m_Component") + .and_then(|v| v.as_array())?; + + // Search for the component type + for comp_entry in components { + if let Some(obj) = comp_entry.as_object() { + if let Some(comp_ref) = obj.get("component").and_then(|v| v.as_file_ref()) { + if let Some(comp_doc) = self.get_document(comp_ref.file_id) { + if comp_doc.class_name == component_type { + return Some(comp_doc); + } + } + } + } + } + + None + } + + /// Get all components from a GameObject + /// + /// Returns all components attached to the specified GameObject. + /// + /// # Arguments + /// + /// * `game_object` - The GameObject document + /// + /// # Examples + /// + /// ```no_run + /// use cursebreaker_parser::UnityProject; + /// + /// let project = UnityProject::new(100); + /// # let game_object = &project.find_all_by_type(1)[0]; + /// let components = project.get_all_components(game_object); + /// for component in components { + /// println!("Component: {}", component.class_name); + /// } + /// ``` + pub fn get_all_components(&self, game_object: &UnityDocument) -> Vec<&UnityDocument> { + if !game_object.is_game_object() { + return Vec::new(); + } + + let components = match game_object + .get("GameObject") + .and_then(|v| v.as_object()) + .and_then(|obj| obj.get("m_Component")) + .and_then(|v| v.as_array()) + { + Some(c) => c, + None => return Vec::new(), + }; + + components + .iter() + .filter_map(|comp_entry| { + comp_entry + .as_object()? + .get("component") + .and_then(|v| v.as_file_ref()) + .and_then(|r| self.get_document(r.file_id)) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_find_all_by_type() { + let mut project = UnityProject::new(100); + let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab"; + + if Path::new(path).exists() { + project.load_file(path).unwrap(); + + // Find all GameObjects (type 1) + let game_objects = project.find_all_by_type(1); + assert!(game_objects.len() > 0, "Should find at least one GameObject"); + } + } + + #[test] + fn test_find_all_by_class() { + let mut project = UnityProject::new(100); + let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab"; + + if Path::new(path).exists() { + project.load_file(path).unwrap(); + + // Find all Transform or RectTransform components (UI prefabs typically use RectTransform) + let transforms = project.find_all_by_class("Transform"); + let rect_transforms = project.find_all_by_class("RectTransform"); + + assert!( + transforms.len() > 0 || rect_transforms.len() > 0, + "Should find at least one Transform or RectTransform" + ); + } + } + + #[test] + fn test_find_by_name() { + let mut project = UnityProject::new(100); + let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab"; + + if Path::new(path).exists() { + project.load_file(path).unwrap(); + + // The prefab should have a root GameObject with the name "CardGrabber" + let results = project.find_by_name("CardGrabber"); + assert!(results.len() > 0, "Should find GameObject named 'CardGrabber'"); + } + } + + #[test] + fn test_get_component() { + let mut project = UnityProject::new(100); + let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab"; + + if Path::new(path).exists() { + project.load_file(path).unwrap(); + + // Find a GameObject + let game_objects = project.find_all_by_type(1); + if let Some(go) = game_objects.first() { + // Every GameObject should have a Transform + let transform = project.get_component(go, "Transform"); + assert!(transform.is_some() || project.get_component(go, "RectTransform").is_some(), + "GameObject should have Transform or RectTransform component"); + } + } + } + + #[test] + fn test_get_all_components() { + let mut project = UnityProject::new(100); + let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab"; + + if Path::new(path).exists() { + project.load_file(path).unwrap(); + + // Find a GameObject + let game_objects = project.find_all_by_type(1); + if let Some(go) = game_objects.first() { + let components = project.get_all_components(go); + assert!(components.len() > 0, "GameObject should have at least one component"); + + // Verify all returned items are actual components + for component in components { + assert!(!component.is_game_object(), "Should not return GameObject as component"); + } + } + } + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs index 5522582..afd450b 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -7,11 +7,15 @@ mod component; mod game_object; mod ids; +mod reference; mod transform; +mod type_registry; mod values; pub use component::{Component, GenericComponent}; pub use game_object::GameObject; pub use ids::{FileID, LocalID}; +pub use reference::UnityReference; pub use transform::{RectTransform, Transform}; +pub use type_registry::{get_class_name, get_type_id}; pub use values::{Color, ExternalRef, FileRef, Quaternion, Vector2, Vector3}; diff --git a/src/types/reference.rs b/src/types/reference.rs new file mode 100644 index 0000000..ea4a95d --- /dev/null +++ b/src/types/reference.rs @@ -0,0 +1,291 @@ +//! Unified reference type for Unity object references +//! +//! This module provides the UnityReference enum which abstracts over +//! local references (FileRef) and external references (ExternalRef). + +use crate::types::{ExternalRef, FileID, FileRef}; + +/// A unified reference type that can be local, external, or null +/// +/// Unity uses different reference patterns: +/// - Local: `{fileID: N}` - references an object in the same file +/// - External: `{fileID: M, guid: XXXX, type: N}` - references an external asset +/// - Null: `{fileID: 0}` - null/empty reference +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum UnityReference { + /// Reference to an object in the same file by FileID + Local(FileID), + + /// Reference to an external asset by GUID + External { + /// The GUID of the external asset + guid: String, + /// The Unity type ID + type_id: i32, + /// Optional resolved FileID within the external file + file_id: Option, + }, + + /// Null reference (fileID: 0) + Null, +} + +impl UnityReference { + /// Create a UnityReference from a FileRef + /// + /// FileID of 0 is treated as a null reference. + /// + /// # Examples + /// + /// ``` + /// use cursebreaker_parser::types::{UnityReference, FileRef, FileID}; + /// + /// // Local reference + /// let file_ref = FileRef::new(FileID::from_i64(12345)); + /// let reference = UnityReference::from_file_ref(&file_ref); + /// assert!(reference.is_local()); + /// + /// // Null reference + /// let null_ref = FileRef::new(FileID::from_i64(0)); + /// let reference = UnityReference::from_file_ref(&null_ref); + /// assert!(reference.is_null()); + /// ``` + pub fn from_file_ref(file_ref: &FileRef) -> Self { + if file_ref.file_id.as_i64() == 0 { + UnityReference::Null + } else { + UnityReference::Local(file_ref.file_id) + } + } + + /// Create a UnityReference from an ExternalRef + /// + /// # Examples + /// + /// ``` + /// use cursebreaker_parser::types::{UnityReference, ExternalRef}; + /// + /// let ext_ref = ExternalRef::new("abc123".to_string(), 2); + /// let reference = UnityReference::from_external_ref(&ext_ref); + /// assert!(reference.is_external()); + /// ``` + pub fn from_external_ref(ext_ref: &ExternalRef) -> Self { + UnityReference::External { + guid: ext_ref.guid.clone(), + type_id: ext_ref.type_id, + file_id: None, + } + } + + /// Check if this is a null reference + /// + /// # Examples + /// + /// ``` + /// use cursebreaker_parser::types::UnityReference; + /// + /// let reference = UnityReference::Null; + /// assert!(reference.is_null()); + /// ``` + pub fn is_null(&self) -> bool { + matches!(self, UnityReference::Null) + } + + /// Check if this is a local reference + /// + /// # Examples + /// + /// ``` + /// use cursebreaker_parser::types::{UnityReference, FileID}; + /// + /// let reference = UnityReference::Local(FileID::from_i64(12345)); + /// assert!(reference.is_local()); + /// ``` + pub fn is_local(&self) -> bool { + matches!(self, UnityReference::Local(_)) + } + + /// Check if this is an external reference + /// + /// # Examples + /// + /// ``` + /// use cursebreaker_parser::types::UnityReference; + /// + /// let reference = UnityReference::External { + /// guid: "abc123".to_string(), + /// type_id: 2, + /// file_id: None, + /// }; + /// assert!(reference.is_external()); + /// ``` + pub fn is_external(&self) -> bool { + matches!(self, UnityReference::External { .. }) + } + + /// Get the FileID for local references + /// + /// Returns None for external or null references. + /// + /// # Examples + /// + /// ``` + /// use cursebreaker_parser::types::{UnityReference, FileID}; + /// + /// let reference = UnityReference::Local(FileID::from_i64(12345)); + /// assert_eq!(reference.as_file_id(), Some(FileID::from_i64(12345))); + /// + /// let null_ref = UnityReference::Null; + /// assert_eq!(null_ref.as_file_id(), None); + /// ``` + pub fn as_file_id(&self) -> Option { + match self { + UnityReference::Local(file_id) => Some(*file_id), + _ => None, + } + } + + /// Get the GUID for external references + /// + /// Returns None for local or null references. + /// + /// # Examples + /// + /// ``` + /// use cursebreaker_parser::types::UnityReference; + /// + /// let reference = UnityReference::External { + /// guid: "abc123".to_string(), + /// type_id: 2, + /// file_id: None, + /// }; + /// assert_eq!(reference.as_guid(), Some("abc123")); + /// ``` + pub fn as_guid(&self) -> Option<&str> { + match self { + UnityReference::External { guid, .. } => Some(guid), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_file_ref_local() { + let file_ref = FileRef::new(FileID::from_i64(12345)); + let reference = UnityReference::from_file_ref(&file_ref); + assert!(reference.is_local()); + assert_eq!(reference.as_file_id(), Some(FileID::from_i64(12345))); + } + + #[test] + fn test_from_file_ref_null() { + let file_ref = FileRef::new(FileID::from_i64(0)); + let reference = UnityReference::from_file_ref(&file_ref); + assert!(reference.is_null()); + assert!(!reference.is_local()); + } + + #[test] + fn test_from_external_ref() { + let ext_ref = ExternalRef::new("abc123".to_string(), 2); + let reference = UnityReference::from_external_ref(&ext_ref); + assert!(reference.is_external()); + assert_eq!(reference.as_guid(), Some("abc123")); + } + + #[test] + fn test_null_reference() { + let reference = UnityReference::Null; + assert!(reference.is_null()); + assert!(!reference.is_local()); + assert!(!reference.is_external()); + } + + #[test] + fn test_local_reference() { + let reference = UnityReference::Local(FileID::from_i64(999)); + assert!(reference.is_local()); + assert!(!reference.is_null()); + assert!(!reference.is_external()); + } + + #[test] + fn test_external_reference() { + let reference = UnityReference::External { + guid: "test-guid".to_string(), + type_id: 3, + file_id: None, + }; + assert!(reference.is_external()); + assert!(!reference.is_null()); + assert!(!reference.is_local()); + } + + #[test] + fn test_equality() { + let ref1 = UnityReference::Local(FileID::from_i64(123)); + let ref2 = UnityReference::Local(FileID::from_i64(123)); + let ref3 = UnityReference::Local(FileID::from_i64(456)); + + assert_eq!(ref1, ref2); + assert_ne!(ref1, ref3); + } + + #[test] + fn test_external_equality() { + let ref1 = UnityReference::External { + guid: "abc".to_string(), + type_id: 2, + file_id: None, + }; + let ref2 = UnityReference::External { + guid: "abc".to_string(), + type_id: 2, + file_id: None, + }; + let ref3 = UnityReference::External { + guid: "xyz".to_string(), + type_id: 2, + file_id: None, + }; + + assert_eq!(ref1, ref2); + assert_ne!(ref1, ref3); + } + + #[test] + fn test_as_file_id() { + let local = UnityReference::Local(FileID::from_i64(12345)); + assert_eq!(local.as_file_id(), Some(FileID::from_i64(12345))); + + let null = UnityReference::Null; + assert_eq!(null.as_file_id(), None); + + let external = UnityReference::External { + guid: "test".to_string(), + type_id: 1, + file_id: None, + }; + assert_eq!(external.as_file_id(), None); + } + + #[test] + fn test_as_guid() { + let external = UnityReference::External { + guid: "test-guid".to_string(), + type_id: 1, + file_id: None, + }; + assert_eq!(external.as_guid(), Some("test-guid")); + + let local = UnityReference::Local(FileID::from_i64(123)); + assert_eq!(local.as_guid(), None); + + let null = UnityReference::Null; + assert_eq!(null.as_guid(), None); + } +} diff --git a/src/types/type_registry.rs b/src/types/type_registry.rs new file mode 100644 index 0000000..e2e263c --- /dev/null +++ b/src/types/type_registry.rs @@ -0,0 +1,354 @@ +//! Unity type ID to class name mapping +//! +//! This module provides a centralized registry for mapping Unity type IDs +//! to their corresponding class names and vice versa. + +use once_cell::sync::Lazy; +use std::collections::HashMap; + +/// Unity type ID to class name registry +pub struct UnityTypeRegistry { + type_to_name: HashMap, + name_to_type: HashMap<&'static str, u32>, +} + +impl UnityTypeRegistry { + /// Create a new empty registry + fn new() -> Self { + Self { + type_to_name: HashMap::new(), + name_to_type: HashMap::new(), + } + } + + /// Register a Unity type ID with its class name + fn register(&mut self, type_id: u32, class_name: &'static str) { + self.type_to_name.insert(type_id, class_name); + self.name_to_type.insert(class_name, type_id); + } + + /// Get the class name for a Unity type ID + pub fn get_class_name(&self, type_id: u32) -> Option<&'static str> { + self.type_to_name.get(&type_id).copied() + } + + /// Get the type ID for a Unity class name + pub fn get_type_id(&self, class_name: &str) -> Option { + self.name_to_type.get(class_name).copied() + } +} + +/// Global Unity type registry (lazily initialized) +pub static UNITY_TYPE_REGISTRY: Lazy = Lazy::new(|| { + let mut registry = UnityTypeRegistry::new(); + + // Core Unity types + registry.register(1, "GameObject"); + registry.register(2, "Component"); + registry.register(3, "LevelGameManager"); + registry.register(4, "Transform"); + registry.register(5, "TimeManager"); + registry.register(8, "Behaviour"); + registry.register(9, "GameManager"); + registry.register(11, "AudioManager"); + registry.register(13, "InputManager"); + registry.register(18, "EditorExtension"); + registry.register(19, "Physics2DSettings"); + registry.register(20, "Camera"); + registry.register(21, "Material"); + registry.register(23, "MeshRenderer"); + registry.register(25, "Renderer"); + registry.register(27, "Texture"); + registry.register(28, "Texture2D"); + registry.register(29, "OcclusionCullingSettings"); + registry.register(33, "MeshFilter"); + registry.register(41, "OcclusionPortal"); + registry.register(43, "Mesh"); + registry.register(45, "Skybox"); + registry.register(47, "QualitySettings"); + registry.register(48, "Shader"); + registry.register(49, "TextAsset"); + registry.register(50, "Rigidbody2D"); + registry.register(53, "Collider2D"); + registry.register(54, "Rigidbody"); + registry.register(55, "PhysicMaterial"); + registry.register(56, "Collider"); + registry.register(57, "Joint"); + registry.register(58, "CircleCollider2D"); + registry.register(59, "HingeJoint"); + registry.register(60, "PolygonCollider2D"); + registry.register(61, "BoxCollider2D"); + registry.register(62, "PhysicsMaterial2D"); + registry.register(64, "MeshCollider"); + registry.register(65, "BoxCollider"); + registry.register(68, "EdgeCollider2D"); + registry.register(70, "CapsuleCollider2D"); + registry.register(72, "CompositeCollider2D"); + registry.register(74, "AnimationClip"); + registry.register(75, "ConstantForce"); + registry.register(81, "AudioListener"); + registry.register(82, "AudioSource"); + registry.register(83, "AudioClip"); + registry.register(84, "RenderTexture"); + registry.register(87, "MeshParticleEmitter"); + registry.register(88, "ParticleEmitter"); + registry.register(89, "Cubemap"); + registry.register(90, "Avatar"); + registry.register(91, "AnimatorController"); + registry.register(92, "GUILayer"); + registry.register(93, "RuntimeAnimatorController"); + registry.register(94, "ScriptMapper"); + registry.register(95, "Animator"); + registry.register(96, "TrailRenderer"); + registry.register(98, "TextMesh"); + registry.register(102, "TextureImporter"); + registry.register(104, "RenderSettings"); + registry.register(108, "Light"); + registry.register(109, "CGProgram"); + registry.register(110, "BaseAnimationTrack"); + registry.register(111, "Animation"); + registry.register(114, "MonoBehaviour"); + registry.register(115, "MonoScript"); + registry.register(116, "MonoManager"); + registry.register(117, "Texture3D"); + registry.register(118, "NewAnimationTrack"); + registry.register(119, "Projector"); + registry.register(120, "LineRenderer"); + registry.register(121, "Flare"); + registry.register(122, "Halo"); + registry.register(123, "LensFlare"); + registry.register(124, "FlareLayer"); + registry.register(125, "HaloLayer"); + registry.register(126, "NavMeshAreas"); + registry.register(127, "HaloManager"); + registry.register(128, "Font"); + registry.register(129, "PlayerSettings"); + registry.register(130, "NamedObject"); + registry.register(131, "GUITexture"); + registry.register(132, "GUIText"); + registry.register(133, "GUIElement"); + registry.register(134, "PhysicMaterial"); + registry.register(135, "SphereCollider"); + registry.register(136, "CapsuleCollider"); + registry.register(137, "SkinnedMeshRenderer"); + registry.register(138, "FixedJoint"); + registry.register(141, "BuildSettings"); + registry.register(143, "AssetBundle"); + registry.register(144, "CharacterController"); + registry.register(145, "CharacterJoint"); + registry.register(146, "SpringJoint"); + registry.register(147, "WheelCollider"); + registry.register(148, "ResourceManager"); + registry.register(149, "NetworkView"); + registry.register(150, "NetworkManager"); + registry.register(152, "MovieTexture"); + registry.register(153, "ConfigurableJoint"); + registry.register(154, "TerrainCollider"); + registry.register(155, "MasterServerInterface"); + registry.register(156, "TerrainData"); + registry.register(157, "LightmapSettings"); + registry.register(158, "WebCamTexture"); + registry.register(159, "EditorSettings"); + registry.register(162, "EditorUserSettings"); + registry.register(164, "AudioReverbFilter"); + registry.register(165, "AudioHighPassFilter"); + registry.register(166, "AudioChorusFilter"); + registry.register(167, "AudioReverbZone"); + registry.register(168, "AudioEchoFilter"); + registry.register(169, "AudioLowPassFilter"); + registry.register(170, "AudioDistortionFilter"); + registry.register(171, "SparseTexture"); + registry.register(180, "AudioBehaviour"); + registry.register(181, "AudioFilter"); + registry.register(182, "WindZone"); + registry.register(183, "Cloth"); + registry.register(184, "SubstanceArchive"); + registry.register(185, "ProceduralMaterial"); + registry.register(186, "ProceduralTexture"); + registry.register(191, "OffMeshLink"); + registry.register(192, "OcclusionArea"); + registry.register(193, "Tree"); + registry.register(194, "NavMeshObsolete"); + registry.register(195, "NavMeshAgent"); + registry.register(196, "NavMeshSettings"); + registry.register(197, "LightProbesLegacy"); + registry.register(198, "ParticleSystem"); + registry.register(199, "ParticleSystemRenderer"); + registry.register(200, "ShaderVariantCollection"); + registry.register(205, "LODGroup"); + registry.register(206, "BlendTree"); + registry.register(207, "Motion"); + registry.register(208, "NavMeshObstacle"); + registry.register(210, "TerrainInstance"); + registry.register(212, "SpriteRenderer"); + registry.register(213, "Sprite"); + registry.register(214, "CachedSpriteAtlas"); + registry.register(215, "ReflectionProbe"); + registry.register(218, "Terrain"); + registry.register(220, "LightProbeGroup"); + registry.register(221, "AnimatorOverrideController"); + registry.register(222, "CanvasRenderer"); + registry.register(223, "Canvas"); + registry.register(224, "RectTransform"); + registry.register(225, "CanvasGroup"); + registry.register(226, "BillboardAsset"); + registry.register(227, "BillboardRenderer"); + registry.register(228, "SpeedTreeWindAsset"); + registry.register(229, "AnchoredJoint2D"); + registry.register(230, "Joint2D"); + registry.register(231, "SpringJoint2D"); + registry.register(232, "DistanceJoint2D"); + registry.register(233, "HingeJoint2D"); + registry.register(234, "SliderJoint2D"); + registry.register(235, "WheelJoint2D"); + registry.register(238, "NavMeshData"); + registry.register(240, "AudioMixer"); + registry.register(241, "AudioMixerController"); + registry.register(243, "AudioMixerGroupController"); + registry.register(244, "AudioMixerEffectController"); + registry.register(245, "AudioMixerSnapshotController"); + registry.register(246, "PhysicsUpdateBehaviour2D"); + registry.register(247, "ConstantForce2D"); + registry.register(248, "Effector2D"); + registry.register(249, "AreaEffector2D"); + registry.register(250, "PointEffector2D"); + registry.register(251, "PlatformEffector2D"); + registry.register(252, "SurfaceEffector2D"); + registry.register(258, "LightProbes"); + registry.register(271, "SampleClip"); + registry.register(272, "AudioMixerSnapshot"); + registry.register(273, "AudioMixerGroup"); + registry.register(290, "AssetBundleManifest"); + registry.register(1001, "PrefabInstance"); + registry.register(1002, "EditorExtensionImpl"); + registry.register(1003, "AssetImporter"); + registry.register(1004, "AssetDatabase"); + registry.register(1005, "Mesh3DSImporter"); + registry.register(1006, "TextureImporter"); + registry.register(1007, "ShaderImporter"); + registry.register(1008, "ComputeShaderImporter"); + registry.register(1020, "AudioImporter"); + registry.register(1026, "HierarchyState"); + registry.register(1027, "GUIDSerializer"); + registry.register(1028, "AssetMetaData"); + registry.register(1029, "DefaultAsset"); + registry.register(1030, "DefaultImporter"); + registry.register(1031, "TextScriptImporter"); + registry.register(1032, "SceneAsset"); + registry.register(1034, "NativeFormatImporter"); + registry.register(1035, "MonoImporter"); + registry.register(1038, "LibraryAssetImporter"); + registry.register(1040, "ModelImporter"); + registry.register(1041, "FBXImporter"); + registry.register(1042, "TrueTypeFontImporter"); + registry.register(1045, "MovieImporter"); + registry.register(1050, "EditorBuildSettings"); + registry.register(1051, "DDSImporter"); + registry.register(1052, "InspectorExpandedState"); + registry.register(1053, "AnnotationManager"); + registry.register(1055, "MonoManager"); + registry.register(1101, "AnimatorStateMachine"); + registry.register(1102, "AnimatorState"); + registry.register(1105, "AnimatorStateTransition"); + registry.register(1107, "AnimatorTransition"); + + registry +}); + +/// Get the class name for a Unity type ID +/// +/// Returns None if the type ID is not registered. +/// +/// # Examples +/// +/// ``` +/// use cursebreaker_parser::types::get_class_name; +/// +/// assert_eq!(get_class_name(1), Some("GameObject")); +/// assert_eq!(get_class_name(4), Some("Transform")); +/// assert_eq!(get_class_name(999999), None); +/// ``` +pub fn get_class_name(type_id: u32) -> Option<&'static str> { + UNITY_TYPE_REGISTRY.get_class_name(type_id) +} + +/// Get the type ID for a Unity class name +/// +/// Returns None if the class name is not registered. +/// +/// # Examples +/// +/// ``` +/// use cursebreaker_parser::types::get_type_id; +/// +/// assert_eq!(get_type_id("GameObject"), Some(1)); +/// assert_eq!(get_type_id("Transform"), Some(4)); +/// assert_eq!(get_type_id("UnknownType"), None); +/// ``` +pub fn get_type_id(class_name: &str) -> Option { + UNITY_TYPE_REGISTRY.get_type_id(class_name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_class_name_game_object() { + assert_eq!(get_class_name(1), Some("GameObject")); + } + + #[test] + fn test_get_class_name_transform() { + assert_eq!(get_class_name(4), Some("Transform")); + } + + #[test] + fn test_get_class_name_rect_transform() { + assert_eq!(get_class_name(224), Some("RectTransform")); + } + + #[test] + fn test_get_class_name_mono_behaviour() { + assert_eq!(get_class_name(114), Some("MonoBehaviour")); + } + + #[test] + fn test_get_class_name_sprite_renderer() { + assert_eq!(get_class_name(212), Some("SpriteRenderer")); + } + + #[test] + fn test_get_class_name_unknown() { + assert_eq!(get_class_name(999999), None); + } + + #[test] + fn test_get_type_id_game_object() { + assert_eq!(get_type_id("GameObject"), Some(1)); + } + + #[test] + fn test_get_type_id_transform() { + assert_eq!(get_type_id("Transform"), Some(4)); + } + + #[test] + fn test_get_type_id_rect_transform() { + assert_eq!(get_type_id("RectTransform"), Some(224)); + } + + #[test] + fn test_get_type_id_unknown() { + assert_eq!(get_type_id("UnknownType"), None); + } + + #[test] + fn test_bidirectional_mapping() { + // Test that type_id -> name -> type_id works + let type_id = 114; + let class_name = get_class_name(type_id).unwrap(); + assert_eq!(class_name, "MonoBehaviour"); + assert_eq!(get_type_id(class_name), Some(type_id)); + } +}