This commit is contained in:
2025-12-31 18:28:45 +09:00
parent 8baafbdb0c
commit e8464d3f74
13 changed files with 1871 additions and 213 deletions

View File

@@ -4,7 +4,9 @@
"Bash(cat:*)", "Bash(cat:*)",
"Bash(cargo build:*)", "Bash(cargo build:*)",
"Bash(cargo test:*)", "Bash(cargo test:*)",
"Bash(cargo run:*)" "Bash(cargo run:*)",
"Bash(cargo tree:*)",
"WebFetch(domain:docs.rs)"
] ]
} }
} }

78
Cargo.lock generated
View File

@@ -11,6 +11,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "atomic_refcell" name = "atomic_refcell"
version = "0.1.13" version = "0.1.13"
@@ -23,12 +29,15 @@ version = "0.1.0"
dependencies = [ dependencies = [
"glam", "glam",
"indexmap", "indexmap",
"lru",
"once_cell",
"pretty_assertions", "pretty_assertions",
"regex", "regex",
"serde", "serde",
"serde_yaml", "serde_yaml",
"sparsey", "sparsey",
"thiserror", "thiserror",
"walkdir",
] ]
[[package]] [[package]]
@@ -43,6 +52,12 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "glam" name = "glam"
version = "0.29.3" version = "0.29.3"
@@ -57,6 +72,11 @@ name = "hashbrown"
version = "0.15.5" version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
@@ -82,12 +102,27 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" 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]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.6" version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]] [[package]]
name = "pretty_assertions" name = "pretty_assertions"
version = "1.4.1" version = "1.4.1"
@@ -157,6 +192,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" 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]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@@ -254,6 +298,40 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 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]] [[package]]
name = "yansi" name = "yansi"
version = "1.0.1" version = "1.0.1"

View File

@@ -34,6 +34,15 @@ glam = { version = "0.29", features = ["serde"] }
# ECS (Entity Component System) # ECS (Entity Component System)
sparsey = "0.13" 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] [dev-dependencies]
# Testing utilities # Testing utilities
pretty_assertions = "1.4" pretty_assertions = "1.4"

View File

@@ -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<dyn std::error::Error>> {
// 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.

View File

@@ -69,49 +69,49 @@ This roadmap breaks down the development into 5 phases, each building on the pre
### Tasks ### Tasks
1. **Core Data Structures** 1. **Core Data Structures**
- [ ] Implement `UnityDocument` struct - [x] Implement `UnityDocument` struct
- [ ] Implement `UnityFile` struct - [x] Implement `UnityFile` struct
- [ ] Create property storage (PropertyMap using IndexMap) - [x] Create property storage (PropertyMap using IndexMap)
- [ ] Define FileID and LocalID types - [x] Define FileID and LocalID types
2. **Property Value Types** 2. **Property Value Types**
- [ ] Implement `PropertyValue` enum (Integer, Float, String, Boolean, etc.) - [x] Implement `PropertyValue` enum (Integer, Float, String, Boolean, etc.)
- [ ] Add Vector3, Color, Quaternion value types - [x] Add Vector3, Color, Quaternion value types
- [ ] Add Array and nested Object support - [x] Add Array and nested Object support
- [ ] Implement Debug and Display for PropertyValue - [x] Implement Debug and Display for PropertyValue
3. **Property Parser** 3. **Property Parser**
- [ ] Parse YAML mappings into PropertyMap - [x] Parse YAML mappings into PropertyMap
- [ ] Handle nested properties (paths like `m_Component[0].component`) - [x] Handle nested properties (paths like `m_Component[0].component`)
- [ ] Parse Unity-specific formats: - [x] Parse Unity-specific formats:
- [ ] `{fileID: N}` references - [x] `{fileID: N}` references
- [ ] `{x: 0, y: 0, z: 0}` vectors - [x] `{x: 0, y: 0, z: 0}` vectors
- [ ] `{r: 1, g: 1, b: 1, a: 1}` colors - [x] `{r: 1, g: 1, b: 1, a: 1}` colors
- [ ] `{guid: ..., type: N}` external references - [x] `{guid: ..., type: N}` external references
4. **GameObject & Component Models** 4. **GameObject & Component Models**
- [ ] Create specialized GameObject struct - [x] Create specialized GameObject struct
- [ ] Create base Component trait/struct - [x] Create base Component trait/struct
- [ ] Add common component types (Transform, RectTransform, etc.) - [x] Add common component types (Transform, RectTransform, etc.)
- [ ] Helper methods for accessing common properties - [x] Helper methods for accessing common properties
5. **Testing** 5. **Testing**
- [ ] Unit tests for property parsing - [x] Unit tests for property parsing
- [ ] Test all PropertyValue variants - [x] Test all PropertyValue variants
- [ ] Integration test: parse GameObject with components - [x] Integration test: parse GameObject with components
- [ ] Snapshot tests using sample Unity files - [x] Snapshot tests using sample Unity files
### Deliverables ### Deliverables
- [ ] ✓ Complete data model implemented - [x] ✓ Complete data model implemented
- [ ] ✓ Properties parsed into type-safe structures - [x] ✓ Properties parsed into type-safe structures
- [ ] ✓ GameObject and Component abstractions working - [x] ✓ GameObject and Component abstractions working
- [ ] ✓ All property types handled correctly - [x] ✓ All property types handled correctly
### Success Criteria ### Success Criteria
- [ ] Parse entire `CardGrabber.prefab` correctly - [x] Parse entire `CardGrabber.prefab` correctly
- [ ] Extract all GameObject properties (name, components list) - [x] Extract all GameObject properties (name, components list)
- [ ] Extract all Component properties with correct types - [x] Extract all Component properties with correct types
- [ ] Can access nested properties programmatically - [x] Can access nested properties programmatically
--- ---

352
src/ecs/mod.rs Normal file
View File

@@ -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<FileID, Entity>,
}
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<FileID, Entity>)> {
// 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<Entity> {
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<Entity>, Vec<Entity>)> = 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<Entity> = 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::<Transform>(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<FileID, Entity>)> {
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");
}
}
}
}

View File

@@ -54,6 +54,22 @@ pub enum Error {
/// Invalid property path /// Invalid property path
#[error("Invalid property path: {0}")] #[error("Invalid property path: {0}")]
InvalidPropertyPath(String), 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 { impl Error {
@@ -71,4 +87,24 @@ impl Error {
pub fn property_not_found(msg: impl Into<String>) -> Self { pub fn property_not_found(msg: impl Into<String>) -> Self {
Error::PropertyNotFound(msg.into()) Error::PropertyNotFound(msg.into())
} }
/// Create a GUID resolution error
pub fn guid_resolution_error(msg: impl Into<String>) -> 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<String>) -> Self {
Error::WorldBuildError(msg.into())
}
} }

View File

@@ -16,18 +16,23 @@
//! ``` //! ```
// Public modules // Public modules
pub mod ecs;
pub mod error; pub mod error;
pub mod model; pub mod model;
pub mod parser; pub mod parser;
pub mod project;
pub mod property; pub mod property;
pub mod types; pub mod types;
// Re-exports // Re-exports
pub use ecs::{build_world_from_project, GameObjectComponent, WorldBuilder};
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use model::{UnityDocument, UnityFile}; pub use model::{UnityDocument, UnityFile};
pub use parser::parse_unity_file; pub use parser::parse_unity_file;
pub use project::UnityProject;
pub use property::PropertyValue; pub use property::PropertyValue;
pub use types::{ pub use types::{
Color, Component, ExternalRef, FileID, FileRef, GameObject, GenericComponent, LocalID, get_class_name, get_type_id, Color, Component, ExternalRef, FileID, FileRef, GameObject,
Quaternion, RectTransform, Transform, Vector2, Vector3, GenericComponent, LocalID, Quaternion, RectTransform, Transform, UnityReference, Vector2,
Vector3,
}; };

426
src/project/mod.rs Normal file
View File

@@ -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<PathBuf, UnityFile>,
/// GUID to file path mapping for cross-file reference resolution
guid_to_path: HashMap<String, PathBuf>,
/// FileID to (PathBuf, document index) mapping for fast lookups
file_id_index: HashMap<FileID, (PathBuf, usize)>,
/// LRU cache for resolved references
reference_cache: LruCache<UnityReference, Option<(PathBuf, usize)>>,
/// 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<PathBuf>) -> 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<Path>) -> Result<Vec<PathBuf>> {
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<Option<&UnityDocument>> {
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<Option<(PathBuf, usize)>> {
// 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<PathBuf, UnityFile> {
&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<Path>) -> 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());
}
}

280
src/project/query.rs Normal file
View File

@@ -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");
}
}
}
}
}

View File

@@ -7,11 +7,15 @@
mod component; mod component;
mod game_object; mod game_object;
mod ids; mod ids;
mod reference;
mod transform; mod transform;
mod type_registry;
mod values; mod values;
pub use component::{Component, GenericComponent}; pub use component::{Component, GenericComponent};
pub use game_object::GameObject; pub use game_object::GameObject;
pub use ids::{FileID, LocalID}; pub use ids::{FileID, LocalID};
pub use reference::UnityReference;
pub use transform::{RectTransform, Transform}; pub use transform::{RectTransform, Transform};
pub use type_registry::{get_class_name, get_type_id};
pub use values::{Color, ExternalRef, FileRef, Quaternion, Vector2, Vector3}; pub use values::{Color, ExternalRef, FileRef, Quaternion, Vector2, Vector3};

291
src/types/reference.rs Normal file
View File

@@ -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<FileID>,
},
/// 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<FileID> {
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);
}
}

354
src/types/type_registry.rs Normal file
View File

@@ -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<u32, &'static str>,
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<u32> {
self.name_to_type.get(class_name).copied()
}
}
/// Global Unity type registry (lazily initialized)
pub static UNITY_TYPE_REGISTRY: Lazy<UnityTypeRegistry> = 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<u32> {
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));
}
}