Compare commits

...

8 Commits

Author SHA1 Message Date
8a06185b98 cleanup 2026-01-03 14:51:31 +00:00
bfd451aca9 project restructure 2026-01-03 14:04:12 +00:00
ff3c092d9e early yaml exit 2026-01-03 13:39:28 +00:00
0552b4dff0 prefab instanciation 2026-01-03 12:06:47 +00:00
cd35339151 regex for file parsing 2026-01-03 04:49:21 +00:00
6f40cb9177 meta files phase 3 2026-01-03 04:09:19 +00:00
673fa114fa meta phase 1 2026-01-03 03:54:17 +00:00
7e3d338373 working demo part 1 2026-01-02 15:11:14 +00:00
50 changed files with 4570 additions and 1437 deletions

View File

@@ -10,7 +10,12 @@
"Bash(findstr:*)",
"Bash(cargo check:*)",
"Bash(ls:*)",
"Bash(find:*)"
"Bash(find:*)",
"Bash(grep:*)",
"Bash(wc:*)"
],
"additionalDirectories": [
"/home/connor/repos/CBAssets/"
]
}
}

56
Cargo.lock generated
View File

@@ -23,34 +23,6 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c"
[[package]]
name = "cursebreaker-parser"
version = "0.1.0"
dependencies = [
"cursebreaker-parser-macros",
"glam",
"indexmap",
"inventory",
"lru",
"once_cell",
"pretty_assertions",
"regex",
"serde",
"serde_yaml",
"sparsey",
"thiserror",
"walkdir",
]
[[package]]
name = "cursebreaker-parser-macros"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "diff"
version = "0.1.13"
@@ -318,6 +290,34 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unity-parser"
version = "0.1.0"
dependencies = [
"glam",
"indexmap",
"inventory",
"lru",
"once_cell",
"pretty_assertions",
"regex",
"serde",
"serde_yaml",
"sparsey",
"thiserror",
"unity-parser-macros",
"walkdir",
]
[[package]]
name = "unity-parser-macros"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"

View File

@@ -1,10 +1,10 @@
[workspace]
members = ["cursebreaker-parser", "cursebreaker-parser-macros"]
members = ["unity-parser", "unity-parser-macros"]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
authors = ["connordemeyer@gmail.com"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/yourusername/cursebreaker-parser-rust"
repository = "https://github.com/yourusername/unity-parser-rust"

403
README.md Normal file
View File

@@ -0,0 +1,403 @@
# Cursebreaker Unity Parser
A high-performance Rust library for parsing Unity project files (.unity scenes, .prefab prefabs, and .asset files) with automatic MonoBehaviour component discovery and ECS integration.
[![Rust](https://img.shields.io/badge/rust-1.70%2B-orange.svg)](https://www.rust-lang.org/)
[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](LICENSE)
**⚠️ Work in Progress**: This library is under active development. APIs may change.
## Features
### Core Parsing
- **Multi-format support**: Parse `.unity` scenes, `.prefab` prefabs, and `.asset` files
- **ECS integration**: Automatically builds [Sparsey](https://github.com/LechintanTudor/sparsey) ECS worlds from scenes
- **Type-safe**: Strong typing for Unity primitives (Vector3, Quaternion, Color, etc.)
- **Fast**: Efficient YAML parsing with minimal allocations
### Component System
- **Derive macro**: `#[derive(UnityComponent)]` for automatic component parsing
- **Auto-registration**: Components register themselves via [inventory](https://github.com/dtolnay/inventory)
- **GUID resolution**: Automatically resolves MonoBehaviour script GUIDs to class names
- **Prefab resolution**: Resolves prefab GUIDs for nested prefab references
- **Type filtering**: Selectively parse only the components you need
### Advanced Features
- **Prefab instantiation**: Clone and modify prefab instances (basic support)
- **Reference resolution**: FileID → Entity mapping
- **Regex filtering**: Parse only files matching specific patterns
- **Transform hierarchies**: Parent-child relationships preserved
- **Memory efficient**: 128-bit GUIDs stored as u128 (16 bytes vs ~56 bytes for String)
## Installation
Add to your `Cargo.toml`:
```toml
[dependencies]
unity_parser = { path = "unity-parser" }
sparsey = "0.13" # For ECS queries
```
## Quick Start
### Parse a Unity Scene
```rust
use unity_parser::UnityFile;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let file = UnityFile::from_path("Scene.unity")?;
match file {
UnityFile::Scene(scene) => {
println!("Scene with {} entities", scene.entity_map.len());
// Query transforms
let transforms = scene.world.borrow::<unity_parser::Transform>();
for (file_id, entity) in &scene.entity_map {
if let Some(transform) = transforms.get(*entity) {
println!("Entity {} at {:?}", file_id, transform.local_position());
}
}
}
UnityFile::Prefab(prefab) => {
println!("Prefab with {} documents", prefab.documents.len());
}
UnityFile::Asset(asset) => {
println!("Asset with {} documents", asset.documents.len());
}
}
Ok(())
}
```
### Parse a Prefab
```rust
use unity_parser::UnityFile;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let file = UnityFile::from_path("Player.prefab")?;
if let UnityFile::Prefab(prefab) = file {
// Find all GameObjects
let game_objects = prefab.get_documents_by_class("GameObject");
for doc in game_objects {
if let Some(mapping) = doc.as_mapping() {
if let Some(name) = mapping.get("m_Name").and_then(|v| v.as_str()) {
println!("GameObject: {}", name);
}
}
}
// Find documents by type ID
let transforms = prefab.get_documents_by_type(4); // Transform = type 4
println!("Found {} Transforms", transforms.len());
}
Ok(())
}
```
### Define Custom Components
**⚠️ Note**: The derive macro currently has namespace issues and may not compile. Manual implementation is recommended until fixed.
```rust
use unity_parser::{UnityComponent, ComponentContext};
use serde_yaml::Mapping;
#[derive(Debug, Clone)]
pub struct PlaySFX {
pub volume: f64,
pub start_time: f64,
pub end_time: f64,
pub is_loop: bool,
}
// Manual implementation (recommended until macro is fixed)
impl UnityComponent for PlaySFX {
fn parse(yaml: &Mapping, _ctx: &ComponentContext) -> Option<Self> {
Some(Self {
volume: unity_parser::yaml_helpers::get_f64(yaml, "volume").unwrap_or(1.0),
start_time: unity_parser::yaml_helpers::get_f64(yaml, "startTime").unwrap_or(0.0),
end_time: unity_parser::yaml_helpers::get_f64(yaml, "endTime").unwrap_or(0.0),
is_loop: unity_parser::yaml_helpers::get_bool(yaml, "isLoop").unwrap_or(false),
})
}
}
```
## GUID Resolution
The parser automatically resolves Unity MonoBehaviour GUIDs to class names:
```
Unity Scene File Rust Code
───────────────── ─────────
MonoBehaviour: Custom component
m_Script: in ECS World
guid: 091c537... ──────────>
volume: 1.0
isLoop: 0
```
### How It Works
1. **Scan**: Parser scans Unity project's `Assets/` for `*.cs.meta` files
2. **Build Map**: Extracts GUIDs from `.meta` files
3. **Extract Class**: Parses `.cs` files to get `class Name : MonoBehaviour`
4. **Resolve**: Maps GUID → Class Name → Registered Component
5. **Parse**: Automatically parses MonoBehaviour YAML into components
The GUID resolver builds automatically when parsing scenes if a Unity project root is detected.
## Type Filtering
Parse only specific Unity types and MonoBehaviours for better performance:
```rust
use unity_parser::{TypeFilter, parse_unity_file_filtered};
use std::collections::HashSet;
// Parse only Transforms and GameObjects
let mut types = HashSet::new();
types.insert("Transform".to_string());
types.insert("GameObject".to_string());
let filter = TypeFilter::with_unity_types(types);
let file = parse_unity_file_filtered(
Path::new("Scene.unity"),
None, // No regex filter
Some(&filter)
)?;
```
## Regex Path Filtering
Parse only files matching specific patterns:
```rust
use regex::Regex;
use unity_parser::parse_unity_file_filtered;
// Only parse production scenes
let filter = Regex::new(r"Assets/Scenes/Production/")?;
let scene = parse_unity_file_filtered(
Path::new("Assets/Scenes/Production/Level1.unity"),
Some(&filter),
None
)?;
```
## Supported Unity Types
### Built-in Components
- ✅ GameObject
- ✅ Transform
- ✅ RectTransform
- ✅ PrefabInstance
### Value Types
- ✅ Vector2, Vector3
- ✅ Quaternion
- ✅ Color
- ✅ FileID, GUID
- ✅ ExternalRef, FileRef
### Custom Components
- ✅ Any `MonoBehaviour` via manual `UnityComponent` implementation
- ⚠️ `#[derive(UnityComponent)]` macro (has namespace bugs, not recommended)
## Architecture
### Component Flow
```
Unity Scene File
Raw YAML Documents
┌─────────────────────┐
│ GUID Resolution │ ← .meta files + .cs files
│ (MonoBehaviour) │ (Script GUID → Class Name)
└─────────────────────┘
┌─────────────────────┐
│ Prefab GUID Res. │ ← .prefab.meta files
│ (Nested Prefabs) │ (Prefab GUID → Prefab Path)
└─────────────────────┘
┌─────────────────────┐
│ Component Registry │ ← UnityComponent trait impls
│ (inventory) │
└─────────────────────┘
┌─────────────────────┐
│ ECS World │ ← Sparsey entities & components
│ (Transform, etc) │
└─────────────────────┘
```
### Memory Efficiency
**GUID Storage**:
- Old approach: `String` (24 bytes stack + 32 bytes heap = 56 bytes)
- Current: `u128` (16 bytes on stack)
- **3.5x memory reduction** for GUID storage
**GUID Comparison**:
- Old: O(n) string comparison (32 characters)
- New: O(1) integer comparison
- **Significant speedup** for HashMap lookups
## Project Structure
```
cursebreaker-parser-rust/
├── unity-parser/ # Main library crate
│ ├── src/
│ │ ├── ecs/ # ECS world building
│ │ │ └── builder.rs
│ │ ├── model/ # UnityFile, Scene, Prefab, Asset
│ │ │ └── mod.rs
│ │ ├── parser/ # YAML parsing & GUID resolution
│ │ │ ├── guid_resolver.rs # Script GUID → Class Name
│ │ │ ├── prefab_guid_resolver.rs # Prefab GUID → Path
│ │ │ ├── meta.rs # .meta file parsing
│ │ │ ├── yaml.rs # YAML document splitting
│ │ │ └── mod.rs
│ │ ├── project/ # ⚠️ OUTDATED - needs refactoring
│ │ │ └── mod.rs
│ │ ├── types/ # Unity types & components
│ │ │ ├── unity_types/
│ │ │ │ ├── game_object.rs
│ │ │ │ ├── transform.rs
│ │ │ │ ├── prefab_instance.rs
│ │ │ │ └── mod.rs
│ │ │ ├── component.rs # UnityComponent trait & helpers
│ │ │ ├── guid.rs # 128-bit GUID type
│ │ │ ├── ids.rs # FileID, LocalID
│ │ │ ├── reference.rs # UnityReference enum
│ │ │ ├── type_filter.rs # TypeFilter for selective parsing
│ │ │ ├── values.rs # Vector3, Quaternion, Color, etc.
│ │ │ └── mod.rs
│ │ ├── error.rs # Error types
│ │ ├── macros.rs
│ │ ├── property/
│ │ └── lib.rs
│ ├── examples/
│ │ ├── basic_parsing.rs
│ │ ├── custom_component.rs
│ │ ├── ecs_integration.rs
│ │ ├── find_playsfx.rs
│ │ ├── parse_resources.rs
│ │ └── parse_resource_prefabs.rs
│ ├── tests/
│ └── Cargo.toml
├── unity-parser-macros/ # Proc macro crate (⚠️ has bugs)
│ ├── src/
│ │ └── lib.rs
│ └── Cargo.toml
├── Cargo.toml # Workspace config
└── README.md
```
## Known Issues
### Critical Issues
1. **`unity-parser/src/project/mod.rs` is OUTDATED**
- Built for old architecture before `UnityFile` enum refactor
- References non-existent `UnityDocument` type (should be `RawDocument`)
- Module is disabled in lib.rs until refactored
2. **Derive macro namespace mismatch**
- `unity-parser-macros` uses `unity_parser` namespace
- Actual crate name is `unity_parser::` (underscore, not hyphen)
- Manual `UnityComponent` implementation recommended
3. **Placeholder values in Cargo.toml**
- Author and repository fields need updating
### Minor Issues
1. Disabled example/test files may reference outdated APIs
2. Some examples may have incorrect YAML access patterns
## Running Examples
```bash
# Parse basic Unity file
cargo run --example basic_parsing
# Custom component parsing (requires Unity project)
cargo run --example custom_component
# ECS integration showcase
cargo run --example ecs_integration
# Find PlaySFX components (requires CBAssets project)
cargo run --example find_playsfx
```
## Testing
```bash
# Unit tests
cargo test --lib
# Integration tests
cargo test --test integration_tests
# All tests
cargo test
```
## Roadmap
### Completed
- ✅ Phase 1: GUID Resolution (Script GUID → Class Name)
- ✅ Phase 2: MonoBehaviour Parser
- ✅ Phase 3: Prefab GUID Resolution
- ✅ Type filtering for selective parsing
- ✅ Regex path filtering
- ✅ Basic prefab instantiation
### In Progress / Needs Work
- 🔧 Refactor `project` module for new architecture
- 🔧 Update disabled example/test files
- 🔧 Fix example code YAML access patterns
### Future Enhancements
- [ ] Full prefab modification system
- [ ] Persistent GUID cache for instant loading
- [ ] Watch mode for live Unity project monitoring
- [ ] More built-in Unity component types
- [ ] Better error messages with line numbers
- [ ] Parallel processing support
- [ ] Cross-platform path handling improvements
## Contributing
Contributions welcome! Areas needing help:
- **Documentation**: API docs, more examples, tutorials
- **Testing**: Integration tests with real Unity projects
- **Performance**: Optimize YAML parsing, parallel processing
## License
Licensed under either of:
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
## Acknowledgments
- **Unity Technologies**: For the YAML-based file format
- **Sparsey**: ECS library for component storage
- **serde_yaml**: YAML parsing foundation
- **inventory**: Compile-time component registration

View File

@@ -1,121 +0,0 @@
//! Example demonstrating GUID resolution with .meta files
//!
//! This example shows how to:
//! - Load Unity files with their .meta files
//! - Access GUID to path mappings
//! - Resolve cross-file references using GUIDs
//!
//! Run with: cargo run --example guid_resolution
use cursebreaker_parser::{UnityProject, MetaFile};
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Unity GUID Resolution Example");
println!("==============================\n");
// Create a new Unity project with LRU cache
let mut project = UnityProject::new(1000);
// Example 1: Parse a .meta file directly
println!("Example 1: Parsing a .meta file");
println!("---------------------------------");
let meta_content = r#"
fileFormatVersion: 2
guid: 4ab6bfb0ff54cdf4c8dd38ca244d6f15
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
"#;
let meta = MetaFile::from_str(meta_content)?;
println!("Parsed GUID: {}", meta.guid());
println!("File format version: {:?}\n", meta.file_format_version());
// Example 2: Load Unity files with automatic .meta parsing
println!("Example 2: Loading Unity files with .meta files");
println!("-------------------------------------------------");
let test_dir = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand";
if Path::new(test_dir).exists() {
// Load all Unity files in the directory
let loaded_files = project.load_directory(test_dir)?;
println!("Loaded {} Unity files", loaded_files.len());
println!("Found {} GUID mappings\n", project.guid_mappings().len());
// Example 3: Inspect GUID mappings
println!("Example 3: GUID to Path Mappings");
println!("---------------------------------");
for (guid, path) in project.guid_mappings().iter().take(5) {
println!("GUID: {} -> {:?}", guid, path.file_name().unwrap());
}
println!();
// Example 4: Look up a file by GUID
println!("Example 4: Looking up files by GUID");
println!("------------------------------------");
if let Some((sample_guid, _)) = project.guid_mappings().iter().next() {
if let Some(path) = project.get_path_by_guid(sample_guid) {
println!("GUID {} resolves to:", sample_guid);
println!(" Path: {:?}", path);
// Get the file
if let Some(file) = project.get_file(path) {
println!(" Documents: {}", file.documents.len());
// Show the first GameObject
for doc in &file.documents {
if doc.is_game_object() {
if let Some(obj) = doc.get("GameObject").and_then(|v| v.as_object()) {
if let Some(name) = obj.get("m_Name").and_then(|v| v.as_str()) {
println!(" Contains GameObject: {}", name);
break;
}
}
}
}
}
}
}
println!();
// Example 5: Cross-file reference resolution (when available)
println!("Example 5: Cross-file Reference Resolution");
println!("-------------------------------------------");
// Find all external references in loaded files
let mut external_ref_count = 0;
for file in project.files().values() {
for doc in &file.documents {
// Scan properties for external references
for value in doc.properties.values() {
if let Some(ext_ref) = value.as_external_ref() {
external_ref_count += 1;
// Try to resolve this GUID
if let Some(target_path) = project.get_path_by_guid(&ext_ref.guid) {
println!("✓ External reference resolved:");
println!(" GUID: {}", ext_ref.guid);
println!(" Target: {:?}", target_path.file_name().unwrap());
}
}
}
}
}
println!("\nFound {} external references in loaded files", external_ref_count);
} else {
println!("Test data directory not found: {}", test_dir);
println!("This example works best with Unity sample project files.");
}
Ok(())
}

View File

@@ -1,231 +0,0 @@
//! ECS world building from Unity documents
use crate::model::RawDocument;
use crate::types::{
yaml_helpers, ComponentContext, FileID, GameObject, LinkingContext, PrefabInstanceComponent,
RectTransform, Transform, TypeFilter, UnityComponent,
};
use crate::{Error, Result};
use sparsey::{Entity, World};
use std::cell::RefCell;
use std::collections::HashMap;
/// Build a Sparsey ECS World from raw Unity documents
///
/// This uses a 3-pass approach:
/// 1. Create entities for all GameObjects
/// 2. Attach components (Transform, RectTransform, etc.) to entities
/// 3. Resolve Transform hierarchy (parent/children Entity references)
///
/// # Arguments
/// - `documents`: Parsed Unity documents to build the world from
///
/// # Returns
/// A tuple of (World, FileID → Entity mapping)
pub fn build_world_from_documents(
documents: Vec<RawDocument>,
) -> Result<(World, HashMap<FileID, Entity>)> {
// Create World with registered component types
let mut world = World::builder()
.register::<GameObject>()
.register::<Transform>()
.register::<RectTransform>()
.register::<PrefabInstanceComponent>()
.build();
let linking_ctx = RefCell::new(LinkingContext::new());
// PASS 1: Create entities for all GameObjects
for doc in documents.iter().filter(|d| d.type_id == 1 || d.class_name == "GameObject") {
let entity = spawn_game_object(&mut world, doc)?;
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
}
// PASS 2: Attach components to entities
let type_filter = TypeFilter::parse_all();
for doc in documents.iter().filter(|d| d.type_id != 1 && d.class_name != "GameObject") {
attach_component(&mut world, doc, &linking_ctx, &type_filter)?;
}
// PASS 3: Execute all deferred linking callbacks
let entity_map = linking_ctx.into_inner().execute_callbacks(&mut world);
Ok((world, entity_map))
}
/// Build entities from raw Unity documents into an existing world
///
/// This is similar to `build_world_from_documents` but spawns into an existing
/// world instead of creating a new one. This is used for prefab instantiation.
///
/// Uses the same 3-pass approach:
/// 1. Create entities for all GameObjects
/// 2. Attach components (Transform, RectTransform, etc.) to entities
/// 3. Resolve Transform hierarchy (parent/children Entity references)
///
/// # Arguments
/// - `documents`: Parsed Unity documents to build entities from
/// - `world`: Existing Sparsey ECS world to spawn entities into
/// - `entity_map`: Existing entity map to merge new mappings into
///
/// # Returns
/// Vec of newly spawned entities
pub fn build_world_from_documents_into(
documents: Vec<RawDocument>,
world: &mut World,
entity_map: &mut HashMap<FileID, Entity>,
) -> Result<Vec<Entity>> {
let linking_ctx = RefCell::new(LinkingContext::new());
// Initialize linking context with existing entity_map
// This allows cross-references between prefab instances and scene entities
*linking_ctx.borrow_mut().entity_map_mut() = entity_map.clone();
let mut spawned_entities = Vec::new();
// PASS 1: Create entities for all GameObjects
for doc in documents.iter().filter(|d| d.type_id == 1 || d.class_name == "GameObject") {
let entity = spawn_game_object(world, doc)?;
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
spawned_entities.push(entity);
}
// PASS 2: Attach components to entities
let type_filter = TypeFilter::parse_all();
for doc in documents.iter().filter(|d| d.type_id != 1 && d.class_name != "GameObject") {
attach_component(world, doc, &linking_ctx, &type_filter)?;
}
// PASS 3: Execute all deferred linking callbacks
let final_entity_map = linking_ctx.into_inner().execute_callbacks(world);
// Update caller's entity_map with new mappings
entity_map.extend(final_entity_map);
Ok(spawned_entities)
}
/// Spawn a GameObject entity
fn spawn_game_object(world: &mut World, doc: &RawDocument) -> Result<Entity> {
let yaml = doc
.as_mapping()
.ok_or_else(|| Error::invalid_format("GameObject YAML must be mapping"))?;
let ctx = ComponentContext {
type_id: doc.type_id,
file_id: doc.file_id,
class_name: &doc.class_name,
entity: None,
linking_ctx: None,
yaml,
};
let go = GameObject::parse(yaml, &ctx)
.ok_or_else(|| Error::invalid_format("Failed to parse GameObject"))?;
// Create entity with GameObject component
let entity = world.create((go,));
Ok(entity)
}
/// Attach a component to its GameObject entity
fn attach_component(
world: &mut World,
doc: &RawDocument,
linking_ctx: &RefCell<LinkingContext>,
type_filter: &TypeFilter,
) -> Result<()> {
let yaml = doc
.as_mapping()
.ok_or_else(|| Error::invalid_format("Component YAML must be mapping"))?;
// Get m_GameObject reference to find which entity owns this component
let go_ref = yaml_helpers::get_file_ref(yaml, "m_GameObject");
let entity = match go_ref {
Some(r) => linking_ctx
.borrow()
.entity_map()
.get(&r.file_id)
.copied()
.ok_or_else(|| {
Error::reference_error(format!("Unknown GameObject: {}", r.file_id))
})?,
None => {
// Some components might not have m_GameObject (e.g., standalone assets)
eprintln!(
"Warning: Component {} has no m_GameObject reference",
doc.class_name
);
return Ok(());
}
};
let ctx = ComponentContext {
type_id: doc.type_id,
file_id: doc.file_id,
class_name: &doc.class_name,
entity: Some(entity),
linking_ctx: Some(linking_ctx),
yaml,
};
// Check type filter to see if we should parse this component
let is_custom = doc.class_name.as_str() != "Transform"
&& doc.class_name.as_str() != "RectTransform"
&& doc.class_name.as_str() != "PrefabInstance";
if !type_filter.should_parse(&doc.class_name, is_custom) {
// Skip this component type based on filter
return Ok(());
}
// Dispatch to appropriate component parser
match doc.class_name.as_str() {
"Transform" => {
if let Some(transform) = Transform::parse(yaml, &ctx) {
world.insert(entity, (transform,));
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
}
}
"RectTransform" => {
if let Some(rect) = RectTransform::parse(yaml, &ctx) {
world.insert(entity, (rect,));
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
}
}
"PrefabInstance" => {
// Parse and store nested prefab reference
if let Some(prefab_comp) = PrefabInstanceComponent::parse(yaml, &ctx) {
world.insert(entity, (prefab_comp,));
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
}
}
_ => {
// Check if this is a registered custom component
let mut found_custom = false;
for reg in inventory::iter::<crate::types::ComponentRegistration> {
if reg.class_name == doc.class_name.as_str() {
found_custom = true;
// Parse and insert the component into the ECS world
if (reg.parse_and_insert)(yaml, &ctx, world, entity) {
// Successfully parsed and inserted
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
}
break;
}
}
if !found_custom {
// Unknown component type - skip with warning
eprintln!(
"Warning: Skipping unknown component type: {}",
doc.class_name
);
}
}
}
Ok(())
}

View File

@@ -1,230 +0,0 @@
//! Unity YAML parsing module
pub mod meta;
mod unity_tag;
mod yaml;
pub use meta::{get_meta_path, MetaFile};
pub use unity_tag::{parse_unity_tag, UnityTag};
pub use yaml::split_yaml_documents;
use crate::model::{RawDocument, UnityAsset, UnityFile, UnityPrefab, UnityScene};
use crate::types::FileID;
use crate::{Error, Result};
use std::path::Path;
/// Parse a Unity file from the given path
///
/// Automatically detects file type based on extension:
/// - .unity → UnityFile::Scene with ECS World
/// - .prefab → UnityFile::Prefab with raw YAML
/// - .asset → UnityFile::Asset with raw YAML
///
/// # Example
///
/// ```no_run
/// use cursebreaker_parser::parser::parse_unity_file;
/// use cursebreaker_parser::UnityFile;
/// use std::path::Path;
///
/// let file = parse_unity_file(Path::new("Scene.unity"))?;
/// match file {
/// UnityFile::Scene(scene) => println!("Scene with {} entities", scene.entity_map.len()),
/// UnityFile::Prefab(prefab) => println!("Prefab with {} documents", prefab.documents.len()),
/// UnityFile::Asset(asset) => println!("Asset with {} documents", asset.documents.len()),
/// }
/// # Ok::<(), cursebreaker_parser::Error>(())
/// ```
pub fn parse_unity_file(path: &Path) -> Result<UnityFile> {
// Read the file
let content = std::fs::read_to_string(path)?;
// Validate Unity header
validate_unity_header(&content, path)?;
// Detect file type by extension
let file_type = detect_file_type(path);
// Parse based on file type
match file_type {
FileType::Scene => parse_scene(path, &content),
FileType::Prefab => parse_prefab(path, &content),
FileType::Asset => parse_asset(path, &content),
FileType::Unknown => Err(Error::invalid_format(format!(
"Unknown file extension: {}",
path.display()
))),
}
}
/// File type enumeration
enum FileType {
Scene,
Prefab,
Asset,
Unknown,
}
/// Detect file type based on extension
fn detect_file_type(path: &Path) -> FileType {
match path.extension().and_then(|s| s.to_str()) {
Some("unity") => FileType::Scene,
Some("prefab") => FileType::Prefab,
Some("asset") => FileType::Asset,
_ => FileType::Unknown,
}
}
/// Parse a scene file into an ECS World
fn parse_scene(path: &Path, content: &str) -> Result<UnityFile> {
let raw_documents = parse_raw_documents(content)?;
// Build ECS world from documents
let (world, entity_map) = crate::ecs::build_world_from_documents(raw_documents)?;
Ok(UnityFile::Scene(UnityScene::new(
path.to_path_buf(),
world,
entity_map,
)))
}
/// Parse a prefab file into raw YAML documents
fn parse_prefab(path: &Path, content: &str) -> Result<UnityFile> {
let raw_documents = parse_raw_documents(content)?;
Ok(UnityFile::Prefab(UnityPrefab::new(
path.to_path_buf(),
raw_documents,
)))
}
/// Parse an asset file into raw YAML documents
fn parse_asset(path: &Path, content: &str) -> Result<UnityFile> {
let raw_documents = parse_raw_documents(content)?;
Ok(UnityFile::Asset(UnityAsset::new(
path.to_path_buf(),
raw_documents,
)))
}
/// Parse raw YAML documents from file content
fn parse_raw_documents(content: &str) -> Result<Vec<RawDocument>> {
// Split into individual YAML documents
let raw_docs = split_yaml_documents(content)?;
// Parse each document
raw_docs
.iter()
.filter_map(|raw| parse_raw_document(raw).transpose())
.collect()
}
/// Parse a single raw YAML document into a RawDocument
fn parse_raw_document(raw_doc: &str) -> Result<Option<RawDocument>> {
// Parse the Unity tag line (e.g., "--- !u!1 &12345")
let tag = match parse_unity_tag(raw_doc) {
Some(tag) => tag,
None => return Ok(None), // Skip documents without Unity tags
};
// Extract the YAML content (everything after the tag line)
let yaml_content = extract_yaml_content(raw_doc);
if yaml_content.trim().is_empty() {
return Ok(None);
}
// Parse YAML but don't convert to PropertyValue yet
let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_content)?;
// Unity documents have format "GameObject: { ... }"
// Extract class name and inner YAML
let (class_name, inner_yaml) = match &yaml_value {
serde_yaml::Value::Mapping(map) if map.len() == 1 => {
// Single-key mapping - this is the standard Unity format
let (key, value) = map.iter().next().unwrap();
let class_name = key
.as_str()
.ok_or_else(|| Error::invalid_format("Class name must be string"))?
.to_string();
(class_name, value.clone())
}
_ => {
// Fallback for malformed documents
let class_name = format!("UnityType{}", tag.type_id);
(class_name, yaml_value)
}
};
Ok(Some(RawDocument::new(
tag.type_id,
FileID::from_i64(tag.file_id),
class_name,
inner_yaml,
)))
}
/// Validate that the file has a proper Unity YAML header
fn validate_unity_header(content: &str, path: &Path) -> Result<()> {
let has_yaml_header = content.starts_with("%YAML");
let has_unity_tag = content.contains("%TAG !u! tag:unity3d.com");
if !has_yaml_header || !has_unity_tag {
return Err(Error::MissingHeader(path.to_path_buf()));
}
Ok(())
}
/// Extract the YAML content from a raw document (skip the Unity tag line)
fn extract_yaml_content(raw_doc: &str) -> &str {
// Find the first newline after the "--- !u!" tag
if let Some(first_line_end) = raw_doc.find('\n') {
&raw_doc[first_line_end + 1..]
} else {
""
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_unity_header() {
let valid_content = "%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n";
assert!(validate_unity_header(valid_content, Path::new("test.unity")).is_ok());
let invalid_content = "Not a Unity file";
assert!(validate_unity_header(invalid_content, Path::new("test.unity")).is_err());
}
#[test]
fn test_extract_yaml_content() {
let raw_doc = "--- !u!1 &12345\nGameObject:\n m_Name: Test";
let content = extract_yaml_content(raw_doc);
assert_eq!(content, "GameObject:\n m_Name: Test");
}
#[test]
fn test_detect_file_type() {
assert!(matches!(
detect_file_type(Path::new("test.unity")),
FileType::Scene
));
assert!(matches!(
detect_file_type(Path::new("test.prefab")),
FileType::Prefab
));
assert!(matches!(
detect_file_type(Path::new("test.asset")),
FileType::Asset
));
assert!(matches!(
detect_file_type(Path::new("test.txt")),
FileType::Unknown
));
}
}

View File

@@ -1,512 +0,0 @@
//! 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::parser::meta::{get_meta_path, MetaFile};
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));
}
// Extract GUID from .meta file for guid_to_path mapping
let meta_path = get_meta_path(&path);
if meta_path.exists() {
match MetaFile::from_path(&meta_path) {
Ok(meta_file) => {
self.guid_to_path.insert(meta_file.guid, path.clone());
}
Err(e) => {
// Log warning but continue (graceful degradation)
eprintln!("Warning: Failed to parse .meta file {:?}: {}", meta_path, e);
}
}
} else {
// Log warning if .meta file doesn't exist
eprintln!("Warning: .meta file not found for {:?}", path);
}
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
}
/// Get the GUID to path mappings
///
/// Returns a reference to the map of GUIDs to file paths.
///
/// # Examples
///
/// ```no_run
/// use cursebreaker_parser::UnityProject;
///
/// let project = UnityProject::new(100);
/// println!("Project has {} GUID mappings", project.guid_mappings().len());
/// ```
pub fn guid_mappings(&self) -> &HashMap<String, PathBuf> {
&self.guid_to_path
}
/// Get the path for a specific GUID
///
/// # Arguments
///
/// * `guid` - The GUID to look up
///
/// # Examples
///
/// ```no_run
/// use cursebreaker_parser::UnityProject;
///
/// let project = UnityProject::new(100);
/// if let Some(path) = project.get_path_by_guid("4ab6bfb0ff54cdf4c8dd38ca244d6f15") {
/// println!("Found asset at: {:?}", path);
/// }
/// ```
pub fn get_path_by_guid(&self, guid: &str) -> Option<&PathBuf> {
self.guid_to_path.get(guid)
}
}
#[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());
}
#[test]
fn test_guid_mappings() {
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();
// Check if GUID mappings were loaded (depends on .meta file existence)
let guid_count = project.guid_mappings().len();
if guid_count > 0 {
println!("Found {} GUID mappings", guid_count);
}
}
}
#[test]
fn test_get_path_by_guid() {
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 loaded = project.load_directory(dir).unwrap();
// If we loaded files, check if we can look up by GUID
if !loaded.is_empty() && !project.guid_mappings().is_empty() {
// Get the first GUID
if let Some((guid, expected_path)) = project.guid_mappings().iter().next() {
let found_path = project.get_path_by_guid(guid);
assert_eq!(found_path, Some(expected_path));
}
}
}
}
}

View File

@@ -1,161 +0,0 @@
//! Type filtering for selective parsing
//!
//! This module provides functionality to selectively parse only specific Unity
//! component types, improving performance and reducing memory usage.
use std::collections::HashSet;
/// Filter for controlling which Unity types get parsed
///
/// By default, all types are parsed. Use `TypeFilter::new()` to create
/// a filter that only parses specific types.
#[derive(Debug, Clone)]
pub struct TypeFilter {
/// Set of Unity type names to parse (e.g., "Transform", "Camera")
/// If None, all types are parsed
unity_types: Option<HashSet<String>>,
/// Set of custom component names to parse (e.g., "PlaySFX")
/// If None, all custom types are parsed
custom_types: Option<HashSet<String>>,
/// Whether to parse all types (default)
parse_all: bool,
}
impl TypeFilter {
/// Create a new filter that parses ALL types (default behavior)
pub fn parse_all() -> Self {
Self {
unity_types: None,
custom_types: None,
parse_all: true,
}
}
/// Create a new filter with specific Unity and custom types
///
/// # Example
/// ```
/// use cursebreaker_parser::TypeFilter;
///
/// let filter = TypeFilter::new(
/// vec!["Transform", "Camera", "Light"],
/// vec!["PlaySFX", "Interact"]
/// );
/// ```
pub fn new<S1, S2>(unity_types: Vec<S1>, custom_types: Vec<S2>) -> Self
where
S1: Into<String>,
S2: Into<String>,
{
Self {
unity_types: Some(unity_types.into_iter().map(|s| s.into()).collect()),
custom_types: Some(custom_types.into_iter().map(|s| s.into()).collect()),
parse_all: false,
}
}
/// Create a filter that only parses specific Unity types (no custom types)
pub fn unity_only<S: Into<String>>(types: Vec<S>) -> Self {
Self {
unity_types: Some(types.into_iter().map(|s| s.into()).collect()),
custom_types: Some(HashSet::new()),
parse_all: false,
}
}
/// Create a filter that only parses specific custom types (no Unity types)
pub fn custom_only<S: Into<String>>(types: Vec<S>) -> Self {
Self {
unity_types: Some(HashSet::new()),
custom_types: Some(types.into_iter().map(|s| s.into()).collect()),
parse_all: false,
}
}
/// Check if a Unity type should be parsed
pub fn should_parse_unity(&self, type_name: &str) -> bool {
if self.parse_all {
return true;
}
match &self.unity_types {
Some(types) => types.contains(type_name),
None => true, // If not specified, parse all
}
}
/// Check if a custom type should be parsed
pub fn should_parse_custom(&self, type_name: &str) -> bool {
if self.parse_all {
return true;
}
match &self.custom_types {
Some(types) => types.contains(type_name),
None => true, // If not specified, parse all
}
}
/// Check if any type should be parsed
pub fn should_parse(&self, type_name: &str, is_custom: bool) -> bool {
if is_custom {
self.should_parse_custom(type_name)
} else {
self.should_parse_unity(type_name)
}
}
}
impl Default for TypeFilter {
fn default() -> Self {
Self::parse_all()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_all() {
let filter = TypeFilter::parse_all();
assert!(filter.should_parse_unity("Transform"));
assert!(filter.should_parse_unity("Camera"));
assert!(filter.should_parse_custom("PlaySFX"));
}
#[test]
fn test_specific_types() {
let filter = TypeFilter::new(
vec!["Transform", "Camera"],
vec!["PlaySFX"]
);
assert!(filter.should_parse_unity("Transform"));
assert!(filter.should_parse_unity("Camera"));
assert!(!filter.should_parse_unity("Light"));
assert!(filter.should_parse_custom("PlaySFX"));
assert!(!filter.should_parse_custom("Interact"));
}
#[test]
fn test_unity_only() {
let filter = TypeFilter::unity_only(vec!["Transform"]);
assert!(filter.should_parse_unity("Transform"));
assert!(!filter.should_parse_unity("Camera"));
assert!(!filter.should_parse_custom("PlaySFX"));
}
#[test]
fn test_custom_only() {
let filter = TypeFilter::custom_only(vec!["PlaySFX"]);
assert!(!filter.should_parse_unity("Transform"));
assert!(filter.should_parse_custom("PlaySFX"));
assert!(!filter.should_parse_custom("Interact"));
}
}

View File

@@ -1,83 +0,0 @@
//! Integration test for GUID resolution with .meta files
use cursebreaker_parser::parser::meta::{get_meta_path, MetaFile};
use cursebreaker_parser::UnityProject;
use std::path::Path;
#[test]
fn test_meta_file_parsing() {
// Test parsing a .meta file directly
let meta_content = r#"
fileFormatVersion: 2
guid: 4ab6bfb0ff54cdf4c8dd38ca244d6f15
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
"#;
let meta = MetaFile::from_str(meta_content).unwrap();
assert_eq!(meta.guid(), "4ab6bfb0ff54cdf4c8dd38ca244d6f15");
assert_eq!(meta.file_format_version(), Some(2));
}
#[test]
fn test_meta_path_generation() {
let asset_path = Path::new("Assets/Prefabs/Player.prefab");
let meta_path = get_meta_path(asset_path);
assert_eq!(
meta_path,
Path::new("Assets/Prefabs/Player.prefab.meta")
);
}
#[test]
fn test_guid_resolution_in_project() {
let mut project = UnityProject::new(100);
let test_dir = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand";
if Path::new(test_dir).exists() {
// Load all files in the directory
let loaded_files = project.load_directory(test_dir).unwrap();
if !loaded_files.is_empty() {
println!("Loaded {} files", loaded_files.len());
println!(
"Found {} GUID mappings",
project.guid_mappings().len()
);
// If we have GUID mappings, test that we can look them up
for (guid, path) in project.guid_mappings() {
let found_path = project.get_path_by_guid(guid);
assert_eq!(found_path, Some(path));
println!("GUID {} -> {:?}", guid, path);
}
}
}
}
#[test]
fn test_cross_file_reference_resolution() {
let mut project = UnityProject::new(100);
let test_dir = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic";
if Path::new(test_dir).exists() {
// Load multiple directories to enable cross-file resolution
let _ = project.load_directory(test_dir);
let guid_count = project.guid_mappings().len();
let file_count = project.files().len();
println!("Loaded {} files with {} GUID mappings", file_count, guid_count);
// Verify we can look up files by GUID
if guid_count > 0 {
let sample_guid = project.guid_mappings().keys().next().unwrap();
let path = project.get_path_by_guid(sample_guid);
assert!(path.is_some(), "Should be able to look up GUID");
}
}
}

693
resource_prefabs_output.txt Normal file
View File

@@ -0,0 +1,693 @@
Cursebreaker Resource Prefabs
======================================================================
Total resources found: 171
----------------------------------------------------------------------
Prefab: HarvestableSpawner_73Medicinal Herbs
TypeID: 73
MaxHealth: 0
Prefab: HarvestableSpawner_65Radishes
TypeID: 65
MaxHealth: 0
Prefab: HarvestableSpawner_22Sandstone Grouper
TypeID: 22
MaxHealth: 0
Prefab: HarvestableSpawner_14Mageflower
TypeID: 14
MaxHealth: 0
Prefab: HarvestableSpawner_108mountain Ore3
TypeID: 108
MaxHealth: 0
Prefab: HarvestableSpawner_120Swamp Brambles
TypeID: 120
MaxHealth: 0
Prefab: HarvestableSpawner_20Caveshroom
TypeID: 20
MaxHealth: 0
Prefab: Prefab_Resource_30HeartbellVine
TypeID: 30
MaxHealth: 5
Prefab: HarvestableSpawner_76Speed herb
TypeID: 76
MaxHealth: 0
Prefab: Prefab_Resource_22SandstoneGrouper
TypeID: 22
MaxHealth: 5
Prefab: Prefab_Resource_64Carrot_Field
TypeID: 64
MaxHealth: 5
Prefab: HarvestableSpawner_26Betta Iotachi
TypeID: 26
MaxHealth: 0
Prefab: HarvestableSpawner_85Magic res ampoule crafting mat
TypeID: 85
MaxHealth: 0
Prefab: Prefab_Resource_15Blueberries
TypeID: 15
MaxHealth: 5
Prefab: HarvestableSpawner_58Radiant Sunflower
TypeID: 58
MaxHealth: 0
Prefab: HarvestableSpawner_76Waspheart Iris
TypeID: 76
MaxHealth: 0
Prefab: HarvestableSpawner_30Heartbell Vine
TypeID: 30
MaxHealth: 0
Prefab: HarvestableSpawner_61Eggplants
TypeID: 61
MaxHealth: 0
Prefab: Prefab_Resource_8Deadwood
TypeID: 0
MaxHealth: 5
Prefab: HarvestableSpawner_70Cabbage
TypeID: 70
MaxHealth: 0
Prefab: Prefab_Resource_19Coal
TypeID: 19
MaxHealth: 20
Prefab: Prefab_Resource_12PebbleSturgeon
TypeID: 12
MaxHealth: 5
Prefab: Prefab_Resource_6Oak
TypeID: 6
MaxHealth: 5
Prefab: HarvestableSpawner_2Copper Ore
TypeID: 2
MaxHealth: 0
Prefab: HarvestableSpawner_111Deep Earth Moss
TypeID: 111
MaxHealth: 0
Prefab: HarvestableSpawner_49Dreamylion
TypeID: 49
MaxHealth: 0
Prefab: HarvestableSpawner_71Nest
TypeID: 71
MaxHealth: 0
Prefab: HarvestableSpawner_19Coal
TypeID: 19
MaxHealth: 0
Prefab: Prefab_Resource_21CharcoalSnapper
TypeID: 21
MaxHealth: 5
Prefab: HarvestableSpawner_12Pebble Sturgeon
TypeID: 12
MaxHealth: 0
Prefab: Prefab_Resource_25ObsidianWalleye
TypeID: 25
MaxHealth: 5
Prefab: Prefab_Resource_65Radish_Field
TypeID: 65
MaxHealth: 5
Prefab: HarvestableSpawner_83Phys res ampoule crafting mat
TypeID: 83
MaxHealth: 0
Prefab: HarvestableSpawner_78Firestick flower
TypeID: 78
MaxHealth: 0
Prefab: Prefab_Resource_13ArcaneSeedTree
TypeID: 13
MaxHealth: 5
Prefab: HarvestableSpawner_116Tree
TypeID: 116
MaxHealth: 0
Prefab: HarvestableSpawner_6Oak
TypeID: 6
MaxHealth: 0
Prefab: HarvestableSpawner_37Ancients' Tears
TypeID: 37
MaxHealth: 0
Prefab: HarvestableSpawner_63Corn
TypeID: 63
MaxHealth: 0
Prefab: HarvestableSpawner_44Voidlight Tendril
TypeID: 44
MaxHealth: 0
Prefab: Prefab_Resource_5TitaniumOre
TypeID: 5
MaxHealth: 20
Prefab: HarvestableSpawner_55Poppy of the Fallen
TypeID: 55
MaxHealth: 0
Prefab: HarvestableSpawner_102mountain fish3
TypeID: 102
MaxHealth: 0
Prefab: HarvestableSpawner_15Blueberries
TypeID: 15
MaxHealth: 0
Prefab: HarvestableSpawner_41Falls Hydrangea
TypeID: 41
MaxHealth: 0
Prefab: Prefab_Resource_26BettaIotachi
TypeID: 26
MaxHealth: 5
Prefab: Prefab_Resource_36Noblerose
TypeID: 36
MaxHealth: 5
Prefab: Prefab_Resource_58Sunflower
TypeID: 58
MaxHealth: 5
Prefab: HarvestableSpawner_45Autumnleaf Lily
TypeID: 45
MaxHealth: 0
Prefab: Prefab_Resource_32Lakesberry
TypeID: 32
MaxHealth: 5
Prefab: HarvestableSpawner_106Imberite Ore
TypeID: 106
MaxHealth: 0
Prefab: HarvestableSpawner_100mountain fish1
TypeID: 100
MaxHealth: 0
Prefab: HarvestableSpawner_27Rock Lobster
TypeID: 27
MaxHealth: 0
Prefab: HarvestableSpawner_9Wild Goopy
TypeID: 9
MaxHealth: 0
Prefab: HarvestableSpawner_68Epic ore
TypeID: 68
MaxHealth: 0
Prefab: HarvestableSpawner_113Soulgrowth Mushrooms
TypeID: 113
MaxHealth: 0
Prefab: HarvestableSpawner_28Horsetail Reed Bass
TypeID: 28
MaxHealth: 0
Prefab: HarvestableSpawner_8Deadwood tree
TypeID: 8
MaxHealth: 0
Prefab: HarvestableSpawner_77Glowing Willow
TypeID: 77
MaxHealth: 0
Prefab: Prefab_Resource_33Silvermirror
TypeID: 33
MaxHealth: 5
Prefab: HarvestableSpawner_42Wight's Pearls
TypeID: 42
MaxHealth: 0
Prefab: HarvestableSpawner_16Spellbound Oak
TypeID: 16
MaxHealth: 0
Prefab: HarvestableSpawner_25Obsidian Walleye
TypeID: 25
MaxHealth: 0
Prefab: Prefab_Resource_50Haniflower
TypeID: 50
MaxHealth: 5
Prefab: Prefab_Resource_2CopperOre
TypeID: 2
MaxHealth: 20
Prefab: HarvestableSpawner_64Carrots
TypeID: 64
MaxHealth: 0
Prefab: HarvestableSpawner_32Lakesberry
TypeID: 32
MaxHealth: 0
Prefab: HarvestableSpawner_115Malicious Sporecap
TypeID: 115
MaxHealth: 0
Prefab: HarvestableSpawner_88Saltpeter Mine
TypeID: 88
MaxHealth: 0
Prefab: Prefab_Resource_23FuchsiaPerch
TypeID: 23
MaxHealth: 5
Prefab: Prefab_Resource_54GoldenSunflower
TypeID: 54
MaxHealth: 5
Prefab: HarvestableSpawner_58Sunflower
TypeID: 58
MaxHealth: 0
Prefab: HarvestableSpawner_117Ore
TypeID: 117
MaxHealth: 0
Prefab: HarvestableSpawner_1Spruce
TypeID: 1
MaxHealth: 0
Prefab: HarvestableSpawner_79Minigame tree
TypeID: 79
MaxHealth: 0
Prefab: HarvestableSpawner_7Evark tree
TypeID: 7
MaxHealth: 0
Prefab: HarvestableSpawner_81Puppet Flower
TypeID: 81
MaxHealth: 0
Prefab: HarvestableSpawner_6Oak tree
TypeID: 6
MaxHealth: 0
Prefab: Prefab_Resource_14Mageflower
TypeID: 14
MaxHealth: 5
Prefab: Prefab_Resource_7Evark
TypeID: 0
MaxHealth: 5
Prefab: HarvestableSpawner_86Generic mining
TypeID: 86
MaxHealth: 0
Prefab: HarvestableSpawner_85Darkmire Damp Root
TypeID: 85
MaxHealth: 0
Prefab: HarvestableSpawner_110Crystalvein Rose
TypeID: 110
MaxHealth: 0
Prefab: HarvestableSpawner_4Imberite ore
TypeID: 4
MaxHealth: 0
Prefab: Prefab_Resource_16SpellboundOak
TypeID: 16
MaxHealth: 5
Prefab: HarvestableSpawner_59Evening's Cup
TypeID: 59
MaxHealth: 0
Prefab: Prefab_Resource_38Dandelions
TypeID: 38
MaxHealth: 5
Prefab: HarvestableSpawner_101mountain fish2
TypeID: 101
MaxHealth: 0
Prefab: HarvestableSpawner_115MaliciousSporecap
TypeID: 115
MaxHealth: 0
Prefab: HarvestableSpawner_43Skyfold Flower
TypeID: 43
MaxHealth: 0
Prefab: HarvestableSpawner_67Cotton
TypeID: 67
MaxHealth: 0
Prefab: HarvestableSpawner_84Ranged res ampoule crafting mat
TypeID: 84
MaxHealth: 0
Prefab: HarvestableSpawner_23Fuchsia Perch
TypeID: 23
MaxHealth: 0
Prefab: HarvestableSpawner_84Marshland Feather Reed
TypeID: 84
MaxHealth: 0
Prefab: Prefab_Resource_24GoblinShad
TypeID: 24
MaxHealth: 5
Prefab: HarvestableSpawner_109mountain herb1
TypeID: 109
MaxHealth: 0
Prefab: HarvestableSpawner_35Red Nymph
TypeID: 35
MaxHealth: 0
Prefab: HarvestableSpawner_80Sunburst Marlin
TypeID: 80
MaxHealth: 0
Prefab: HarvestableSpawner_74Soothing Mallow
TypeID: 74
MaxHealth: 0
Prefab: HarvestableSpawner_60Golden Tulip
TypeID: 60
MaxHealth: 0
Prefab: HarvestableSpawner_114ColdwaterLily
TypeID: 114
MaxHealth: 0
Prefab: HarvestableSpawner_24Goblin Shad
TypeID: 24
MaxHealth: 0
Prefab: HarvestableSpawner_46Cloud Yarrow
TypeID: 46
MaxHealth: 0
Prefab: HarvestableSpawner_113SoulgrowthMushroom
TypeID: 113
MaxHealth: 0
Prefab: HarvestableSpawner_104mountain tree2
TypeID: 104
MaxHealth: 0
Prefab: Prefab_Resource_62Chili_Pepper_Field
TypeID: 62
MaxHealth: 5
Prefab: Prefab_Resource_67Cotton
TypeID: 67
MaxHealth: 5
Prefab: HarvestableSpawner_111mountain herb3
TypeID: 111
MaxHealth: 0
Prefab: Prefab_Resource_18Dust
TypeID: 18
MaxHealth: 5
Prefab: HarvestableSpawner_18Dust
TypeID: 18
MaxHealth: 0
Prefab: Prefab_Resource_1Spruce
TypeID: 1
MaxHealth: 5
Prefab: HarvestableSpawner_73Sweetgrass
TypeID: 73
MaxHealth: 0
Prefab: Prefab_Resource_35RedNymph
TypeID: 35
MaxHealth: 5
Prefab: HarvestableSpawner_72MinigameOre
TypeID: 72
MaxHealth: 0
Prefab: HarvestableSpawner_67Northwind Cotton
TypeID: 67
MaxHealth: 0
Prefab: HarvestableSpawner_57Queen's Lily
TypeID: 57
MaxHealth: 0
Prefab: HarvestableSpawner_103mountain tree1
TypeID: 103
MaxHealth: 0
Prefab: Prefab_Resource_3IronOre
TypeID: 3
MaxHealth: 20
Prefab: HarvestableSpawner_87Generic alchemy
TypeID: 87
MaxHealth: 0
Prefab: Prefab_Resource_31Bloodpetal
TypeID: 31
MaxHealth: 5
Prefab: HarvestableSpawner_1Spruce tree
TypeID: 1
MaxHealth: 0
Prefab: HarvestableSpawner_40Night's Bloom
TypeID: 40
MaxHealth: 0
Prefab: Prefab_Resource_28HorsetailReedBass
TypeID: 28
MaxHealth: 5
Prefab: HarvestableSpawner_11Redberries
TypeID: 11
MaxHealth: 0
Prefab: HarvestableSpawner_20Cavern Puffball
TypeID: 20
MaxHealth: 0
Prefab: HarvestableSpawner_37Ancient's Tears
TypeID: 37
MaxHealth: 0
Prefab: HarvestableSpawner_107mountain Ore2
TypeID: 107
MaxHealth: 0
Prefab: Prefab_Resource_27RockLobster
TypeID: 27
MaxHealth: 5
Prefab: Prefab_Resource_11RedberryBush
TypeID: 11
MaxHealth: 5
Prefab: HarvestableSpawner_62Chili Peppers
TypeID: 62
MaxHealth: 0
Prefab: HarvestableSpawner_106mountain Ore1
TypeID: 106
MaxHealth: 0
Prefab: HarvestableSpawner_56Deepforest Daisy
TypeID: 56
MaxHealth: 0
Prefab: HarvestableSpawner_105mountain tree3
TypeID: 105
MaxHealth: 0
Prefab: HarvestableSpawner_53Drakefire Root
TypeID: 53
MaxHealth: 0
Prefab: HarvestableSpawner_66Pumpkins
TypeID: 66
MaxHealth: 0
Prefab: Prefab_Resource_34SorcerersWeed
TypeID: 34
MaxHealth: 5
Prefab: HarvestableSpawner_112Rye
TypeID: 112
MaxHealth: 0
Prefab: HarvestableSpawner_3Iron ore
TypeID: 3
MaxHealth: 0
Prefab: Prefab_Resource_66Pumpkin_Patch
TypeID: 66
MaxHealth: 5
Prefab: HarvestableSpawner_73MedicinalHerbs
TypeID: 73
MaxHealth: 0
Prefab: HarvestableSpawner_33Silvermirror Iris
TypeID: 33
MaxHealth: 0
Prefab: HarvestableSpawner_69Cabbage
TypeID: 69
MaxHealth: 0
Prefab: HarvestableSpawner_13Arcane Everbloom
TypeID: 13
MaxHealth: 0
Prefab: Prefab_Resource_61Eggplant_Field
TypeID: 61
MaxHealth: 5
Prefab: HarvestableSpawner_48Sorrowleaf
TypeID: 48
MaxHealth: 0
Prefab: Prefab_Resource_63Corn_Field
TypeID: 63
MaxHealth: 5
Prefab: HarvestableSpawner_39Dawnflame Ivy
TypeID: 39
MaxHealth: 0
Prefab: HarvestableSpawner_54Golden Sunflower
TypeID: 54
MaxHealth: 0
Prefab: Prefab_Resource_37AncientsTears
TypeID: 37
MaxHealth: 5
Prefab: HarvestableSpawner_21Charcoal Snapper
TypeID: 21
MaxHealth: 0
Prefab: HarvestableSpawner_82Spider Egg
TypeID: 82
MaxHealth: 0
Prefab: HarvestableSpawner_34Sorcerer's Weed
TypeID: 34
MaxHealth: 0
Prefab: HarvestableSpawner_75Aurora Trout
TypeID: 75
MaxHealth: 0
Prefab: HarvestableSpawner_47Spellspore Cap
TypeID: 47
MaxHealth: 0
Prefab: HarvestableSpawner_109Skyfall Orchid
TypeID: 109
MaxHealth: 0
Prefab: HarvestableSpawner_52Shyflower Orchid
TypeID: 52
MaxHealth: 0
Prefab: HarvestableSpawner_110mountain herb2
TypeID: 110
MaxHealth: 0
Prefab: HarvestableSpawner_38Dandelions
TypeID: 38
MaxHealth: 0
Prefab: HarvestableSpawner_10Barley
TypeID: 10
MaxHealth: 0
Prefab: HarvestableSpawner_51Royal Daisy
TypeID: 51
MaxHealth: 0
Prefab: Prefab_Resource_20CaveShroom
TypeID: 20
MaxHealth: 20
Prefab: HarvestableSpawner_74Sven Herb 2
TypeID: 74
MaxHealth: 0
Prefab: HarvestableSpawner_69Potatoes
TypeID: 69
MaxHealth: 0
Prefab: HarvestableSpawner_114Coldwater Lily
TypeID: 114
MaxHealth: 0
Prefab: HarvestableSpawner_36Noblerose
TypeID: 36
MaxHealth: 0
Prefab: Prefab_Resource_10Barley_Field
TypeID: 10
MaxHealth: 5
Prefab: HarvestableSpawner_31Bloodpetal Rose
TypeID: 31
MaxHealth: 0
Prefab: Prefab_Resource_9WildGoopy
TypeID: 9
MaxHealth: 5
Prefab: HarvestableSpawner_83Gravelstem Whiteflower
TypeID: 83
MaxHealth: 0
Prefab: Prefab_Resource_41FallsHydrangea
TypeID: 41
MaxHealth: 5
Prefab: HarvestableSpawner_50Haniflower
TypeID: 50
MaxHealth: 0
======================================================================
End of resource data

19
resources_output.txt Normal file
View File

@@ -0,0 +1,19 @@
Cursebreaker Resources - 10_3.unity Scene
======================================================================
Total resources found: 2
----------------------------------------------------------------------
Resource: HarvestableSpawner_2Copper Ore
TypeID: 2
MaxHealth: 0
Position: (1788.727173, 40.725288, 172.017670)
Resource: HarvestableSpawner_38Dandelions
TypeID: 38
MaxHealth: 0
Position: (1746.709717, 44.599632, 299.696503)
======================================================================
End of resource data

View File

@@ -1,11 +1,11 @@
[package]
name = "cursebreaker-parser-macros"
name = "unity-parser-macros"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
license = "MIT OR Apache-2.0"
description = "Procedural macros for cursebreaker-parser"
repository = "https://github.com/yourusername/cursebreaker-parser-rust"
description = "Procedural macros for unity-parser"
repository = "https://github.com/yourusername/unity-parser-rust"
[lib]
proc-macro = true

View File

@@ -1,4 +1,4 @@
//! Procedural macros for cursebreaker-parser
//! Procedural macros for unity-parser
//!
//! This crate provides the `#[derive(UnityComponent)]` macro for automatically
//! generating Unity component parsing code.
@@ -101,7 +101,8 @@ pub fn derive_unity_component(input: TokenStream) -> TokenStream {
<#struct_name as cursebreaker_parser::EcsInsertable>::parse_and_insert(
yaml, ctx, world, entity
)
}
},
register: |builder| builder.register::<#struct_name>()
}
}
};

View File

@@ -1,17 +1,17 @@
[package]
name = "cursebreaker-parser"
name = "unity-parser"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
license = "MIT OR Apache-2.0"
description = "A high-performance Rust library for parsing Unity project files (.unity, .prefab, .asset)"
repository = "https://github.com/yourusername/cursebreaker-parser-rust"
repository = "https://github.com/yourusername/unity-parser-rust"
keywords = ["unity", "parser", "yaml", "gamedev"]
categories = ["parser-implementations", "game-development"]
rust-version = "1.70"
[lib]
name = "cursebreaker_parser"
name = "unity_parser"
path = "src/lib.rs"
[dependencies]
@@ -47,7 +47,7 @@ once_cell = "1.19"
inventory = "0.3"
# Procedural macro for derive(UnityComponent)
cursebreaker-parser-macros = { path = "../cursebreaker-parser-macros" }
unity-parser-macros = { path = "../unity-parser-macros" }
[dev-dependencies]
# Testing utilities

View File

@@ -1,4 +1,4 @@
use cursebreaker_parser::UnityFile;
use unity_parser::UnityFile;
use std::path::Path;
fn main() {
@@ -38,16 +38,13 @@ fn main() {
let game_objects = prefab.get_documents_by_class("GameObject");
println!("Found {} GameObjects:", game_objects.len());
for go in game_objects {
// doc.yaml already contains the inner content (after class wrapper)
if let Some(mapping) = go.as_mapping() {
if let Some(go_obj) = mapping.get("GameObject") {
if let Some(props) = go_obj.as_mapping() {
if let Some(name) = props.get("m_Name").and_then(|v| v.as_str()) {
if let Some(name) = mapping.get("m_Name").and_then(|v| v.as_str()) {
println!(" - {}", name);
}
}
}
}
}
println!();

View File

@@ -1,7 +1,7 @@
//! Example demonstrating how to define custom Unity MonoBehaviour components
//! using the #[derive(UnityComponent)] macro.
use cursebreaker_parser::{yaml_helpers, ComponentContext, UnityComponent};
use unity_parser::{yaml_helpers, ComponentContext, UnityComponent};
/// Custom Unity MonoBehaviour component for playing sound effects
///
@@ -71,7 +71,7 @@ isLoop: 1
let mapping = yaml.as_mapping().unwrap();
// Create a dummy context
use cursebreaker_parser::{ComponentContext, FileID};
use unity_parser::{ComponentContext, FileID};
let ctx = ComponentContext {
type_id: 114,
file_id: FileID::from_i64(12345),

View File

@@ -5,7 +5,7 @@
//! 2. Using the parse_with_types! macro for selective parsing
//! 3. Querying the ECS world for components
use cursebreaker_parser::{parse_with_types, ComponentContext, EcsInsertable, FileID, TypeFilter, UnityComponent};
use unity_parser::{parse_with_types, ComponentContext, EcsInsertable, FileID, TypeFilter, UnityComponent};
/// Custom Unity MonoBehaviour component
#[derive(Debug, Clone, UnityComponent)]

View File

@@ -0,0 +1,194 @@
//! Demo: Find all PlaySFX components and their locations in VR_Horror_YouCantRun
//!
//! This example demonstrates:
//! 1. Parsing a real Unity project
//! 2. Finding custom MonoBehaviour components (PlaySFX)
//! 3. Querying the ECS world for components
//! 4. Accessing Transform data for component locations
use unity_parser::{UnityComponent, UnityFile};
use std::path::Path;
/// PlaySFX component from VR_Horror_YouCantRun
///
/// C# definition:
/// ```csharp
/// public class PlaySFX : MonoBehaviour
/// {
/// [SerializeField] float volume;
/// [SerializeField] float startTime;
/// [SerializeField] float endTime;
/// [SerializeField] bool isLoop;
/// }
/// ```
#[derive(Debug, Clone, UnityComponent)]
#[unity_class("PlaySFX")]
pub struct PlaySFX {
#[unity_field("volume")]
pub volume: f64,
#[unity_field("startTime")]
pub start_time: f64,
#[unity_field("endTime")]
pub end_time: f64,
#[unity_field("isLoop")]
pub is_loop: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🎮 VR Horror - PlaySFX Component Finder");
println!("{}", "=".repeat(70));
println!();
let project_path = Path::new("test_data/VR_Horror_YouCantRun");
// Check if project exists
if !project_path.exists() {
eprintln!("❌ Error: VR_Horror_YouCantRun project not found at {}", project_path.display());
eprintln!(" Run the integration tests first to download it:");
eprintln!(" cargo test test_vr_horror_project");
return Ok(());
}
println!("📁 Scanning project: {}", project_path.display());
println!();
// Find all Unity scene files
let scene_files = find_unity_files(project_path, "unity");
println!("📄 Found {} scene file(s)", scene_files.len());
println!();
let mut total_playsfx = 0;
// Parse each scene
for scene_path in scene_files {
println!("🔍 Parsing: {}", scene_path.file_name().unwrap().to_string_lossy());
match UnityFile::from_path(&scene_path) {
Ok(UnityFile::Scene(scene)) => {
// Get views for all component types we need
let playsfx_view = scene.world.borrow::<PlaySFX>();
let transform_view = scene.world.borrow::<unity_parser::Transform>();
let rect_transform_view = scene.world.borrow::<unity_parser::RectTransform>();
let gameobject_view = scene.world.borrow::<unity_parser::GameObject>();
// Find all entities that have PlaySFX
let mut found_count = 0;
let mut found_entities = Vec::new();
// Iterate through all entities in the entity_map
for entity in scene.entity_map.values() {
if let Some(playsfx) = playsfx_view.get(*entity) {
found_entities.push((*entity, playsfx.clone()));
found_count += 1;
}
}
if found_count > 0 {
println!(" ✅ Found {} PlaySFX component(s)", found_count);
total_playsfx += found_count;
// Process each found PlaySFX component
for (entity, playsfx) in found_entities {
let transform = transform_view.get(entity);
let rect_transform = rect_transform_view.get(entity);
let game_object = gameobject_view.get(entity);
let name = game_object
.and_then(|go| go.name())
.unwrap_or("(unnamed)");
println!();
println!(" 🔊 PlaySFX on GameObject: \"{}\"", name);
println!(" Entity: {:?}", entity);
println!(" Properties:");
println!(" • volume: {}", playsfx.volume);
println!(" • startTime: {}", playsfx.start_time);
println!(" • endTime: {}", playsfx.end_time);
println!(" • isLoop: {}", playsfx.is_loop);
// Print position if available
if let Some(transform) = transform {
if let Some(pos) = transform.local_position() {
println!(" Transform:");
println!(" • Position: ({:.2}, {:.2}, {:.2})",
pos.x, pos.y, pos.z);
}
if let Some(rot) = transform.local_rotation() {
println!(" • Rotation: ({:.2}, {:.2}, {:.2}, {:.2})",
rot.x, rot.y, rot.z, rot.w);
}
if let Some(scale) = transform.local_scale() {
println!(" • Scale: ({:.2}, {:.2}, {:.2})",
scale.x, scale.y, scale.z);
}
} else if let Some(rect_transform) = rect_transform {
let transform = rect_transform.transform();
if let Some(pos) = transform.local_position() {
println!(" RectTransform (UI):");
println!(" • Position: ({:.2}, {:.2}, {:.2})",
pos.x, pos.y, pos.z);
}
} else {
println!(" ⚠️ No Transform found");
}
}
} else {
println!(" ⊘ No PlaySFX components found");
}
println!();
}
Ok(_) => {
println!(" ⊘ Skipped (not a scene file)");
println!();
}
Err(e) => {
println!(" ❌ Parse error: {}", e);
println!();
}
}
}
println!("{}", "=".repeat(70));
println!("📊 Summary:");
println!(" Total PlaySFX components found: {}", total_playsfx);
println!("{}", "=".repeat(70));
Ok(())
}
/// Find all Unity files with a specific extension in a directory
fn find_unity_files(dir: &Path, extension: &str) -> Vec<std::path::PathBuf> {
let mut files = Vec::new();
fn visit_dir(dir: &Path, extension: &str, files: &mut Vec<std::path::PathBuf>) {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
// Skip Library, Temp, Builds, and .git directories
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name == "Library" || name == "Temp" || name == "Builds" || name == ".git" {
continue;
}
}
if path.is_dir() {
visit_dir(&path, extension, files);
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if ext == extension {
files.push(path);
}
}
}
}
}
visit_dir(dir, extension, &mut files);
files.sort();
files
}

View File

@@ -0,0 +1,218 @@
//! Parse Cursebreaker Resource Prefabs
//!
//! This example demonstrates:
//! 1. Parsing Cursebreaker prefab files directly
//! 2. Finding Interactable_Resource components in prefabs
//! 3. Extracting typeId and maxHealth data
//! 4. Writing resource data to an output file
//!
//! Note: The 10_3.unity scene uses prefab instances, and the current parser
//! doesn't yet support resolving components from nested prefabs. This example
//! parses the prefab files directly instead.
use unity_parser::{GuidResolver, UnityComponent, UnityFile};
use std::fs::File;
use std::io::Write;
use std::path::Path;
use walkdir::WalkDir;
/// Interactable_Resource component from Cursebreaker
///
/// C# definition from Interactable_Resource.cs:
/// ```csharp
/// public class Interactable_Resource : Interactable
/// {
/// public int health;
/// public int maxHealth;
/// public int typeId;
/// // ... other fields
/// }
/// ```
#[derive(Debug, Clone, UnityComponent)]
#[unity_class("Interactable_Resource")]
pub struct InteractableResource {
#[unity_field("maxHealth")]
pub max_health: i64,
#[unity_field("typeId")]
pub type_id: i64,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🎮 Cursebreaker - Resource Prefab Parser");
println!("{}", "=".repeat(70));
println!();
// Build GUID resolver for the project
let project_path = Path::new("/home/connor/repos/CBAssets");
println!("📦 Building GUID resolver for project: {}", project_path.display());
let resolver = match GuidResolver::from_project(project_path) {
Ok(r) => {
println!(" ✅ GUID resolver built successfully ({} mappings)", r.len());
// Debug: Check if we have Interactable_Resource
if let Some(class) = r.resolve_class_name("d39ddbf1c2c3d1a4baa070e5e76548bd") {
println!(" ✅ Found Interactable_Resource in resolver: {}", class);
} else {
println!(" ⚠️ Interactable_Resource NOT found in resolver");
// Try to find what we did find related to "Interactable"
println!(" Searching for similar class names...");
}
Some(r)
}
Err(e) => {
eprintln!(" ❌ Failed to build GUID resolver: {}", e);
None
}
};
println!();
let harvestables_dir = Path::new("/home/connor/repos/CBAssets/_GameAssets/Prefabs/Harvestables");
if !harvestables_dir.exists() {
eprintln!("❌ Error: Harvestables directory not found at {}", harvestables_dir.display());
return Err("Harvestables directory not found".into());
}
println!("📁 Scanning for harvestable prefabs in:");
println!(" {}", harvestables_dir.display());
println!();
// Find all prefab files
let mut prefab_files = Vec::new();
for entry in WalkDir::new(harvestables_dir)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("prefab") {
prefab_files.push(path.to_path_buf());
}
}
println!("📄 Found {} prefab file(s)", prefab_files.len());
println!();
let mut all_resources = Vec::new();
// Parse each prefab using the GUID resolver we built
for prefab_path in &prefab_files {
println!("🔍 Parsing: {}", prefab_path.file_name().unwrap().to_string_lossy());
// For prefabs, we need to manually parse and check documents
// since prefabs don't have an ECS world like scenes do
match UnityFile::from_path(prefab_path) {
Ok(UnityFile::Prefab(prefab)) => {
// Search through YAML documents for Interactable_Resource components
let mut found_in_prefab = false;
for doc in &prefab.documents {
// Check if this document is a MonoBehaviour
if doc.class_name == "MonoBehaviour" {
// Try to extract the m_Script GUID
if let Some(m_script) = doc.yaml.get("m_Script").and_then(|v| v.as_mapping()) {
if let Some(guid_val) = m_script.get("guid").and_then(|v| v.as_str()) {
// Resolve GUID to class name
if let Some(ref res) = resolver {
if let Some(class_name) = res.resolve_class_name(guid_val) {
// Debug: print what we found
if prefab_path.file_name().unwrap().to_string_lossy().contains("Copper Ore") {
eprintln!("DEBUG: Found class '{}' in Copper Ore prefab", class_name);
}
if class_name == "Interactable_Resource" {
// Extract fields
let type_id = doc.yaml.get("typeId")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let max_health = doc.yaml.get("maxHealth")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let prefab_name = prefab_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
all_resources.push((
prefab_name.to_string(),
type_id,
max_health,
));
found_in_prefab = true;
}
} else if prefab_path.file_name().unwrap().to_string_lossy().contains("Copper Ore") {
eprintln!("DEBUG: Could not resolve GUID '{}' in Copper Ore prefab", guid_val);
}
}
}
}
}
}
if found_in_prefab {
println!(" ✅ Found Interactable_Resource");
} else {
println!(" ⊘ No Interactable_Resource found");
}
}
Ok(_) => {
println!(" ⊘ Not a prefab file");
}
Err(e) => {
println!(" ❌ Parse error: {}", e);
}
}
}
println!();
println!("{}", "=".repeat(70));
println!("📊 Summary: Found {} resource(s)", all_resources.len());
println!("{}", "=".repeat(70));
println!();
if !all_resources.is_empty() {
// Display resources
for (name, type_id, max_health) in &all_resources {
println!(" 📦 Prefab: \"{}\"", name);
println!(" • typeId: {}", type_id);
println!(" • maxHealth: {}", max_health);
println!();
}
// Write to output file
let output_path = "resource_prefabs_output.txt";
let mut output_file = File::create(output_path)?;
writeln!(output_file, "Cursebreaker Resource Prefabs")?;
writeln!(output_file, "{}", "=".repeat(70))?;
writeln!(output_file)?;
writeln!(output_file, "Total resources found: {}", all_resources.len())?;
writeln!(output_file)?;
writeln!(output_file, "{}", "-".repeat(70))?;
writeln!(output_file)?;
for (name, type_id, max_health) in &all_resources {
writeln!(output_file, "Prefab: {}", name)?;
writeln!(output_file, " TypeID: {}", type_id)?;
writeln!(output_file, " MaxHealth: {}", max_health)?;
writeln!(output_file)?;
}
writeln!(output_file, "{}", "=".repeat(70))?;
writeln!(output_file, "End of resource data")?;
println!("📝 Resource data written to: {}", output_path);
}
println!();
println!("{}", "=".repeat(70));
println!("✅ Parsing complete!");
println!("{}", "=".repeat(70));
Ok(())
}

View File

@@ -0,0 +1,147 @@
//! Parse Cursebreaker Resources from 10_3.unity Scene
//!
//! This example demonstrates:
//! 1. Parsing the Cursebreaker Unity project
//! 2. Finding Interactable_Resource components
//! 3. Extracting typeId and transform positions
//! 4. Writing resource data to an output file
use unity_parser::{UnityComponent, UnityFile};
use std::fs::File;
use std::io::Write;
use std::path::Path;
/// Interactable_Resource component from Cursebreaker
///
/// C# definition from Interactable_Resource.cs:
/// ```csharp
/// public class Interactable_Resource : Interactable
/// {
/// public int health;
/// public int maxHealth;
/// public int typeId;
/// // ... other fields
/// }
/// ```
#[derive(Debug, Clone, UnityComponent)]
#[unity_class("Interactable_Resource")]
pub struct InteractableResource {
#[unity_field("maxHealth")]
pub max_health: i64,
#[unity_field("typeId")]
pub type_id: i64,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("🎮 Cursebreaker - Resource Parser");
println!("{}", "=".repeat(70));
println!();
let scene_path = Path::new("/home/connor/repos/CBAssets/_GameAssets/Scenes/Tiles/10_3.unity");
// Check if scene exists
if !scene_path.exists() {
eprintln!("❌ Error: Scene not found at {}", scene_path.display());
return Err("Scene file not found".into());
}
println!("📁 Parsing scene: {}", scene_path.display());
println!();
// Parse the scene
match UnityFile::from_path(&scene_path) {
Ok(UnityFile::Scene(scene)) => {
println!("✅ Scene parsed successfully!");
println!(" Total entities: {}", scene.entity_map.len());
println!();
// Get views for component types we need
let resource_view = scene.world.borrow::<InteractableResource>();
let transform_view = scene.world.borrow::<unity_parser::Transform>();
let gameobject_view = scene.world.borrow::<unity_parser::GameObject>();
// Find all entities that have Interactable_Resource
let mut found_resources = Vec::new();
for entity in scene.entity_map.values() {
if let Some(resource) = resource_view.get(*entity) {
let transform = transform_view.get(*entity);
let game_object = gameobject_view.get(*entity);
let name = game_object
.and_then(|go| go.name())
.unwrap_or("(unnamed)");
let position = transform
.and_then(|t| t.local_position())
.map(|p| (p.x, p.y, p.z));
found_resources.push((name.to_string(), resource.clone(), position));
}
}
println!("🔍 Found {} Interactable_Resource component(s)", found_resources.len());
println!();
if !found_resources.is_empty() {
// Display resources in console
for (name, resource, position) in &found_resources {
println!(" 📦 Resource: \"{}\"", name);
println!(" • typeId: {}", resource.type_id);
println!(" • maxHealth: {}", resource.max_health);
if let Some((x, y, z)) = position {
println!(" • Position: ({:.2}, {:.2}, {:.2})", x, y, z);
} else {
println!(" • Position: (no transform)");
}
println!();
}
// Write to output file
let output_path = "resources_output.txt";
let mut output_file = File::create(output_path)?;
writeln!(output_file, "Cursebreaker Resources - 10_3.unity Scene")?;
writeln!(output_file, "{}", "=".repeat(70))?;
writeln!(output_file)?;
writeln!(output_file, "Total resources found: {}", found_resources.len())?;
writeln!(output_file)?;
writeln!(output_file, "{}", "-".repeat(70))?;
writeln!(output_file)?;
for (name, resource, position) in &found_resources {
writeln!(output_file, "Resource: {}", name)?;
writeln!(output_file, " TypeID: {}", resource.type_id)?;
writeln!(output_file, " MaxHealth: {}", resource.max_health)?;
if let Some((x, y, z)) = position {
writeln!(output_file, " Position: ({:.6}, {:.6}, {:.6})", x, y, z)?;
} else {
writeln!(output_file, " Position: N/A")?;
}
writeln!(output_file)?;
}
writeln!(output_file, "{}", "=".repeat(70))?;
writeln!(output_file, "End of resource data")?;
println!("📝 Resource data written to: {}", output_path);
println!();
}
println!("{}", "=".repeat(70));
println!("✅ Parsing complete!");
println!("{}", "=".repeat(70));
}
Ok(_) => {
eprintln!("❌ Error: File is not a scene");
return Err("Not a Unity scene file".into());
}
Err(e) => {
eprintln!("❌ Parse error: {}", e);
return Err(Box::new(e));
}
}
Ok(())
}

View File

@@ -0,0 +1,19 @@
Cursebreaker Resources - 10_3.unity Scene
======================================================================
Total resources found: 2
----------------------------------------------------------------------
Resource: HarvestableSpawner_11Redberries
TypeID: 11
MaxHealth: 0
Position: (1769.135864, 32.664658, 150.395081)
Resource: HarvestableSpawner_38Dandelions
TypeID: 38
MaxHealth: 0
Position: (1746.709717, 44.599632, 299.696503)
======================================================================
End of resource data

View File

@@ -0,0 +1,434 @@
//! ECS world building from Unity documents
use crate::model::RawDocument;
use crate::parser::{GuidResolver, PrefabGuidResolver};
use crate::types::{
yaml_helpers, ComponentContext, FileID, GameObject, LinkingContext, PrefabInstanceComponent,
PrefabResolver, RectTransform, Transform, TypeFilter, UnityComponent,
};
use crate::{Error, Result};
use sparsey::{Entity, World};
use std::cell::RefCell;
use std::collections::HashMap;
/// Build a Sparsey ECS World from raw Unity documents
///
/// This uses a 4-pass approach:
/// 1. Create entities for all GameObjects
/// 2. Attach components (Transform, RectTransform, etc.) to entities
/// 3. Resolve and instantiate prefab instances
/// 4. Resolve Callbacks generated when parsing components
///
/// # Arguments
/// - `documents`: Parsed Unity documents to build the world from
/// - `guid_resolver`: Optional GUID resolver for resolving MonoBehaviour scripts to class names
/// - `prefab_guid_resolver`: Optional prefab GUID resolver for automatic prefab instantiation
///
/// # Returns
/// A tuple of (World, FileID → Entity mapping)
pub fn build_world_from_documents(
documents: Vec<RawDocument>,
guid_resolver: Option<&GuidResolver>,
prefab_guid_resolver: Option<&PrefabGuidResolver>,
) -> Result<(World, HashMap<FileID, Entity>)> {
// Create World builder with registered component types
let mut builder = World::builder();
builder
.register::<GameObject>()
.register::<Transform>()
.register::<RectTransform>()
.register::<PrefabInstanceComponent>();
// Register all custom components from inventory
for reg in inventory::iter::<crate::types::ComponentRegistration> {
(reg.register)(&mut builder);
}
let mut world = builder.build();
let linking_ctx = RefCell::new(LinkingContext::new());
// PASS 1: Create entities for all GameObjects and PrefabInstances
for doc in documents.iter().filter(|d| d.type_id == 1 || d.class_name == "GameObject") {
let entity = spawn_game_object(&mut world, doc)?;
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
}
// Also create entities for PrefabInstances (type 1001)
for doc in documents.iter().filter(|d| d.type_id == 1001 || d.class_name == "PrefabInstance") {
// Create an entity to represent this prefab instance
let entity = world.create(());
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
// Parse and attach the PrefabInstanceComponent
if let Some(yaml) = doc.as_mapping() {
let ctx = ComponentContext {
type_id: doc.type_id,
file_id: doc.file_id,
class_name: &doc.class_name,
entity: Some(entity),
linking_ctx: Some(&linking_ctx),
yaml,
guid_resolver,
};
if let Some(prefab_comp) = PrefabInstanceComponent::parse(yaml, &ctx) {
world.insert(entity, (prefab_comp,));
}
}
}
// PASS 2: Attach components to entities
let type_filter = TypeFilter::parse_all();
for doc in documents.iter().filter(|d| {
d.type_id != 1 && d.class_name != "GameObject" &&
d.type_id != 1001 && d.class_name != "PrefabInstance"
}) {
attach_component(&mut world, doc, &linking_ctx, &type_filter, guid_resolver)?;
}
// PASS 2.5: Resolve and instantiate prefab instances (NEW)
if let Some(prefab_resolver_ref) = prefab_guid_resolver {
let mut prefab_resolver = PrefabResolver::from_resolvers(guid_resolver, prefab_resolver_ref);
// Query for entities with PrefabInstanceComponent
// We need to collect first to avoid borrowing conflicts
let prefab_view = world.borrow::<PrefabInstanceComponent>();
let prefab_entities: Vec<_> = linking_ctx
.borrow()
.entity_map()
.values()
.filter_map(|&entity| {
prefab_view.get(entity).map(|component| (entity, component.clone()))
})
.collect();
drop(prefab_view); // Release the borrow
eprintln!("Pass 2.5: Found {} prefab instance(s) to resolve", prefab_entities.len());
for (entity, component) in prefab_entities {
match prefab_resolver.instantiate_from_component(
&component,
Some(entity),
&mut world,
linking_ctx.borrow_mut().entity_map_mut(),
) {
Ok(spawned) => {
eprintln!(" ✅ Spawned {} entities from prefab GUID: {}",
spawned.len(), component.prefab_ref.guid);
}
Err(e) => {
// Soft failure - warn but continue
eprintln!(" ⚠️ Warning: Failed to instantiate prefab: {}", e);
}
}
// Remove PrefabInstanceComponent after resolution
// This prevents it from being processed again
let _ = world.remove::<(PrefabInstanceComponent,)>(entity);
}
}
// PASS 3: Execute all deferred linking callbacks
let entity_map = linking_ctx.into_inner().execute_callbacks(&mut world);
Ok((world, entity_map))
}
/// Build entities from raw Unity documents into an existing world
///
/// This is similar to `build_world_from_documents` but spawns into an existing
/// world instead of creating a new one. This is used for prefab instantiation.
///
/// Uses the same 3.5-pass approach:
/// 1. Create entities for all GameObjects
/// 2. Attach components (Transform, RectTransform, etc.) to entities
/// 2.5. Resolve and instantiate prefab instances (if resolver provided)
/// 3. Resolve Transform hierarchy (parent/children Entity references)
///
/// # Arguments
/// - `documents`: Parsed Unity documents to build entities from
/// - `world`: Existing Sparsey ECS world to spawn entities into
/// - `entity_map`: Existing entity map to merge new mappings into
/// - `guid_resolver`: Optional script GUID resolver for MonoBehaviour components
/// - `prefab_guid_resolver`: Optional prefab GUID resolver for nested prefab instantiation
///
/// # Returns
/// Vec of newly spawned entities
pub fn build_world_from_documents_into(
documents: Vec<RawDocument>,
world: &mut World,
entity_map: &mut HashMap<FileID, Entity>,
guid_resolver: Option<&GuidResolver>,
prefab_guid_resolver: Option<&PrefabGuidResolver>,
) -> Result<Vec<Entity>> {
let linking_ctx = RefCell::new(LinkingContext::new());
// Initialize linking context with existing entity_map
// This allows cross-references between prefab instances and scene entities
*linking_ctx.borrow_mut().entity_map_mut() = entity_map.clone();
let mut spawned_entities = Vec::new();
// PASS 1: Create entities for all GameObjects and PrefabInstances
for doc in documents.iter().filter(|d| d.type_id == 1 || d.class_name == "GameObject") {
let entity = spawn_game_object(world, doc)?;
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
spawned_entities.push(entity);
}
// Also create entities for PrefabInstances (type 1001)
for doc in documents.iter().filter(|d| d.type_id == 1001 || d.class_name == "PrefabInstance") {
// Create an entity to represent this prefab instance
let entity = world.create(());
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
spawned_entities.push(entity);
// Parse and attach the PrefabInstanceComponent
if let Some(yaml) = doc.as_mapping() {
let ctx = ComponentContext {
type_id: doc.type_id,
file_id: doc.file_id,
class_name: &doc.class_name,
entity: Some(entity),
linking_ctx: Some(&linking_ctx),
yaml,
guid_resolver: None, // Nested prefabs use None
};
if let Some(prefab_comp) = PrefabInstanceComponent::parse(yaml, &ctx) {
world.insert(entity, (prefab_comp,));
}
}
}
// PASS 2: Attach components to entities
let type_filter = TypeFilter::parse_all();
for doc in documents.iter().filter(|d| {
d.type_id != 1 && d.class_name != "GameObject" &&
d.type_id != 1001 && d.class_name != "PrefabInstance"
}) {
// Use GUID resolver to resolve MonoBehaviour components in prefabs
attach_component(world, doc, &linking_ctx, &type_filter, guid_resolver)?;
}
// PASS 2.5: Resolve and instantiate nested prefab instances
if let Some(prefab_resolver_ref) = prefab_guid_resolver {
let mut prefab_resolver = PrefabResolver::from_resolvers(guid_resolver, prefab_resolver_ref);
// Query for entities with PrefabInstanceComponent
let prefab_view = world.borrow::<PrefabInstanceComponent>();
let prefab_entities: Vec<_> = linking_ctx
.borrow()
.entity_map()
.values()
.filter_map(|&entity| {
prefab_view.get(entity).map(|component| (entity, component.clone()))
})
.collect();
drop(prefab_view); // Release the borrow
if !prefab_entities.is_empty() {
eprintln!("Pass 2.5 (nested): Found {} prefab instance(s) to resolve", prefab_entities.len());
}
for (entity, component) in prefab_entities {
match prefab_resolver.instantiate_from_component(
&component,
Some(entity),
world,
linking_ctx.borrow_mut().entity_map_mut(),
) {
Ok(spawned) => {
eprintln!(" ✅ Spawned {} entities from nested prefab", spawned.len());
spawned_entities.extend(spawned);
}
Err(e) => {
eprintln!(" ⚠️ Warning: Failed to instantiate nested prefab: {}", e);
}
}
// Remove PrefabInstanceComponent after resolution
let _ = world.remove::<(PrefabInstanceComponent,)>(entity);
}
}
// PASS 3: Execute all deferred linking callbacks
let final_entity_map = linking_ctx.into_inner().execute_callbacks(world);
// Update caller's entity_map with new mappings
entity_map.extend(final_entity_map);
Ok(spawned_entities)
}
/// Spawn a GameObject entity
fn spawn_game_object(world: &mut World, doc: &RawDocument) -> Result<Entity> {
let yaml = doc
.as_mapping()
.ok_or_else(|| Error::invalid_format("GameObject YAML must be mapping"))?;
let ctx = ComponentContext {
type_id: doc.type_id,
file_id: doc.file_id,
class_name: &doc.class_name,
entity: None,
linking_ctx: None,
yaml,
guid_resolver: None,
};
let go = GameObject::parse(yaml, &ctx)
.ok_or_else(|| Error::invalid_format("Failed to parse GameObject"))?;
// Create entity with GameObject component
let entity = world.create((go,));
Ok(entity)
}
/// Attach a component to its GameObject entity
fn attach_component(
world: &mut World,
doc: &RawDocument,
linking_ctx: &RefCell<LinkingContext>,
type_filter: &TypeFilter,
guid_resolver: Option<&GuidResolver>,
) -> Result<()> {
let yaml = doc
.as_mapping()
.ok_or_else(|| Error::invalid_format("Component YAML must be mapping"))?;
// Get m_GameObject reference to find which entity owns this component
let go_ref = yaml_helpers::get_file_ref(yaml, "m_GameObject");
let entity = match go_ref {
Some(r) => linking_ctx
.borrow()
.entity_map()
.get(&r.file_id)
.copied()
.ok_or_else(|| {
Error::reference_error(format!("Unknown GameObject: {}", r.file_id))
})?,
None => {
// Some components might not have m_GameObject (e.g., standalone assets)
eprintln!(
"Warning: Component {} has no m_GameObject reference",
doc.class_name
);
return Ok(());
}
};
let ctx = ComponentContext {
type_id: doc.type_id,
file_id: doc.file_id,
class_name: &doc.class_name,
entity: Some(entity),
linking_ctx: Some(linking_ctx),
yaml,
guid_resolver,
};
// Check type filter to see if we should parse this component
let is_custom = doc.class_name.as_str() != "Transform"
&& doc.class_name.as_str() != "RectTransform"
&& doc.class_name.as_str() != "PrefabInstance";
if !type_filter.should_parse(&doc.class_name, is_custom) {
// Skip this component type based on filter
return Ok(());
}
// Dispatch to appropriate component parser
match doc.class_name.as_str() {
"Transform" => {
if let Some(transform) = Transform::parse(yaml, &ctx) {
world.insert(entity, (transform,));
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
}
}
"RectTransform" => {
if let Some(rect) = RectTransform::parse(yaml, &ctx) {
world.insert(entity, (rect,));
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
}
}
"PrefabInstance" => {
// Parse and store nested prefab reference
if let Some(prefab_comp) = PrefabInstanceComponent::parse(yaml, &ctx) {
world.insert(entity, (prefab_comp,));
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
}
}
"MonoBehaviour" => {
// Extract m_Script GUID to resolve the actual class name
if let Some(resolver) = guid_resolver {
if let Some(script_ref) = yaml_helpers::get_external_ref(yaml, "m_Script") {
// Resolve GUID to class name
if let Some(class_name) = resolver.resolve_class_name(script_ref.guid.as_str()) {
// Try to find a registered custom component with this class name
let mut found_custom = false;
for reg in inventory::iter::<crate::types::ComponentRegistration> {
if reg.class_name == class_name {
found_custom = true;
// Parse and insert the component into the ECS world
if (reg.parse_and_insert)(yaml, &ctx, world, entity) {
// Successfully parsed and inserted
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
}
break;
}
}
if !found_custom {
// GUID resolved but no registered component found
eprintln!(
"Warning: Skipping MonoBehaviour '{}' (no registered parser)",
class_name
);
}
} else {
// GUID not found in resolver
eprintln!(
"Warning: Could not resolve MonoBehaviour GUID: {}",
script_ref.guid
);
}
} else {
// No m_Script reference found
eprintln!("Warning: MonoBehaviour missing m_Script reference");
}
} else {
// No GUID resolver available
eprintln!("Warning: Skipping MonoBehaviour (no GUID resolver available)");
}
}
_ => {
// Check if this is a registered custom component
let mut found_custom = false;
for reg in inventory::iter::<crate::types::ComponentRegistration> {
if reg.class_name == doc.class_name.as_str() {
found_custom = true;
// Parse and insert the component into the ECS world
if (reg.parse_and_insert)(yaml, &ctx, world, entity) {
// Successfully parsed and inserted
linking_ctx.borrow_mut().entity_map_mut().insert(doc.file_id, entity);
}
break;
}
}
if !found_custom {
// Unknown component type - skip with warning
eprintln!(
"Warning: Skipping unknown component type: {}",
doc.class_name
);
}
}
}
Ok(())
}

View File

@@ -6,7 +6,7 @@
//! # Example
//!
//! ```no_run
//! use cursebreaker_parser::UnityFile;
//! use unity_parser::UnityFile;
//!
//! let file = UnityFile::from_path("Scene.unity")?;
//! match file {
@@ -21,7 +21,7 @@
//! println!("Asset with {} documents", asset.documents.len());
//! }
//! }
//! # Ok::<(), cursebreaker_parser::Error>(())
//! # Ok::<(), unity_parser::Error>(())
//! ```
// Public modules
@@ -38,16 +38,19 @@ pub mod types;
// Re-exports
pub use error::{Error, Result};
pub use model::{RawDocument, UnityAsset, UnityFile, UnityPrefab, UnityScene};
pub use parser::{meta::MetaFile, parse_unity_file};
pub use parser::{
find_project_root, meta::MetaFile, parse_unity_file, parse_unity_file_filtered,
GuidResolver, PrefabGuidResolver,
};
// TODO: Re-enable once project module is updated
// pub use project::UnityProject;
pub use property::PropertyValue;
pub use types::{
get_class_name, get_type_id, Color, ComponentContext, ComponentRegistration, EcsInsertable,
ExternalRef, FileID, FileRef, GameObject, LocalID, PrefabInstance, PrefabInstanceComponent,
PrefabModification, PrefabResolver, Quaternion, RectTransform, Transform, TypeFilter,
UnityComponent, UnityReference, Vector2, Vector3, yaml_helpers,
ExternalRef, FileID, FileRef, GameObject, Guid, LocalID, PrefabInstance,
PrefabInstanceComponent, PrefabModification, PrefabResolver, Quaternion, RectTransform,
Transform, TypeFilter, UnityComponent, UnityReference, Vector2, Vector3, yaml_helpers,
};
// Re-export the derive macro from the macro crate
pub use cursebreaker_parser_macros::UnityComponent;
pub use unity_parser_macros::UnityComponent;

View File

@@ -0,0 +1,501 @@
//! Unity GUID resolution for MonoBehaviour scripts
//!
//! This module resolves Unity GUIDs to their corresponding MonoBehaviour class names
//! by scanning .meta files and parsing C# scripts.
//!
//! # How Unity GUID Resolution Works
//!
//! 1. Every asset has a `.meta` file with a unique GUID
//! 2. MonoBehaviour components reference scripts via GUID in `m_Script`
//! 3. We scan `.cs.meta` files to build a GUID → Script Path mapping
//! 4. We parse the `.cs` files to extract the class name
//! 5. Result: GUID → Class Name mapping for component registration
//!
//! # Example
//!
//! ```no_run
//! use unity_parser::parser::GuidResolver;
//! use std::path::Path;
//!
//! // Build resolver from Unity project directory
//! let project_path = Path::new("path/to/UnityProject");
//! let resolver = GuidResolver::from_project(project_path)?;
//!
//! // Resolve a GUID to class name
//! let guid = "091c537484687e9419460cdcd7038234";
//! if let Some(class_name) = resolver.resolve_class_name(guid) {
//! println!("GUID {} → {}", guid, class_name);
//! }
//! # Ok::<(), unity_parser::Error>(())
//! ```
use crate::parser::meta::MetaFile;
use crate::types::Guid;
use crate::{Error, Result};
use regex::Regex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
/// Resolves Unity GUIDs to MonoBehaviour class names
///
/// This struct builds a mapping from GUID to class name by scanning
/// a Unity project's `.cs.meta` files and parsing the corresponding
/// C# scripts to extract class names.
///
/// Uses a 128-bit `Guid` type for efficient storage and fast comparisons.
#[derive(Debug, Clone)]
pub struct GuidResolver {
/// Map from GUID to MonoBehaviour class name
guid_to_class: HashMap<Guid, String>,
}
impl GuidResolver {
/// Create a new empty GuidResolver
pub fn new() -> Self {
Self {
guid_to_class: HashMap::new(),
}
}
/// Build a GuidResolver by scanning a Unity project directory
///
/// This scans for all `.cs.meta` files (MonoBehaviour scripts),
/// parses them to extract GUIDs, then parses the corresponding
/// C# files to extract class names.
///
/// # Arguments
///
/// * `project_path` - Path to the Unity project root (containing Assets/ folder)
///
/// # Examples
///
/// ```no_run
/// use unity_parser::parser::GuidResolver;
/// use std::path::Path;
///
/// let resolver = GuidResolver::from_project(Path::new("MyUnityProject"))?;
/// # Ok::<(), unity_parser::Error>(())
/// ```
pub fn from_project(project_path: impl AsRef<Path>) -> Result<Self> {
let project_path = project_path.as_ref();
// Verify this looks like a Unity project - check for Assets/ or _GameAssets/
let assets_dir = if project_path.join("Assets").exists() {
project_path.join("Assets")
} else if project_path.join("_GameAssets").exists() {
project_path.join("_GameAssets")
} else {
return Err(Error::invalid_format(format!(
"Not a Unity project: missing Assets/ or _GameAssets/ directory at {}",
project_path.display()
)));
};
let mut resolver = Self::new();
// Walk the Assets directory looking for .cs.meta files
for entry in WalkDir::new(&assets_dir)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
// Only process .cs.meta files (MonoBehaviour scripts)
if !is_cs_meta_file(path) {
continue;
}
// Parse the .meta file to get the GUID
let meta = match MetaFile::from_path(path) {
Ok(meta) => meta,
Err(e) => {
eprintln!("Warning: Failed to parse {}: {}", path.display(), e);
continue;
}
};
// Get the corresponding .cs file path
let cs_path = path.with_file_name(
path.file_name()
.and_then(|s| s.to_str())
.and_then(|s| s.strip_suffix(".meta"))
.unwrap_or(""),
);
// Extract the class name from the .cs file
let class_name = match extract_class_name(&cs_path) {
Ok(Some(name)) => name,
Ok(None) => {
// No MonoBehaviour class found in this file
continue;
}
Err(e) => {
eprintln!(
"Warning: Failed to extract class name from {}: {}",
cs_path.display(),
e
);
continue;
}
};
// Parse the GUID string to Guid type
let guid = match Guid::from_hex(meta.guid()) {
Ok(g) => g,
Err(e) => {
eprintln!(
"Warning: Invalid GUID in {}: {}",
path.display(),
e
);
continue;
}
};
// Store the mapping
resolver.guid_to_class.insert(guid, class_name);
}
Ok(resolver)
}
/// Resolve a GUID to its class name
///
/// Accepts either a `&Guid` or a string that can be parsed as a GUID.
///
/// # Arguments
///
/// * `guid` - The Unity GUID (e.g., "091c537484687e9419460cdcd7038234" or a `Guid`)
///
/// # Returns
///
/// The class name if the GUID is found, otherwise None
///
/// # Examples
///
/// ```no_run
/// # use unity_parser::{GuidResolver, Guid};
/// # use std::path::Path;
/// # let resolver = GuidResolver::from_project(Path::new("."))?;
/// // Resolve by string
/// if let Some(class_name) = resolver.resolve_class_name("091c537484687e9419460cdcd7038234") {
/// println!("Found class: {}", class_name);
/// }
///
/// // Resolve by Guid
/// let guid = Guid::from_hex("091c537484687e9419460cdcd7038234")?;
/// if let Some(class_name) = resolver.resolve_class_name(&guid) {
/// println!("Found class: {}", class_name);
/// }
/// # Ok::<(), unity_parser::Error>(())
/// ```
pub fn resolve_class_name<G: AsGuid>(&self, guid: G) -> Option<&str> {
guid.as_guid()
.and_then(|g| self.guid_to_class.get(&g))
.map(|s| s.as_str())
}
/// Get the number of GUID mappings
pub fn len(&self) -> usize {
self.guid_to_class.len()
}
/// Check if the resolver is empty
pub fn is_empty(&self) -> bool {
self.guid_to_class.is_empty()
}
/// Insert a GUID → class name mapping manually
///
/// Useful for testing or adding custom mappings
///
/// # Examples
///
/// ```
/// use unity_parser::{GuidResolver, Guid};
///
/// let mut resolver = GuidResolver::new();
/// let guid = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap();
/// resolver.insert(guid, "PlaySFX".to_string());
/// ```
pub fn insert(&mut self, guid: Guid, class_name: String) {
self.guid_to_class.insert(guid, class_name);
}
/// Get all resolved GUIDs
///
/// Returns an iterator over all GUIDs in the resolver.
pub fn guids(&self) -> impl Iterator<Item = Guid> + '_ {
self.guid_to_class.keys().copied()
}
}
impl Default for GuidResolver {
fn default() -> Self {
Self::new()
}
}
/// Trait for types that can be converted to a GUID for lookup
///
/// This allows the `resolve_class_name` method to accept both `&Guid` and `&str`.
pub trait AsGuid {
/// Convert to a GUID, returning None if conversion fails
fn as_guid(&self) -> Option<Guid>;
}
impl AsGuid for Guid {
fn as_guid(&self) -> Option<Guid> {
Some(*self)
}
}
impl AsGuid for &Guid {
fn as_guid(&self) -> Option<Guid> {
Some(**self)
}
}
impl AsGuid for str {
fn as_guid(&self) -> Option<Guid> {
Guid::from_hex(self).ok()
}
}
impl AsGuid for &str {
fn as_guid(&self) -> Option<Guid> {
Guid::from_hex(self).ok()
}
}
impl AsGuid for String {
fn as_guid(&self) -> Option<Guid> {
Guid::from_hex(self).ok()
}
}
/// Check if a path points to a C# .meta file
fn is_cs_meta_file(path: &Path) -> bool {
path.extension()
.and_then(|s| s.to_str())
.map(|ext| ext == "meta")
.unwrap_or(false)
&& path
.file_name()
.and_then(|s| s.to_str())
.map(|name| name.ends_with(".cs.meta"))
.unwrap_or(false)
}
/// Extract the MonoBehaviour class name from a C# script file
///
/// This looks for patterns like:
/// - `public class ClassName : MonoBehaviour`
/// - `class ClassName : MonoBehaviour`
/// - `public class ClassName:MonoBehaviour`
///
/// # Arguments
///
/// * `cs_path` - Path to the .cs file
///
/// # Returns
///
/// Ok(Some(class_name)) if a MonoBehaviour class was found
/// Ok(None) if no MonoBehaviour class was found
/// Err if the file couldn't be read
fn extract_class_name(cs_path: &Path) -> Result<Option<String>> {
let content = std::fs::read_to_string(cs_path)?;
// Regex to match MonoBehaviour class declarations (direct or indirect inheritance)
// Matches: public class ClassName : MonoBehaviour
// Also matches: public class ClassName : SomeBaseClass (which may inherit from MonoBehaviour)
// Captures the class name in group 1
//
// We match any class with inheritance (: BaseClass) because in Unity,
// scripts can inherit from MonoBehaviour indirectly through base classes.
// The component registration system will filter for actual MonoBehaviours.
let class_regex = Regex::new(
r"(?:public\s+)?class\s+(\w+)\s*:\s*\w+"
).unwrap();
// Find the first class with inheritance in the file
// Unity typically has one main class per script file
if let Some(captures) = class_regex.captures(&content) {
if let Some(class_name) = captures.get(1) {
return Ok(Some(class_name.as_str().to_string()));
}
}
Ok(None)
}
/// Find the Unity project root from any path within the project
///
/// Searches upward from the given path until it finds a directory
/// containing an "Assets" folder.
///
/// # Arguments
///
/// * `path` - Any path within the Unity project (file or directory)
///
/// # Returns
///
/// The project root path if found, otherwise an error
///
/// # Examples
///
/// ```no_run
/// use unity_parser::parser::find_project_root;
/// use std::path::Path;
///
/// let scene_path = Path::new("MyProject/Assets/Scenes/Main.unity");
/// let project_root = find_project_root(scene_path)?;
/// assert_eq!(project_root.file_name().unwrap(), "MyProject");
/// # Ok::<(), unity_parser::Error>(())
/// ```
pub fn find_project_root(path: impl AsRef<Path>) -> Result<PathBuf> {
let path = path.as_ref();
// Start from the file's directory (or the directory itself)
let mut current = if path.is_file() {
path.parent().ok_or_else(|| {
Error::invalid_format("Cannot get parent directory")
})?
} else {
path
};
// Search upward for Assets/ or _GameAssets/ directory
loop {
let assets_dir = current.join("Assets");
let game_assets_dir = current.join("_GameAssets");
if (assets_dir.exists() && assets_dir.is_dir()) || (game_assets_dir.exists() && game_assets_dir.is_dir()) {
return Ok(current.to_path_buf());
}
// Move up one directory
current = current.parent().ok_or_else(|| {
Error::invalid_format(format!(
"Could not find Unity project root from {}",
path.display()
))
})?;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_cs_meta_file() {
assert!(is_cs_meta_file(Path::new("PlaySFX.cs.meta")));
assert!(is_cs_meta_file(Path::new("Assets/Scripts/PlaySFX.cs.meta")));
assert!(!is_cs_meta_file(Path::new("PlaySFX.cs")));
assert!(!is_cs_meta_file(Path::new("Scene.unity.meta")));
assert!(!is_cs_meta_file(Path::new("texture.png.meta")));
}
#[test]
fn test_extract_class_name() {
// Test standard MonoBehaviour class
let content = r#"
using UnityEngine;
public class PlaySFX : MonoBehaviour
{
public float volume = 1.0f;
}
"#;
let temp_file = std::env::temp_dir().join("test_playsfx.cs");
std::fs::write(&temp_file, content).unwrap();
let result = extract_class_name(&temp_file).unwrap();
assert_eq!(result, Some("PlaySFX".to_string()));
std::fs::remove_file(&temp_file).ok();
}
#[test]
fn test_extract_class_name_no_public() {
// Test without public modifier
let content = r#"
using UnityEngine;
class MyBehaviour : MonoBehaviour
{
void Start() {}
}
"#;
let temp_file = std::env::temp_dir().join("test_mybehaviour.cs");
std::fs::write(&temp_file, content).unwrap();
let result = extract_class_name(&temp_file).unwrap();
assert_eq!(result, Some("MyBehaviour".to_string()));
std::fs::remove_file(&temp_file).ok();
}
#[test]
fn test_extract_class_name_no_space() {
// Test with no space after colon
let content = r#"
using UnityEngine;
public class TestScript:MonoBehaviour
{
void Update() {}
}
"#;
let temp_file = std::env::temp_dir().join("test_testscript.cs");
std::fs::write(&temp_file, content).unwrap();
let result = extract_class_name(&temp_file).unwrap();
assert_eq!(result, Some("TestScript".to_string()));
std::fs::remove_file(&temp_file).ok();
}
#[test]
fn test_extract_class_name_not_monobehaviour() {
// Test class that doesn't inherit from MonoBehaviour
let content = r#"
using UnityEngine;
public class HelperClass
{
public int value;
}
"#;
let temp_file = std::env::temp_dir().join("test_helper.cs");
std::fs::write(&temp_file, content).unwrap();
let result = extract_class_name(&temp_file).unwrap();
assert_eq!(result, None);
std::fs::remove_file(&temp_file).ok();
}
#[test]
fn test_guid_resolver_manual() {
let mut resolver = GuidResolver::new();
assert!(resolver.is_empty());
let guid = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap();
resolver.insert(guid, "PlaySFX".to_string());
assert_eq!(resolver.len(), 1);
// Test resolving by string
assert_eq!(
resolver.resolve_class_name("091c537484687e9419460cdcd7038234"),
Some("PlaySFX")
);
// Test resolving by Guid
assert_eq!(
resolver.resolve_class_name(&guid),
Some("PlaySFX")
);
// Test nonexistent GUID
assert_eq!(
resolver.resolve_class_name("00000000000000000000000000000000"),
None
);
}
}

View File

@@ -29,11 +29,11 @@ impl MetaFile {
/// # Examples
///
/// ```no_run
/// use cursebreaker_parser::parser::meta::MetaFile;
/// use unity_parser::parser::meta::MetaFile;
///
/// let meta = MetaFile::from_path("Assets/Prefabs/Player.prefab.meta")?;
/// println!("GUID: {}", meta.guid);
/// # Ok::<(), cursebreaker_parser::Error>(())
/// # Ok::<(), unity_parser::Error>(())
/// ```
pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
@@ -90,7 +90,7 @@ impl MetaFile {
/// # Examples
///
/// ```
/// use cursebreaker_parser::parser::meta::get_meta_path;
/// use unity_parser::parser::meta::get_meta_path;
/// use std::path::PathBuf;
///
/// let asset = PathBuf::from("Assets/Scenes/MainMenu.unity");

View File

@@ -0,0 +1,638 @@
//! Unity YAML parsing module
pub mod guid_resolver;
pub mod meta;
pub mod prefab_guid_resolver;
mod unity_tag;
mod yaml;
pub use guid_resolver::{find_project_root, GuidResolver};
pub use meta::{get_meta_path, MetaFile};
pub use prefab_guid_resolver::PrefabGuidResolver;
pub use unity_tag::{parse_unity_tag, UnityTag};
pub use yaml::split_yaml_documents;
use crate::model::{RawDocument, UnityAsset, UnityFile, UnityPrefab, UnityScene};
use crate::types::{FileID, Guid, TypeFilter};
use crate::{Error, Result};
use regex::Regex;
use std::path::Path;
/// Parse a Unity file from the given path
///
/// Automatically detects file type based on extension:
/// - .unity → UnityFile::Scene with ECS World
/// - .prefab → UnityFile::Prefab with raw YAML
/// - .asset → UnityFile::Asset with raw YAML
///
/// By default, parses all files. Use `parse_unity_file_filtered` for regex filtering.
///
/// # Example
///
/// ```no_run
/// use unity_parser::parser::parse_unity_file;
/// use unity_parser::UnityFile;
/// use std::path::Path;
///
/// let file = parse_unity_file(Path::new("Scene.unity"))?;
/// match file {
/// UnityFile::Scene(scene) => println!("Scene with {} entities", scene.entity_map.len()),
/// UnityFile::Prefab(prefab) => println!("Prefab with {} documents", prefab.documents.len()),
/// UnityFile::Asset(asset) => println!("Asset with {} documents", asset.documents.len()),
/// }
/// # Ok::<(), unity_parser::Error>(())
/// ```
pub fn parse_unity_file(path: &Path) -> Result<UnityFile> {
parse_unity_file_filtered(path, None, None)
}
/// Parse a Unity file with optional regex filtering and type filtering
///
/// Same as `parse_unity_file`, but allows filtering files by path pattern and Unity types.
/// If the path doesn't match the regex, returns an error.
///
/// # Arguments
///
/// * `path` - Path to the Unity file to parse
/// * `filter` - Optional regex to match against the file path. If None, parses all files (default behavior).
/// * `type_filter` - Optional filter for Unity types and MonoBehaviour GUIDs. If None, parses all types (default behavior).
///
/// # Example
///
/// ```no_run
/// use unity_parser::parser::{parse_unity_file_filtered};
/// use unity_parser::TypeFilter;
/// use regex::Regex;
/// use std::path::Path;
/// use std::collections::HashSet;
///
/// // Only parse files with "Test" in the name
/// let filter = Regex::new(r"Test").unwrap();
/// let file = parse_unity_file_filtered(Path::new("TestScene.unity"), Some(&filter), None)?;
///
/// // Only parse Transform and GameObject types
/// let mut types = HashSet::new();
/// types.insert("Transform".to_string());
/// types.insert("GameObject".to_string());
/// let type_filter = TypeFilter::with_unity_types(types);
/// let file2 = parse_unity_file_filtered(Path::new("Scene.unity"), None, Some(&type_filter))?;
/// # Ok::<(), unity_parser::Error>(())
/// ```
pub fn parse_unity_file_filtered(
path: &Path,
filter: Option<&Regex>,
type_filter: Option<&TypeFilter>,
) -> Result<UnityFile> {
// Apply filter if provided
if let Some(regex) = filter {
let path_str = path.to_str().ok_or_else(|| {
Error::invalid_format("Path contains invalid UTF-8")
})?;
if !regex.is_match(path_str) {
return Err(Error::invalid_format(format!(
"Path '{}' does not match filter pattern",
path.display()
)));
}
}
parse_unity_file_impl(path, type_filter)
}
/// Internal implementation of Unity file parsing
fn parse_unity_file_impl(path: &Path, type_filter: Option<&TypeFilter>) -> Result<UnityFile> {
// Read the file
let content = std::fs::read_to_string(path)?;
// Validate Unity header
validate_unity_header(&content, path)?;
// Detect file type by extension
let file_type = detect_file_type(path);
// Parse based on file type
match file_type {
FileType::Scene => parse_scene(path, &content, type_filter),
FileType::Prefab => parse_prefab(path, &content, type_filter),
FileType::Asset => parse_asset(path, &content, type_filter),
FileType::Unknown => Err(Error::invalid_format(format!(
"Unknown file extension: {}",
path.display()
))),
}
}
/// File type enumeration
enum FileType {
Scene,
Prefab,
Asset,
Unknown,
}
/// Detect file type based on extension
fn detect_file_type(path: &Path) -> FileType {
match path.extension().and_then(|s| s.to_str()) {
Some("unity") => FileType::Scene,
Some("prefab") => FileType::Prefab,
Some("asset") => FileType::Asset,
_ => FileType::Unknown,
}
}
/// Parse a scene file into an ECS World
fn parse_scene(path: &Path, content: &str, type_filter: Option<&TypeFilter>) -> Result<UnityFile> {
let raw_documents = parse_raw_documents(content, type_filter)?;
// Try to find Unity project root and build both GUID resolvers
let (guid_resolver, prefab_guid_resolver) = match find_project_root(path) {
Ok(project_root) => {
eprintln!("📦 Found Unity project root: {}", project_root.display());
// Build script GUID resolver
let guid_res = match GuidResolver::from_project(&project_root) {
Ok(resolver) => {
eprintln!(" ✅ Script GUID resolver built ({} mappings)", resolver.len());
Some(resolver)
}
Err(e) => {
eprintln!(" ⚠️ Warning: Failed to build script GUID resolver: {}", e);
None
}
};
// Build prefab GUID resolver
let prefab_res = match PrefabGuidResolver::from_project(&project_root) {
Ok(resolver) => {
eprintln!(" ✅ Prefab GUID resolver built ({} mappings)", resolver.len());
Some(resolver)
}
Err(e) => {
eprintln!(" ⚠️ Warning: Failed to build prefab GUID resolver: {}", e);
None
}
};
(guid_res, prefab_res)
}
Err(_) => {
// Not part of a Unity project, or project root not found
(None, None)
}
};
// Build ECS world from documents with both resolvers
let (world, entity_map) = crate::ecs::build_world_from_documents(
raw_documents,
guid_resolver.as_ref(),
prefab_guid_resolver.as_ref(),
)?;
Ok(UnityFile::Scene(UnityScene::new(
path.to_path_buf(),
world,
entity_map,
)))
}
/// Parse a prefab file into raw YAML documents
fn parse_prefab(path: &Path, content: &str, type_filter: Option<&TypeFilter>) -> Result<UnityFile> {
let raw_documents = parse_raw_documents(content, type_filter)?;
Ok(UnityFile::Prefab(UnityPrefab::new(
path.to_path_buf(),
raw_documents,
)))
}
/// Parse an asset file into raw YAML documents
fn parse_asset(path: &Path, content: &str, type_filter: Option<&TypeFilter>) -> Result<UnityFile> {
let raw_documents = parse_raw_documents(content, type_filter)?;
Ok(UnityFile::Asset(UnityAsset::new(
path.to_path_buf(),
raw_documents,
)))
}
/// Parse raw YAML documents from file content with optional type filtering
fn parse_raw_documents(content: &str, type_filter: Option<&TypeFilter>) -> Result<Vec<RawDocument>> {
// Split into individual YAML documents
let raw_docs = split_yaml_documents(content)?;
// Parse each document
raw_docs
.iter()
.filter_map(|raw| parse_raw_document(raw, type_filter).transpose())
.collect()
}
/// Parse a single raw YAML document into a RawDocument with optional type filtering
fn parse_raw_document(raw_doc: &str, type_filter: Option<&TypeFilter>) -> Result<Option<RawDocument>> {
// Parse the Unity tag line (e.g., "--- !u!1 &12345")
let tag = match parse_unity_tag(raw_doc) {
Some(tag) => tag,
None => return Ok(None), // Skip documents without Unity tags
};
// Extract the YAML content (everything after the tag line)
let yaml_content = extract_yaml_content(raw_doc);
if yaml_content.trim().is_empty() {
return Ok(None);
}
// Early filtering: Extract class name without full YAML parsing
if let Some(filter) = type_filter {
if filter.is_filtering() {
// Extract the class name efficiently
let class_name = match extract_class_name(yaml_content) {
Some(name) => name,
None => return Ok(None), // Can't extract class name, skip
};
// Check if this is a MonoBehaviour
if class_name == "MonoBehaviour" {
// For MonoBehaviour, we need to check the m_Script GUID
match extract_monobehaviour_guid(yaml_content) {
Some(guid) => {
if !filter.should_parse_guid(&guid) {
// GUID not in whitelist, skip this document
return Ok(None);
}
}
None => {
// Can't extract GUID, skip this MonoBehaviour
return Ok(None);
}
}
} else {
// For non-MonoBehaviour, check the Unity type whitelist
if !filter.should_parse_type(class_name) {
// Type not in whitelist, skip this document
return Ok(None);
}
}
}
}
// If we reach here, the document passed the filter (or no filter was applied)
// Now do the full YAML parsing
let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_content)?;
// Unity documents have format "GameObject: { ... }"
// Extract class name and inner YAML
let (class_name, inner_yaml) = match &yaml_value {
serde_yaml::Value::Mapping(map) if map.len() == 1 => {
// Single-key mapping - this is the standard Unity format
let (key, value) = map.iter().next().unwrap();
let class_name = key
.as_str()
.ok_or_else(|| Error::invalid_format("Class name must be string"))?
.to_string();
(class_name, value.clone())
}
_ => {
// Fallback for malformed documents
let class_name = format!("UnityType{}", tag.type_id);
(class_name, yaml_value)
}
};
Ok(Some(RawDocument::new(
tag.type_id,
FileID::from_i64(tag.file_id),
class_name,
inner_yaml,
)))
}
/// Validate that the file has a proper Unity YAML header
fn validate_unity_header(content: &str, path: &Path) -> Result<()> {
let has_yaml_header = content.starts_with("%YAML");
let has_unity_tag = content.contains("%TAG !u! tag:unity3d.com");
if !has_yaml_header || !has_unity_tag {
return Err(Error::MissingHeader(path.to_path_buf()));
}
Ok(())
}
/// Extract the YAML content from a raw document (skip the Unity tag line)
fn extract_yaml_content(raw_doc: &str) -> &str {
// Find the first newline after the "--- !u!" tag
if let Some(first_line_end) = raw_doc.find('\n') {
&raw_doc[first_line_end + 1..]
} else {
""
}
}
/// Extract the Unity class name from YAML content without full parsing
///
/// Unity documents have the format:
/// ```yaml
/// ClassName:
/// field1: value1
/// field2: value2
/// ```
///
/// This function extracts "ClassName" efficiently without parsing the entire YAML.
fn extract_class_name(yaml_content: &str) -> Option<&str> {
// Find the first line that's not empty
let first_line = yaml_content.lines().find(|line| !line.trim().is_empty())?;
// Class name is the first non-whitespace text before ':'
let class_name = first_line.trim().strip_suffix(':')?;
Some(class_name)
}
/// Extract the m_Script GUID from a MonoBehaviour YAML document without full parsing
///
/// MonoBehaviour documents have the format:
/// ```yaml
/// MonoBehaviour:
/// m_Script: {fileID: 11500000, guid: d39ddbf1c2c3d1a4baa070e5e76548bd, type: 3}
/// ...
/// ```
///
/// Or multi-line format:
/// ```yaml
/// MonoBehaviour:
/// m_Script:
/// fileID: 11500000
/// guid: d39ddbf1c2c3d1a4baa070e5e76548bd
/// type: 3
/// ```
///
/// This function extracts the GUID value efficiently.
fn extract_monobehaviour_guid(yaml_content: &str) -> Option<Guid> {
// Look for any line with "guid: <32 hex chars>"
// This works for both inline and multi-line formats
for line in yaml_content.lines() {
if line.contains("guid:") {
// Find "guid: " and extract the 32-character hex string after it
if let Some(guid_start) = line.find("guid:") {
let after_guid = &line[guid_start + 5..].trim();
// Extract the hex string (32 characters)
let guid_str: String = after_guid
.chars()
.take_while(|c| c.is_ascii_hexdigit())
.collect();
if guid_str.len() == 32 {
return Guid::from_hex(&guid_str).ok();
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_unity_header() {
let valid_content = "%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n";
assert!(validate_unity_header(valid_content, Path::new("test.unity")).is_ok());
let invalid_content = "Not a Unity file";
assert!(validate_unity_header(invalid_content, Path::new("test.unity")).is_err());
}
#[test]
fn test_extract_yaml_content() {
let raw_doc = "--- !u!1 &12345\nGameObject:\n m_Name: Test";
let content = extract_yaml_content(raw_doc);
assert_eq!(content, "GameObject:\n m_Name: Test");
}
#[test]
fn test_detect_file_type() {
assert!(matches!(
detect_file_type(Path::new("test.unity")),
FileType::Scene
));
assert!(matches!(
detect_file_type(Path::new("test.prefab")),
FileType::Prefab
));
assert!(matches!(
detect_file_type(Path::new("test.asset")),
FileType::Asset
));
assert!(matches!(
detect_file_type(Path::new("test.txt")),
FileType::Unknown
));
}
#[test]
fn test_parse_unity_file_filtered_accepts_matching_path() {
use regex::Regex;
let filter = Regex::new(r"Test").unwrap();
let path = Path::new("TestScene.unity");
// Should match and attempt to parse (will fail because file doesn't exist)
let result = parse_unity_file_filtered(path, Some(&filter), None);
assert!(result.is_err());
// Error should be IO error (file not found), not filter error
match result {
Err(e) => {
let err_msg = e.to_string();
assert!(
!err_msg.contains("does not match filter"),
"Should not be a filter error, got: {}",
err_msg
);
}
Ok(_) => panic!("Expected error for non-existent file"),
}
}
#[test]
fn test_parse_unity_file_filtered_rejects_non_matching_path() {
use regex::Regex;
let filter = Regex::new(r"Test").unwrap();
let path = Path::new("MainScene.unity");
// Should reject due to filter
let result = parse_unity_file_filtered(path, Some(&filter), None);
assert!(result.is_err());
// Error should be filter error
match result {
Err(e) => {
let err_msg = e.to_string();
assert!(
err_msg.contains("does not match filter"),
"Expected filter error, got: {}",
err_msg
);
}
Ok(_) => panic!("Expected filter error"),
}
}
#[test]
fn test_parse_unity_file_filtered_none_accepts_all() {
let path = Path::new("AnyScene.unity");
// No filter should accept any path (will fail with IO error)
let result = parse_unity_file_filtered(path, None, None);
assert!(result.is_err());
// Should be IO error, not filter error
match result {
Err(e) => {
let err_msg = e.to_string();
assert!(
!err_msg.contains("does not match filter"),
"Should not be a filter error with None filter, got: {}",
err_msg
);
}
Ok(_) => panic!("Expected IO error for non-existent file"),
}
}
#[test]
fn test_parse_unity_file_uses_default_filter() {
let path = Path::new("AnyScene.unity");
// parse_unity_file should work the same as filtered with None
let result1 = parse_unity_file(path);
let result2 = parse_unity_file_filtered(path, None, None);
// Both should have the same error (IO error for missing file)
assert!(result1.is_err());
assert!(result2.is_err());
}
#[test]
fn test_extract_class_name() {
let yaml = "GameObject:\n m_Name: Test";
assert_eq!(extract_class_name(yaml), Some("GameObject"));
let yaml2 = "Transform:\n m_LocalPosition: {x: 1, y: 2, z: 3}";
assert_eq!(extract_class_name(yaml2), Some("Transform"));
let yaml3 = "MonoBehaviour:\n m_Script: {fileID: 11500000}";
assert_eq!(extract_class_name(yaml3), Some("MonoBehaviour"));
let empty = "";
assert_eq!(extract_class_name(empty), None);
}
#[test]
fn test_extract_monobehaviour_guid() {
let yaml = "MonoBehaviour:\n m_Script: {fileID: 11500000, guid: d39ddbf1c2c3d1a4baa070e5e76548bd, type: 3}";
let guid = extract_monobehaviour_guid(yaml);
assert!(guid.is_some());
assert_eq!(
guid.unwrap().to_hex(),
"d39ddbf1c2c3d1a4baa070e5e76548bd"
);
// Multi-line format
let yaml2 = "MonoBehaviour:\n m_Script:\n fileID: 11500000\n guid: abc123def456789012345678901234ab\n type: 3";
let guid2 = extract_monobehaviour_guid(yaml2);
assert!(guid2.is_some());
assert_eq!(
guid2.unwrap().to_hex(),
"abc123def456789012345678901234ab"
);
let no_guid = "MonoBehaviour:\n m_Name: Test";
assert_eq!(extract_monobehaviour_guid(no_guid), None);
}
#[test]
fn test_type_filter_document_parse_all() {
let filter = TypeFilter::parse_all();
assert!(filter.should_parse_type("Transform"));
assert!(filter.should_parse_type("GameObject"));
assert!(filter.should_parse_type("AnyType"));
assert!(!filter.is_filtering());
}
#[test]
fn test_type_filter_document_with_unity_types() {
use std::collections::HashSet;
let mut types = HashSet::new();
types.insert("Transform".to_string());
types.insert("GameObject".to_string());
let filter = TypeFilter::with_unity_types(types);
assert!(filter.should_parse_type("Transform"));
assert!(filter.should_parse_type("GameObject"));
assert!(!filter.should_parse_type("RectTransform"));
assert!(filter.is_filtering());
// Should still accept any MonoBehaviour GUID since we didn't set a GUID filter
let guid = Guid::from_hex("d39ddbf1c2c3d1a4baa070e5e76548bd").unwrap();
assert!(filter.should_parse_guid(&guid));
}
#[test]
fn test_type_filter_document_with_monobehaviour_guids() {
use std::collections::HashSet;
let mut guids = HashSet::new();
let guid1 = Guid::from_hex("d39ddbf1c2c3d1a4baa070e5e76548bd").unwrap();
let guid2 = Guid::from_hex("abc123def456789012345678901234ab").unwrap();
guids.insert(guid1);
guids.insert(guid2);
let filter = TypeFilter::with_monobehaviour_guids(guids);
assert!(filter.should_parse_guid(&guid1));
assert!(filter.should_parse_guid(&guid2));
let guid3 = Guid::from_hex("00000000000000000000000000000000").unwrap();
assert!(!filter.should_parse_guid(&guid3));
assert!(filter.is_filtering());
// Should still accept any Unity type since we didn't set a type filter
assert!(filter.should_parse_type("Transform"));
assert!(filter.should_parse_type("AnyType"));
}
#[test]
fn test_type_filter_document_with_both() {
use std::collections::HashSet;
let mut types = HashSet::new();
types.insert("Transform".to_string());
let mut guids = HashSet::new();
let guid1 = Guid::from_hex("d39ddbf1c2c3d1a4baa070e5e76548bd").unwrap();
guids.insert(guid1);
let filter = TypeFilter::with_both(types, guids);
// Only Transform should pass
assert!(filter.should_parse_type("Transform"));
assert!(!filter.should_parse_type("GameObject"));
// Only guid1 should pass
assert!(filter.should_parse_guid(&guid1));
let guid2 = Guid::from_hex("abc123def456789012345678901234ab").unwrap();
assert!(!filter.should_parse_guid(&guid2));
assert!(filter.is_filtering());
}
}

View File

@@ -0,0 +1,314 @@
//! Unity GUID resolution for Prefab files
//!
//! This module resolves Unity GUIDs to their corresponding Prefab file paths
//! by scanning .prefab.meta files.
//!
//! # How Unity Prefab GUID Resolution Works
//!
//! 1. Every prefab has a `.prefab.meta` file with a unique GUID
//! 2. PrefabInstance components reference prefabs via GUID in `m_CorrespondingSourceObject`
//! 3. We scan `.prefab.meta` files to build a GUID → Prefab Path mapping
//! 4. Result: GUID → File Path mapping for automatic prefab instantiation
//!
//! # Example
//!
//! ```no_run
//! use unity_parser::parser::PrefabGuidResolver;
//! use std::path::Path;
//!
//! // Build resolver from Unity project directory
//! let project_path = Path::new("path/to/UnityProject");
//! let resolver = PrefabGuidResolver::from_project(project_path)?;
//!
//! // Resolve a GUID to prefab path
//! let guid = "091c537484687e9419460cdcd7038234";
//! if let Some(path) = resolver.resolve_path(guid) {
//! println!("GUID {} → {}", guid, path.display());
//! }
//! # Ok::<(), unity_parser::Error>(())
//! ```
use crate::parser::meta::MetaFile;
use crate::types::Guid;
use crate::{Error, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
/// Resolves Unity GUIDs to Prefab file paths
///
/// This struct builds a mapping from GUID to prefab file path by scanning
/// a Unity project's `.prefab.meta` files.
///
/// Uses a 128-bit `Guid` type for efficient storage and fast comparisons.
#[derive(Debug, Clone)]
pub struct PrefabGuidResolver {
/// Map from GUID to Prefab file path
guid_to_path: HashMap<Guid, PathBuf>,
}
impl PrefabGuidResolver {
/// Create a new empty PrefabGuidResolver
pub fn new() -> Self {
Self {
guid_to_path: HashMap::new(),
}
}
/// Build a PrefabGuidResolver by scanning a Unity project directory
///
/// This scans for all `.prefab.meta` files and extracts their GUIDs
/// to build a GUID → Prefab Path mapping.
///
/// # Arguments
///
/// * `project_path` - Path to the Unity project root (containing Assets/ folder)
///
/// # Examples
///
/// ```no_run
/// use unity_parser::parser::PrefabGuidResolver;
/// use std::path::Path;
///
/// let resolver = PrefabGuidResolver::from_project(Path::new("MyUnityProject"))?;
/// # Ok::<(), unity_parser::Error>(())
/// ```
pub fn from_project(project_path: impl AsRef<Path>) -> Result<Self> {
let project_path = project_path.as_ref();
// Verify this looks like a Unity project - check for Assets/ or _GameAssets/
let assets_dir = if project_path.join("Assets").exists() {
project_path.join("Assets")
} else if project_path.join("_GameAssets").exists() {
project_path.join("_GameAssets")
} else {
return Err(Error::invalid_format(format!(
"Not a Unity project: missing Assets/ or _GameAssets/ directory at {}",
project_path.display()
)));
};
let mut resolver = Self::new();
// Walk the Assets directory looking for .prefab.meta files
for entry in WalkDir::new(&assets_dir)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
// Only process .prefab.meta files
if !is_prefab_meta_file(path) {
continue;
}
// Parse the .meta file to get the GUID
let meta = match MetaFile::from_path(path) {
Ok(meta) => meta,
Err(e) => {
eprintln!("Warning: Failed to parse {}: {}", path.display(), e);
continue;
}
};
// Get the corresponding .prefab file path
let prefab_path = path.with_file_name(
path.file_name()
.and_then(|s| s.to_str())
.and_then(|s| s.strip_suffix(".meta"))
.unwrap_or(""),
);
// Parse the GUID string to Guid type
let guid = match Guid::from_hex(meta.guid()) {
Ok(g) => g,
Err(e) => {
eprintln!(
"Warning: Invalid GUID in {}: {}",
path.display(),
e
);
continue;
}
};
// Store the mapping
resolver.guid_to_path.insert(guid, prefab_path);
}
Ok(resolver)
}
/// Resolve a GUID to its prefab file path
///
/// Accepts either a `&Guid` or a string that can be parsed as a GUID.
///
/// # Arguments
///
/// * `guid` - The Unity GUID (e.g., "091c537484687e9419460cdcd7038234" or a `Guid`)
///
/// # Returns
///
/// The prefab file path if the GUID is found, otherwise None
///
/// # Examples
///
/// ```no_run
/// # use unity_parser::{PrefabGuidResolver, Guid};
/// # use std::path::Path;
/// # let resolver = PrefabGuidResolver::from_project(Path::new("."))?;
/// // Resolve by string
/// if let Some(path) = resolver.resolve_path("091c537484687e9419460cdcd7038234") {
/// println!("Found prefab: {}", path.display());
/// }
///
/// // Resolve by Guid
/// let guid = Guid::from_hex("091c537484687e9419460cdcd7038234")?;
/// if let Some(path) = resolver.resolve_path(&guid) {
/// println!("Found prefab: {}", path.display());
/// }
/// # Ok::<(), unity_parser::Error>(())
/// ```
pub fn resolve_path<G: AsGuid>(&self, guid: G) -> Option<&Path> {
guid.as_guid()
.and_then(|g| self.guid_to_path.get(&g))
.map(|p| p.as_path())
}
/// Get the number of GUID mappings
pub fn len(&self) -> usize {
self.guid_to_path.len()
}
/// Check if the resolver is empty
pub fn is_empty(&self) -> bool {
self.guid_to_path.is_empty()
}
/// Insert a GUID → prefab path mapping manually
///
/// Useful for testing or adding custom mappings
///
/// # Examples
///
/// ```
/// use unity_parser::{PrefabGuidResolver, Guid};
/// use std::path::PathBuf;
///
/// let mut resolver = PrefabGuidResolver::new();
/// let guid = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap();
/// resolver.insert(guid, PathBuf::from("Assets/Prefabs/Player.prefab"));
/// ```
pub fn insert(&mut self, guid: Guid, path: PathBuf) {
self.guid_to_path.insert(guid, path);
}
/// Get all resolved GUIDs
///
/// Returns an iterator over all GUIDs in the resolver.
pub fn guids(&self) -> impl Iterator<Item = Guid> + '_ {
self.guid_to_path.keys().copied()
}
}
impl Default for PrefabGuidResolver {
fn default() -> Self {
Self::new()
}
}
/// Trait for types that can be converted to a GUID for lookup
///
/// This allows the `resolve_path` method to accept both `&Guid` and `&str`.
pub trait AsGuid {
/// Convert to a GUID, returning None if conversion fails
fn as_guid(&self) -> Option<Guid>;
}
impl AsGuid for Guid {
fn as_guid(&self) -> Option<Guid> {
Some(*self)
}
}
impl AsGuid for &Guid {
fn as_guid(&self) -> Option<Guid> {
Some(**self)
}
}
impl AsGuid for str {
fn as_guid(&self) -> Option<Guid> {
Guid::from_hex(self).ok()
}
}
impl AsGuid for &str {
fn as_guid(&self) -> Option<Guid> {
Guid::from_hex(self).ok()
}
}
impl AsGuid for String {
fn as_guid(&self) -> Option<Guid> {
Guid::from_hex(self).ok()
}
}
/// Check if a path points to a Prefab .meta file
fn is_prefab_meta_file(path: &Path) -> bool {
path.extension()
.and_then(|s| s.to_str())
.map(|ext| ext == "meta")
.unwrap_or(false)
&& path
.file_name()
.and_then(|s| s.to_str())
.map(|name| name.ends_with(".prefab.meta"))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_prefab_meta_file() {
assert!(is_prefab_meta_file(Path::new("Player.prefab.meta")));
assert!(is_prefab_meta_file(Path::new("Assets/Prefabs/Player.prefab.meta")));
assert!(!is_prefab_meta_file(Path::new("Player.prefab")));
assert!(!is_prefab_meta_file(Path::new("PlaySFX.cs.meta")));
assert!(!is_prefab_meta_file(Path::new("Scene.unity.meta")));
assert!(!is_prefab_meta_file(Path::new("texture.png.meta")));
}
#[test]
fn test_prefab_guid_resolver_manual() {
let mut resolver = PrefabGuidResolver::new();
assert!(resolver.is_empty());
let guid = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap();
resolver.insert(guid, PathBuf::from("Assets/Prefabs/Player.prefab"));
assert_eq!(resolver.len(), 1);
// Test resolving by string
assert_eq!(
resolver.resolve_path("091c537484687e9419460cdcd7038234").map(|p| p.to_str().unwrap()),
Some("Assets/Prefabs/Player.prefab")
);
// Test resolving by Guid
assert_eq!(
resolver.resolve_path(&guid).map(|p| p.to_str().unwrap()),
Some("Assets/Prefabs/Player.prefab")
);
// Test nonexistent GUID
assert_eq!(
resolver.resolve_path("00000000000000000000000000000000"),
None
);
}
}

View File

@@ -32,7 +32,7 @@ fn unity_tag_regex() -> &'static Regex {
/// # Example
///
/// ```
/// use cursebreaker_parser::parser::parse_unity_tag;
/// use unity_parser::parser::parse_unity_tag;
///
/// let doc = "--- !u!1 &12345\nGameObject:\n m_Name: Test";
/// let tag = parse_unity_tag(doc).unwrap();

View File

@@ -16,7 +16,7 @@ use crate::{Error, Result};
/// # Example
///
/// ```
/// use cursebreaker_parser::parser::split_yaml_documents;
/// use unity_parser::parser::split_yaml_documents;
///
/// let content = "%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n--- !u!1 &123\nGameObject:\n--- !u!4 &456\nTransform:";
/// let docs = split_yaml_documents(content).unwrap();

View File

@@ -15,7 +15,7 @@ impl UnityProject {
/// # Examples
///
/// ```no_run
/// use cursebreaker_parser::UnityProject;
/// use unity_parser::UnityProject;
///
/// let project = UnityProject::new(100);
/// let game_objects = project.find_all_by_type(1); // GameObject = type 1
@@ -37,7 +37,7 @@ impl UnityProject {
/// # Examples
///
/// ```no_run
/// use cursebreaker_parser::UnityProject;
/// use unity_parser::UnityProject;
///
/// let project = UnityProject::new(100);
/// let transforms = project.find_all_by_class("Transform");
@@ -61,7 +61,7 @@ impl UnityProject {
/// # Examples
///
/// ```no_run
/// use cursebreaker_parser::UnityProject;
/// use unity_parser::UnityProject;
///
/// let project = UnityProject::new(100);
/// let players = project.find_by_name("Player");
@@ -97,7 +97,7 @@ impl UnityProject {
/// # Examples
///
/// ```no_run
/// use cursebreaker_parser::UnityProject;
/// use unity_parser::UnityProject;
///
/// let project = UnityProject::new(100);
/// # let game_object = &project.find_all_by_type(1)[0];
@@ -148,7 +148,7 @@ impl UnityProject {
/// # Examples
///
/// ```no_run
/// use cursebreaker_parser::UnityProject;
/// use unity_parser::UnityProject;
///
/// let project = UnityProject::new(100);
/// # let game_object = &project.find_all_by_type(1)[0];

View File

@@ -2,6 +2,7 @@
use crate::types::*;
use serde_yaml::{Mapping, Value};
use sparsey::world::WorldBuilder;
use sparsey::Entity;
use std::cell::RefCell;
use std::collections::HashMap;
@@ -69,6 +70,8 @@ pub struct ComponentContext<'a> {
pub linking_ctx: Option<&'a RefCell<LinkingContext>>,
/// The raw YAML mapping for this component (for extracting FileRefs)
pub yaml: &'a Mapping,
/// GUID resolver for resolving MonoBehaviour script GUIDs to class names
pub guid_resolver: Option<&'a crate::parser::GuidResolver>,
}
/// Trait for Unity components that can be parsed from YAML
@@ -118,6 +121,8 @@ pub struct ComponentRegistration {
pub class_name: &'static str,
/// Parser function that parses and inserts the component into the ECS world
pub parse_and_insert: fn(&Mapping, &ComponentContext, &mut sparsey::World, Entity) -> bool,
/// Function to register this component type with a WorldBuilder
pub register: for<'a> fn(&'a mut WorldBuilder) -> &'a mut WorldBuilder,
}
// Collect all component registrations submitted via the macro

View File

@@ -0,0 +1,255 @@
//! Unity GUID type
//!
//! Unity uses 128-bit GUIDs to uniquely identify assets across a project.
//! GUIDs are stored as 32 hexadecimal characters (e.g., "091c537484687e9419460cdcd7038234").
//!
//! This module provides a type-safe, efficient representation of Unity GUIDs
//! using a 128-bit integer for fast comparisons and minimal memory footprint.
use crate::{Error, Result};
use std::fmt;
use std::str::FromStr;
/// A Unity GUID represented as a 128-bit integer
///
/// Unity GUIDs are 32 hexadecimal characters representing a 128-bit value.
/// This type stores the GUID as a `u128` for efficient storage and comparison.
///
/// # Example
///
/// ```
/// use unity_parser::Guid;
/// use std::str::FromStr;
///
/// // Parse from string
/// let guid = Guid::from_str("091c537484687e9419460cdcd7038234").unwrap();
///
/// // Convert back to string
/// assert_eq!(guid.to_string(), "091c537484687e9419460cdcd7038234");
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Guid(u128);
impl Guid {
/// Create a new GUID from a 128-bit integer
///
/// # Example
///
/// ```
/// use unity_parser::Guid;
///
/// let guid = Guid::from_u128(0x091c537484687e9419460cdcd7038234);
/// ```
pub const fn from_u128(value: u128) -> Self {
Guid(value)
}
/// Get the underlying 128-bit integer value
///
/// # Example
///
/// ```
/// use unity_parser::Guid;
///
/// let guid = Guid::from_u128(42);
/// assert_eq!(guid.as_u128(), 42);
/// ```
pub const fn as_u128(&self) -> u128 {
self.0
}
/// Create a GUID from a hex string
///
/// The string must be exactly 32 hexadecimal characters.
///
/// # Errors
///
/// Returns an error if the string is not valid hex or not 32 characters.
///
/// # Example
///
/// ```
/// use unity_parser::Guid;
///
/// let guid = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap();
/// ```
pub fn from_hex(s: &str) -> Result<Self> {
// Unity GUIDs are always 32 hex characters (128 bits)
if s.len() != 32 {
return Err(Error::invalid_format(format!(
"GUID must be 32 hex characters, got {}",
s.len()
)));
}
// Parse hex string to u128
let value = u128::from_str_radix(s, 16).map_err(|e| {
Error::invalid_format(format!("Invalid hex in GUID '{}': {}", s, e))
})?;
Ok(Guid(value))
}
/// Convert the GUID to a lowercase hex string
///
/// Returns a 32-character hex string representing the GUID.
///
/// # Example
///
/// ```
/// use unity_parser::Guid;
/// use std::str::FromStr;
///
/// let guid = Guid::from_str("091c537484687e9419460cdcd7038234").unwrap();
/// assert_eq!(guid.to_hex(), "091c537484687e9419460cdcd7038234");
/// ```
pub fn to_hex(&self) -> String {
format!("{:032x}", self.0)
}
/// A zero GUID (all bits set to 0)
pub const ZERO: Guid = Guid(0);
}
impl FromStr for Guid {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Self::from_hex(s)
}
}
impl fmt::Display for Guid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:032x}", self.0)
}
}
impl From<u128> for Guid {
fn from(value: u128) -> Self {
Guid(value)
}
}
impl From<Guid> for u128 {
fn from(guid: Guid) -> Self {
guid.0
}
}
// Implement serde serialization if serde feature is enabled
#[cfg(feature = "serde")]
impl serde::Serialize for Guid {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_hex())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for Guid {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Guid::from_hex(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_guid_from_hex() {
let guid = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap();
assert_eq!(guid.to_hex(), "091c537484687e9419460cdcd7038234");
}
#[test]
fn test_guid_from_str() {
let guid: Guid = "091c537484687e9419460cdcd7038234".parse().unwrap();
assert_eq!(guid.to_string(), "091c537484687e9419460cdcd7038234");
}
#[test]
fn test_guid_invalid_length() {
assert!(Guid::from_hex("123").is_err());
assert!(Guid::from_hex("091c537484687e9419460cdcd70382341234").is_err());
}
#[test]
fn test_guid_invalid_hex() {
assert!(Guid::from_hex("091c537484687e9419460cdcd703823g").is_err());
assert!(Guid::from_hex("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ").is_err());
}
#[test]
fn test_guid_zero() {
let zero = Guid::ZERO;
assert_eq!(zero.as_u128(), 0);
assert_eq!(zero.to_hex(), "00000000000000000000000000000000");
}
#[test]
fn test_guid_from_u128() {
let value: u128 = 0x091c537484687e9419460cdcd7038234;
let guid = Guid::from_u128(value);
assert_eq!(guid.as_u128(), value);
assert_eq!(guid.to_hex(), "091c537484687e9419460cdcd7038234");
}
#[test]
fn test_guid_equality() {
let guid1 = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap();
let guid2 = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap();
let guid3 = Guid::from_hex("091c537484687e9419460cdcd7038235").unwrap();
assert_eq!(guid1, guid2);
assert_ne!(guid1, guid3);
}
#[test]
fn test_guid_hash() {
use std::collections::HashSet;
let mut set = HashSet::new();
let guid1 = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap();
let guid2 = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap();
let guid3 = Guid::from_hex("091c537484687e9419460cdcd7038235").unwrap();
set.insert(guid1);
set.insert(guid2); // Duplicate, shouldn't increase size
set.insert(guid3);
assert_eq!(set.len(), 2);
}
#[test]
fn test_guid_ordering() {
let guid1 = Guid::from_hex("091c537484687e9419460cdcd7038234").unwrap();
let guid2 = Guid::from_hex("091c537484687e9419460cdcd7038235").unwrap();
assert!(guid1 < guid2);
assert!(guid2 > guid1);
}
#[test]
fn test_guid_uppercase_hex() {
// Unity GUIDs are lowercase, but we should handle uppercase too
let guid = Guid::from_hex("091C537484687E9419460CDCD7038234").unwrap();
// Output is always lowercase
assert_eq!(guid.to_hex(), "091c537484687e9419460cdcd7038234");
}
#[test]
fn test_guid_conversion() {
let value: u128 = 12345678901234567890;
let guid: Guid = value.into();
let back: u128 = guid.into();
assert_eq!(value, back);
}
}

View File

@@ -7,7 +7,7 @@ use std::fmt;
/// # Examples
///
/// ```
/// use cursebreaker_parser::FileID;
/// use unity_parser::FileID;
///
/// let file_id = FileID::from_i64(1866116814460599870);
/// assert_eq!(file_id.as_i64(), 1866116814460599870);

View File

@@ -5,26 +5,25 @@
//! wrappers for GameObjects and Components.
mod component;
mod game_object;
mod guid;
mod ids;
mod prefab_instance;
mod reference;
mod transform;
mod type_filter;
mod type_registry;
mod unity_types;
mod values;
pub use component::{
yaml_helpers, ComponentContext, ComponentRegistration, EcsInsertable, LinkCallback,
LinkingContext, UnityComponent,
};
pub use game_object::GameObject;
pub use guid::Guid;
pub use ids::{FileID, LocalID};
pub use prefab_instance::{
PrefabInstance, PrefabInstanceComponent, PrefabModification, PrefabResolver,
};
pub use reference::UnityReference;
pub use transform::{RectTransform, Transform};
pub use type_filter::TypeFilter;
pub use type_registry::{get_class_name, get_type_id};
pub use unity_types::{
GameObject, PrefabInstance, PrefabInstanceComponent, PrefabModification, PrefabResolver,
RectTransform, Transform,
};
pub use values::{Color, ExternalRef, FileRef, Quaternion, Vector2, Vector3};

View File

@@ -38,7 +38,7 @@ impl UnityReference {
/// # Examples
///
/// ```
/// use cursebreaker_parser::types::{UnityReference, FileRef, FileID};
/// use unity_parser::types::{UnityReference, FileRef, FileID};
///
/// // Local reference
/// let file_ref = FileRef::new(FileID::from_i64(12345));
@@ -63,7 +63,7 @@ impl UnityReference {
/// # Examples
///
/// ```
/// use cursebreaker_parser::types::{UnityReference, ExternalRef};
/// use unity_parser::types::{UnityReference, ExternalRef};
///
/// let ext_ref = ExternalRef::new("abc123".to_string(), 2);
/// let reference = UnityReference::from_external_ref(&ext_ref);
@@ -82,7 +82,7 @@ impl UnityReference {
/// # Examples
///
/// ```
/// use cursebreaker_parser::types::UnityReference;
/// use unity_parser::types::UnityReference;
///
/// let reference = UnityReference::Null;
/// assert!(reference.is_null());
@@ -96,7 +96,7 @@ impl UnityReference {
/// # Examples
///
/// ```
/// use cursebreaker_parser::types::{UnityReference, FileID};
/// use unity_parser::types::{UnityReference, FileID};
///
/// let reference = UnityReference::Local(FileID::from_i64(12345));
/// assert!(reference.is_local());
@@ -110,7 +110,7 @@ impl UnityReference {
/// # Examples
///
/// ```
/// use cursebreaker_parser::types::UnityReference;
/// use unity_parser::types::UnityReference;
///
/// let reference = UnityReference::External {
/// guid: "abc123".to_string(),
@@ -130,7 +130,7 @@ impl UnityReference {
/// # Examples
///
/// ```
/// use cursebreaker_parser::types::{UnityReference, FileID};
/// use unity_parser::types::{UnityReference, FileID};
///
/// let reference = UnityReference::Local(FileID::from_i64(12345));
/// assert_eq!(reference.as_file_id(), Some(FileID::from_i64(12345)));
@@ -152,7 +152,7 @@ impl UnityReference {
/// # Examples
///
/// ```
/// use cursebreaker_parser::types::UnityReference;
/// use unity_parser::types::UnityReference;
///
/// let reference = UnityReference::External {
/// guid: "abc123".to_string(),

View File

@@ -0,0 +1,326 @@
//! Type filtering for selective parsing
//!
//! This module provides functionality to selectively parse only specific Unity
//! component types, improving performance and reducing memory usage.
use crate::types::Guid;
use std::collections::HashSet;
/// Filter for controlling which Unity types and MonoBehaviour scripts get parsed
///
/// This filter operates at two levels:
/// 1. **Document-level (YAML parsing)**: Skips parsing YAML for unwanted Unity types and MonoBehaviour GUIDs
/// 2. **ECS-level (component insertion)**: Controls which components get inserted into the ECS world
///
/// By default, all types are parsed. Use `TypeFilter::new()` to create
/// a filter that only parses specific types.
#[derive(Debug, Clone)]
pub struct TypeFilter {
/// Set of Unity type names to parse (e.g., "Transform", "Camera")
/// If None, all types are parsed
unity_types: Option<HashSet<String>>,
/// Set of custom component names to parse (e.g., "PlaySFX")
/// If None, all custom types are parsed
custom_types: Option<HashSet<String>>,
/// Set of MonoBehaviour script GUIDs to parse
/// If None, all MonoBehaviour scripts are parsed
monobehaviour_guids: Option<HashSet<Guid>>,
/// Whether to parse all types (default)
parse_all: bool,
}
impl TypeFilter {
/// Create a new filter that parses ALL types (default behavior)
pub fn parse_all() -> Self {
Self {
unity_types: None,
custom_types: None,
monobehaviour_guids: None,
parse_all: true,
}
}
/// Create a new filter with specific Unity and custom types
///
/// # Example
/// ```
/// use unity_parser::TypeFilter;
///
/// let filter = TypeFilter::new(
/// vec!["Transform", "Camera", "Light"],
/// vec!["PlaySFX", "Interact"]
/// );
/// ```
pub fn new<S1, S2>(unity_types: Vec<S1>, custom_types: Vec<S2>) -> Self
where
S1: Into<String>,
S2: Into<String>,
{
Self {
unity_types: Some(unity_types.into_iter().map(|s| s.into()).collect()),
custom_types: Some(custom_types.into_iter().map(|s| s.into()).collect()),
monobehaviour_guids: None,
parse_all: false,
}
}
/// Create a filter that only parses specific Unity types (no custom types)
pub fn unity_only<S: Into<String>>(types: Vec<S>) -> Self {
Self {
unity_types: Some(types.into_iter().map(|s| s.into()).collect()),
custom_types: Some(HashSet::new()),
monobehaviour_guids: None,
parse_all: false,
}
}
/// Create a filter that only parses specific custom types (no Unity types)
pub fn custom_only<S: Into<String>>(types: Vec<S>) -> Self {
Self {
unity_types: Some(HashSet::new()),
custom_types: Some(types.into_iter().map(|s| s.into()).collect()),
monobehaviour_guids: None,
parse_all: false,
}
}
/// Create a filter with specific Unity types and MonoBehaviour GUIDs
pub fn with_unity_types(types: HashSet<String>) -> Self {
Self {
unity_types: Some(types),
custom_types: None,
monobehaviour_guids: None,
parse_all: false,
}
}
/// Create a filter with specific MonoBehaviour GUIDs
pub fn with_monobehaviour_guids(guids: HashSet<Guid>) -> Self {
Self {
unity_types: None,
custom_types: None,
monobehaviour_guids: Some(guids),
parse_all: false,
}
}
/// Create a filter with both Unity types and MonoBehaviour GUIDs
pub fn with_both(types: HashSet<String>, guids: HashSet<Guid>) -> Self {
Self {
unity_types: Some(types),
custom_types: None,
monobehaviour_guids: Some(guids),
parse_all: false,
}
}
/// Check if a Unity type should be parsed
pub fn should_parse_unity(&self, type_name: &str) -> bool {
if self.parse_all {
return true;
}
match &self.unity_types {
Some(types) => types.contains(type_name),
None => true, // If not specified, parse all
}
}
/// Check if a custom type should be parsed
pub fn should_parse_custom(&self, type_name: &str) -> bool {
if self.parse_all {
return true;
}
match &self.custom_types {
Some(types) => types.contains(type_name),
None => true, // If not specified, parse all
}
}
/// Check if any type should be parsed
pub fn should_parse(&self, type_name: &str, is_custom: bool) -> bool {
if is_custom {
self.should_parse_custom(type_name)
} else {
self.should_parse_unity(type_name)
}
}
/// Check if a Unity class name should be parsed at the document level
///
/// This is used during YAML parsing to skip unwanted documents.
/// Returns true if there's no filter, or if the class is in the whitelist.
pub fn should_parse_type(&self, class_name: &str) -> bool {
if self.parse_all {
return true;
}
match &self.unity_types {
None => true, // No filter = parse all
Some(whitelist) => whitelist.contains(class_name),
}
}
/// Check if a MonoBehaviour GUID should be parsed
///
/// This is used during YAML parsing to skip unwanted MonoBehaviour scripts.
/// Returns true if there's no filter, or if the GUID is in the whitelist.
pub fn should_parse_guid(&self, guid: &Guid) -> bool {
if self.parse_all {
return true;
}
match &self.monobehaviour_guids {
None => true, // No filter = parse all
Some(whitelist) => whitelist.contains(guid),
}
}
/// Check if we're filtering anything at all
pub fn is_filtering(&self) -> bool {
!self.parse_all
&& (self.unity_types.is_some()
|| self.custom_types.is_some()
|| self.monobehaviour_guids.is_some())
}
}
impl Default for TypeFilter {
fn default() -> Self {
Self::parse_all()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_all() {
let filter = TypeFilter::parse_all();
assert!(filter.should_parse_unity("Transform"));
assert!(filter.should_parse_unity("Camera"));
assert!(filter.should_parse_custom("PlaySFX"));
}
#[test]
fn test_specific_types() {
let filter = TypeFilter::new(
vec!["Transform", "Camera"],
vec!["PlaySFX"]
);
assert!(filter.should_parse_unity("Transform"));
assert!(filter.should_parse_unity("Camera"));
assert!(!filter.should_parse_unity("Light"));
assert!(filter.should_parse_custom("PlaySFX"));
assert!(!filter.should_parse_custom("Interact"));
}
#[test]
fn test_unity_only() {
let filter = TypeFilter::unity_only(vec!["Transform"]);
assert!(filter.should_parse_unity("Transform"));
assert!(!filter.should_parse_unity("Camera"));
assert!(!filter.should_parse_custom("PlaySFX"));
}
#[test]
fn test_custom_only() {
let filter = TypeFilter::custom_only(vec!["PlaySFX"]);
assert!(!filter.should_parse_unity("Transform"));
assert!(filter.should_parse_custom("PlaySFX"));
assert!(!filter.should_parse_custom("Interact"));
}
#[test]
fn test_with_unity_types() {
use std::collections::HashSet;
let mut types = HashSet::new();
types.insert("Transform".to_string());
types.insert("GameObject".to_string());
let filter = TypeFilter::with_unity_types(types);
assert!(filter.should_parse_type("Transform"));
assert!(filter.should_parse_type("GameObject"));
assert!(!filter.should_parse_type("RectTransform"));
assert!(filter.is_filtering());
// Should still accept any MonoBehaviour GUID since we didn't set a GUID filter
let guid = Guid::from_hex("d39ddbf1c2c3d1a4baa070e5e76548bd").unwrap();
assert!(filter.should_parse_guid(&guid));
}
#[test]
fn test_with_monobehaviour_guids() {
use std::collections::HashSet;
let mut guids = HashSet::new();
let guid1 = Guid::from_hex("d39ddbf1c2c3d1a4baa070e5e76548bd").unwrap();
let guid2 = Guid::from_hex("abc123def456789012345678901234ab").unwrap();
guids.insert(guid1);
guids.insert(guid2);
let filter = TypeFilter::with_monobehaviour_guids(guids);
assert!(filter.should_parse_guid(&guid1));
assert!(filter.should_parse_guid(&guid2));
let guid3 = Guid::from_hex("00000000000000000000000000000000").unwrap();
assert!(!filter.should_parse_guid(&guid3));
assert!(filter.is_filtering());
// Should still accept any Unity type since we didn't set a type filter
assert!(filter.should_parse_type("Transform"));
assert!(filter.should_parse_type("AnyType"));
}
#[test]
fn test_with_both() {
use std::collections::HashSet;
let mut types = HashSet::new();
types.insert("Transform".to_string());
let mut guids = HashSet::new();
let guid1 = Guid::from_hex("d39ddbf1c2c3d1a4baa070e5e76548bd").unwrap();
guids.insert(guid1);
let filter = TypeFilter::with_both(types, guids);
// Only Transform should pass
assert!(filter.should_parse_type("Transform"));
assert!(!filter.should_parse_type("GameObject"));
// Only guid1 should pass
assert!(filter.should_parse_guid(&guid1));
let guid2 = Guid::from_hex("abc123def456789012345678901234ab").unwrap();
assert!(!filter.should_parse_guid(&guid2));
assert!(filter.is_filtering());
}
#[test]
fn test_is_filtering() {
let filter_all = TypeFilter::parse_all();
assert!(!filter_all.is_filtering());
let filter_unity = TypeFilter::unity_only(vec!["Transform"]);
assert!(filter_unity.is_filtering());
let mut guids = std::collections::HashSet::new();
guids.insert(Guid::from_hex("d39ddbf1c2c3d1a4baa070e5e76548bd").unwrap());
let filter_guids = TypeFilter::with_monobehaviour_guids(guids);
assert!(filter_guids.is_filtering());
}
}

View File

@@ -262,7 +262,7 @@ pub static UNITY_TYPE_REGISTRY: Lazy<UnityTypeRegistry> = Lazy::new(|| {
/// # Examples
///
/// ```
/// use cursebreaker_parser::types::get_class_name;
/// use unity_parser::types::get_class_name;
///
/// assert_eq!(get_class_name(1), Some("GameObject"));
/// assert_eq!(get_class_name(4), Some("Transform"));
@@ -279,7 +279,7 @@ pub fn get_class_name(type_id: u32) -> Option<&'static str> {
/// # Examples
///
/// ```
/// use cursebreaker_parser::types::get_type_id;
/// use unity_parser::types::get_type_id;
///
/// assert_eq!(get_type_id("GameObject"), Some(1));
/// assert_eq!(get_type_id("Transform"), Some(4));

View File

@@ -0,0 +1,11 @@
//! Unity-specific types (GameObjects, Transforms, PrefabInstances)
pub mod game_object;
pub mod prefab_instance;
pub mod transform;
pub use game_object::GameObject;
pub use prefab_instance::{
PrefabInstance, PrefabInstanceComponent, PrefabModification, PrefabResolver,
};
pub use transform::{RectTransform, Transform};

View File

@@ -277,25 +277,35 @@ impl PrefabInstance {
/// # Arguments
/// * `world` - The Sparsey ECS world to spawn entities into
/// * `entity_map` - HashMap to track FileID → Entity mappings
/// * `guid_resolver` - Optional GUID resolver for MonoBehaviour scripts
/// * `prefab_guid_resolver` - Optional prefab GUID resolver for nested prefabs
///
/// # Returns
/// Vec of newly created entities
///
/// # Example
/// ```ignore
/// let entities = instance.spawn_into(&mut scene.world, &mut scene.entity_map)?;
/// let entities = instance.spawn_into(&mut scene.world, &mut scene.entity_map, Some(&guid_resolver), None)?;
/// println!("Spawned {} entities", entities.len());
/// ```
pub fn spawn_into(
mut self,
world: &mut World,
entity_map: &mut HashMap<FileID, Entity>,
guid_resolver: Option<&crate::parser::GuidResolver>,
prefab_guid_resolver: Option<&crate::parser::PrefabGuidResolver>,
) -> Result<Vec<Entity>> {
// Apply overrides before spawning
self.apply_overrides()?;
// Spawn into existing world using the builder
crate::ecs::build_world_from_documents_into(self.documents, world, entity_map)
crate::ecs::build_world_from_documents_into(
self.documents,
world,
entity_map,
guid_resolver,
prefab_guid_resolver,
)
}
/// Get the source prefab path (for debugging)
@@ -322,6 +332,18 @@ pub struct PrefabInstanceComponent {
pub modifications: Vec<PrefabModification>,
}
impl Default for PrefabInstanceComponent {
fn default() -> Self {
Self {
prefab_ref: ExternalRef {
guid: String::new(),
type_id: 0,
},
modifications: Vec::new(),
}
}
}
impl UnityComponent for PrefabInstanceComponent {
fn parse(yaml: &Mapping, _ctx: &ComponentContext) -> Option<Self> {
// Extract m_SourcePrefab (external GUID reference)
@@ -412,7 +434,7 @@ fn parse_single_modification(yaml: &Mapping) -> Option<PrefabModification> {
/// - Caching loaded prefabs
/// - Detecting circular prefab references
/// - Recursively instantiating nested prefabs
pub struct PrefabResolver {
pub struct PrefabResolver<'a> {
/// Cache of loaded prefabs (GUID → Prefab)
prefab_cache: HashMap<String, Arc<UnityPrefab>>,
@@ -421,9 +443,15 @@ pub struct PrefabResolver {
/// Stack of GUIDs currently being instantiated (for cycle detection)
instantiation_stack: Vec<String>,
/// GUID resolver for MonoBehaviour scripts
guid_resolver: Option<&'a crate::parser::GuidResolver>,
/// Prefab GUID resolver for nested prefabs
prefab_guid_resolver: Option<&'a crate::parser::PrefabGuidResolver>,
}
impl PrefabResolver {
impl<'a> PrefabResolver<'a> {
/// Create a new PrefabResolver
///
/// # Arguments
@@ -433,9 +461,116 @@ impl PrefabResolver {
prefab_cache: HashMap::new(),
guid_to_path,
instantiation_stack: Vec::new(),
guid_resolver: None,
prefab_guid_resolver: None,
}
}
/// Create a PrefabResolver from resolvers
///
/// # Arguments
/// * `guid_resolver` - Script GUID resolver for MonoBehaviour components
/// * `prefab_guid_resolver` - Prefab GUID resolver for prefab file paths
pub fn from_resolvers(
guid_resolver: Option<&'a crate::parser::GuidResolver>,
prefab_guid_resolver: &'a crate::parser::PrefabGuidResolver,
) -> Self {
// Convert Guid → PathBuf mapping to String → PathBuf mapping
let guid_to_path = prefab_guid_resolver
.guids()
.filter_map(|guid| {
let path = prefab_guid_resolver.resolve_path(&guid)?;
Some((guid.to_hex(), path.to_path_buf()))
})
.collect();
Self {
prefab_cache: HashMap::new(),
guid_to_path,
instantiation_stack: Vec::new(),
guid_resolver,
prefab_guid_resolver: Some(prefab_guid_resolver),
}
}
/// Instantiate a prefab from a PrefabInstanceComponent
///
/// This is the main entry point for automatic prefab instantiation during
/// scene parsing. It:
/// 1. Loads the prefab by GUID
/// 2. Creates a PrefabInstance
/// 3. Applies modifications from the component
/// 4. Recursively instantiates nested prefabs
/// 5. Links the prefab root to the parent entity
///
/// # Arguments
/// * `component` - The PrefabInstanceComponent from the scene
/// * `parent_entity` - The GameObject entity that contains this PrefabInstance component
/// * `world` - The ECS world to spawn entities into
/// * `entity_map` - Entity mapping to update
///
/// # Returns
/// Vec of spawned entities (including the root and all children)
pub fn instantiate_from_component(
&mut self,
component: &PrefabInstanceComponent,
parent_entity: Option<Entity>,
world: &mut World,
entity_map: &mut HashMap<FileID, Entity>,
) -> Result<Vec<Entity>> {
// 1. Extract GUID from component.prefab_ref
let guid = &component.prefab_ref.guid;
// 2. Load prefab via load_prefab()
let prefab = self.load_prefab(guid)?;
// 3. Create PrefabInstance
let mut instance = prefab.instantiate();
// 4. Apply component.modifications using override_value()
for modification in &component.modifications {
instance.override_value(
modification.target_file_id,
&modification.property_path,
modification.value.clone(),
)?;
}
// 5. Spawn the instance
// Note: We pass None for prefab_guid_resolver to prevent infinite recursion
// Nested prefabs should be handled explicitly if needed
let spawned = instance.spawn_into(world, entity_map, self.guid_resolver, None)?;
// 6. Link spawned root to parent_entity (if provided)
if let Some(parent) = parent_entity {
if let Some(&root_entity) = spawned.first() {
// Find the Transform component on the root and link to parent
// Note: This is a simplified version. Full implementation would:
// - Find the actual root GameObject Transform
// - Set m_Father to parent entity
// - Update parent's m_Children array
//
// For now, we'll let the deferred linking system handle this
// via the normal Transform hierarchy resolution in Pass 3
// Try to get Transform components for both parent and child
let mut transforms = world.borrow_mut::<crate::types::Transform>();
if let Some(_child_transform) = transforms.get_mut(root_entity) {
// Store the parent FileID for deferred linking
// This will be picked up by Pass 3's hierarchy resolution
if let Some(_parent_transform) = transforms.get(parent) {
// The parent linking will be handled by the deferred linking system
// We just need to ensure the entities are in the world
drop(transforms); // Release the borrow
}
}
}
}
// 7. Return spawned entities
Ok(spawned)
}
/// Recursively instantiate a prefab and its nested prefabs
///
/// This handles:
@@ -498,7 +633,8 @@ impl PrefabResolver {
}
// Spawn this prefab's entities
let spawned = instance.spawn_into(world, entity_map)?;
// Note: Pass None for prefab_guid_resolver since we handle nesting manually
let spawned = instance.spawn_into(world, entity_map, self.guid_resolver, None)?;
// Pop from stack
self.instantiation_stack.pop();
@@ -524,6 +660,7 @@ impl PrefabResolver {
entity: None,
linking_ctx: None,
yaml: mapping,
guid_resolver: None,
};
if let Some(component) = PrefabInstanceComponent::parse(mapping, &ctx) {

View File

@@ -139,7 +139,7 @@ impl crate::types::EcsInsertable for Transform {
/// # Examples
///
/// ```no_run
/// use cursebreaker_parser::{UnityFile, types::RectTransform};
/// use unity_parser::{UnityFile, types::RectTransform};
///
/// let file = UnityFile::from_path("Canvas.prefab")?;
/// match file {
@@ -152,7 +152,7 @@ impl crate::types::EcsInsertable for Transform {
/// }
/// _ => {}
/// }
/// # Ok::<(), cursebreaker_parser::Error>(())
/// # Ok::<(), unity_parser::Error>(())
/// ```
#[derive(Debug, Clone)]
pub struct RectTransform {

View File

@@ -10,7 +10,7 @@ pub use glam::{Quat as Quaternion, Vec2 as Vector2, Vec3 as Vector3, Vec4 as Col
/// # Examples
///
/// ```
/// use cursebreaker_parser::{FileRef, FileID};
/// use unity_parser::{FileRef, FileID};
///
/// let file_ref = FileRef::new(FileID::from_i64(12345));
/// assert_eq!(file_ref.file_id.as_i64(), 12345);
@@ -32,7 +32,7 @@ impl FileRef {
/// # Examples
///
/// ```
/// use cursebreaker_parser::ExternalRef;
/// use unity_parser::ExternalRef;
///
/// let ext_ref = ExternalRef::new("abc123".to_string(), 2);
/// assert_eq!(ext_ref.guid, "abc123");

View File

@@ -1,6 +1,6 @@
//! Integration tests for parsing real Unity projects
use cursebreaker_parser::UnityFile;
use unity_parser::{GuidResolver, UnityFile};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
@@ -400,3 +400,155 @@ fn benchmark_parsing() {
stats.print_summary();
}
/// Test GUID resolution for MonoBehaviour scripts
#[test]
fn test_guid_resolution() {
let project_path = match clone_test_project(&TestProject::VR_HORROR) {
Ok(path) => path,
Err(e) => {
eprintln!("Failed to clone project: {}", e);
eprintln!("Skipping GUID resolution test (git may not be available)");
return;
}
};
println!("\n{}", "=".repeat(60));
println!("Testing GUID Resolution");
println!("{}", "=".repeat(60));
// Build GUID resolver from project
println!("\nBuilding GuidResolver from project...");
let resolver = GuidResolver::from_project(&project_path)
.expect("Should successfully build GuidResolver");
println!(" ✓ Found {} GUID mappings", resolver.len());
assert!(
!resolver.is_empty(),
"Should find at least some C# scripts with GUIDs"
);
// Test the known PlaySFX GUID from the roadmap
let playsfx_guid_str = "091c537484687e9419460cdcd7038234";
println!("\nTesting known GUID resolution:");
println!(" GUID: {}", playsfx_guid_str);
// Test resolution by string
match resolver.resolve_class_name(playsfx_guid_str) {
Some(class_name) => {
println!(" ✓ Resolved by string to: {}", class_name);
assert_eq!(
class_name, "PlaySFX",
"PlaySFX GUID should resolve to 'PlaySFX' class name"
);
}
None => {
panic!("Failed to resolve PlaySFX GUID. Available GUIDs: {:?}",
resolver.guids().take(5).collect::<Vec<_>>());
}
}
// Test resolution by Guid type
use unity_parser::Guid;
let playsfx_guid = Guid::from_hex(playsfx_guid_str).unwrap();
match resolver.resolve_class_name(&playsfx_guid) {
Some(class_name) => {
println!(" ✓ Resolved by Guid type to: {}", class_name);
assert_eq!(class_name, "PlaySFX");
}
None => panic!("Failed to resolve PlaySFX GUID by Guid type"),
}
// Show sample of resolved GUIDs
println!("\nSample of resolved GUIDs:");
for guid in resolver.guids().take(5) {
if let Some(class_name) = resolver.resolve_class_name(&guid) {
println!(" {}{}", guid, class_name);
}
}
// Demonstrate memory efficiency
println!("\nMemory efficiency:");
println!(" Each Guid: {} bytes (u128)", std::mem::size_of::<Guid>());
println!(" Each String GUID: ~{} bytes (heap allocated)", 32 + std::mem::size_of::<String>());
println!("\n{}", "=".repeat(60));
}
/// Test parsing PlaySFX components from actual scene file
#[test]
fn test_playsfx_parsing() {
use unity_parser::UnityComponent;
/// PlaySFX component from VR_Horror_YouCantRun
#[derive(Debug, Clone, UnityComponent)]
#[unity_class("PlaySFX")]
pub struct PlaySFX {
#[unity_field("volume")]
pub volume: f64,
#[unity_field("startTime")]
pub start_time: f64,
#[unity_field("endTime")]
pub end_time: f64,
#[unity_field("isLoop")]
pub is_loop: bool,
}
let project_path = match clone_test_project(&TestProject::VR_HORROR) {
Ok(path) => path,
Err(e) => {
eprintln!("Failed to clone project: {}", e);
eprintln!("Skipping PlaySFX parsing test (git may not be available)");
return;
}
};
println!("\n{}", "=".repeat(60));
println!("Testing PlaySFX Component Parsing");
println!("{}", "=".repeat(60));
// Parse the 1F.unity scene that contains PlaySFX components
let scene_path = project_path.join("Assets/Scenes/TEST/Final_1F/1F.unity");
if !scene_path.exists() {
eprintln!("Scene file not found: {}", scene_path.display());
return;
}
println!("\n Parsing scene: {}", scene_path.display());
match unity_parser::UnityFile::from_path(&scene_path) {
Ok(unity_parser::UnityFile::Scene(scene)) => {
println!(" ✓ Scene parsed successfully");
println!(" - Total entities: {}", scene.entity_map.len());
// Try to get PlaySFX components
let playsfx_view = scene.world.borrow::<PlaySFX>();
let mut found_count = 0;
for entity in scene.entity_map.values() {
if playsfx_view.get(*entity).is_some() {
found_count += 1;
}
}
println!(" ✓ Found {} PlaySFX component(s)", found_count);
assert!(
found_count > 0,
"Should find at least one PlaySFX component in 1F.unity"
);
}
Ok(_) => {
panic!("File was not parsed as a scene");
}
Err(e) => {
panic!("Failed to parse scene: {}", e);
}
}
println!("\n{}", "=".repeat(60));
}

View File

@@ -1,6 +1,6 @@
//! Tests for the #[derive(UnityComponent)] macro
use cursebreaker_parser::{ComponentContext, FileID, UnityComponent};
use unity_parser::{ComponentContext, FileID, UnityComponent};
/// Test component matching the PlaySFX script from VR_Horror_YouCantRun
#[derive(Debug, Clone, UnityComponent)]
@@ -133,7 +133,7 @@ fn test_component_registration() {
let mut found_play_sfx = false;
let mut found_test_component = false;
for reg in inventory::iter::<cursebreaker_parser::ComponentRegistration> {
for reg in inventory::iter::<unity_parser::ComponentRegistration> {
if reg.class_name == "PlaySFX" {
found_play_sfx = true;
assert_eq!(reg.type_id, 114);
@@ -177,7 +177,7 @@ isLoop: 0
};
// Find the PlaySFX registration and call its parser
for reg in inventory::iter::<cursebreaker_parser::ComponentRegistration> {
for reg in inventory::iter::<unity_parser::ComponentRegistration> {
if reg.class_name == "PlaySFX" {
let result = (reg.parser)(mapping, &ctx);
assert!(result.is_some(), "Registered parser failed to parse");