project restructure
This commit is contained in:
60
unity-parser/Cargo.toml
Normal file
60
unity-parser/Cargo.toml
Normal file
@@ -0,0 +1,60 @@
|
||||
[package]
|
||||
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/unity-parser-rust"
|
||||
keywords = ["unity", "parser", "yaml", "gamedev"]
|
||||
categories = ["parser-implementations", "game-development"]
|
||||
rust-version = "1.70"
|
||||
|
||||
[lib]
|
||||
name = "unity_parser"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# YAML parsing
|
||||
serde_yaml = "0.9"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
|
||||
# Ordered maps for properties
|
||||
indexmap = { version = "2.1", features = ["serde"] }
|
||||
|
||||
# Regex for parsing
|
||||
regex = "1.10"
|
||||
|
||||
# Math types (Vector2, Vector3, Quaternion, etc.)
|
||||
glam = { version = "0.29", features = ["serde"] }
|
||||
|
||||
# ECS (Entity Component System)
|
||||
sparsey = "0.13"
|
||||
|
||||
# LRU cache for reference resolution
|
||||
lru = "0.12"
|
||||
|
||||
# Directory traversal for loading projects
|
||||
walkdir = "2.4"
|
||||
|
||||
# Lazy static initialization for type registry
|
||||
once_cell = "1.19"
|
||||
|
||||
# Component registry for custom MonoBehaviours
|
||||
inventory = "0.3"
|
||||
|
||||
# Procedural macro for derive(UnityComponent)
|
||||
unity-parser-macros = { path = "../unity-parser-macros" }
|
||||
|
||||
[dev-dependencies]
|
||||
# Testing utilities
|
||||
pretty_assertions = "1.4"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
# Future: parallel processing support
|
||||
parallel = []
|
||||
79
unity-parser/examples/basic_parsing.rs
Normal file
79
unity-parser/examples/basic_parsing.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use unity_parser::UnityFile;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
// Parse a Unity prefab file
|
||||
let prefab_path = Path::new("data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab");
|
||||
|
||||
if !prefab_path.exists() {
|
||||
eprintln!("Error: Unity sample project not found.");
|
||||
eprintln!("Please ensure the git submodule is initialized:");
|
||||
eprintln!(" git submodule update --init --recursive");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the file
|
||||
match UnityFile::from_path(prefab_path) {
|
||||
Ok(file) => {
|
||||
println!("Successfully parsed: {:?}", file.path().file_name().unwrap());
|
||||
|
||||
// Handle the different file types
|
||||
match file {
|
||||
UnityFile::Prefab(prefab) => {
|
||||
println!("Found {} documents\n", prefab.documents.len());
|
||||
|
||||
// List all documents
|
||||
for (i, doc) in prefab.documents.iter().enumerate() {
|
||||
println!("Document {}: {} (Type ID: {}, File ID: {})",
|
||||
i + 1,
|
||||
doc.class_name,
|
||||
doc.type_id,
|
||||
doc.file_id
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
// Find all GameObjects
|
||||
let game_objects = prefab.get_documents_by_class("GameObject");
|
||||
println!("Found {} GameObjects:", game_objects.len());
|
||||
for go in game_objects {
|
||||
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()) {
|
||||
println!(" - {}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
// Find all Transforms
|
||||
let transforms = prefab.get_documents_by_type(224); // RectTransform type ID
|
||||
println!("Found {} RectTransforms", transforms.len());
|
||||
|
||||
// Look up a specific document by file ID
|
||||
if let Some(first_doc) = prefab.documents.first() {
|
||||
let file_id = first_doc.file_id;
|
||||
if let Some(found) = prefab.get_document(file_id) {
|
||||
println!("\nLooking up document by file ID {}:", file_id);
|
||||
println!(" Class: {}", found.class_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
UnityFile::Scene(scene) => {
|
||||
println!("This is a scene file with {} entities", scene.entity_map.len());
|
||||
}
|
||||
UnityFile::Asset(asset) => {
|
||||
println!("This is an asset file with {} documents", asset.documents.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error parsing file: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
unity-parser/examples/custom_component.rs
Normal file
101
unity-parser/examples/custom_component.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
//! Example demonstrating how to define custom Unity MonoBehaviour components
|
||||
//! using the #[derive(UnityComponent)] macro.
|
||||
|
||||
use unity_parser::{yaml_helpers, ComponentContext, UnityComponent};
|
||||
|
||||
/// Custom Unity MonoBehaviour component for playing sound effects
|
||||
///
|
||||
/// This mirrors the C# PlaySFX MonoBehaviour:
|
||||
/// ```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,
|
||||
}
|
||||
|
||||
/// Another example - a custom damage component
|
||||
#[derive(Debug, Clone, UnityComponent)]
|
||||
#[unity_class("DamageDealer")]
|
||||
pub struct DamageDealer {
|
||||
#[unity_field("damageAmount")]
|
||||
pub damage_amount: f64,
|
||||
|
||||
#[unity_field("damageType")]
|
||||
pub damage_type: String,
|
||||
|
||||
#[unity_field("canCrit")]
|
||||
pub can_crit: bool,
|
||||
|
||||
#[unity_field("critMultiplier")]
|
||||
pub crit_multiplier: f64,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("Custom Unity Component Example");
|
||||
println!("===============================\n");
|
||||
|
||||
println!("Defined custom components:");
|
||||
println!(" - PlaySFX: volume, start_time, end_time, is_loop");
|
||||
println!(" - DamageDealer: damage_amount, damage_type, can_crit, crit_multiplier\n");
|
||||
|
||||
println!("These components are automatically registered via the inventory crate.");
|
||||
println!("When parsing Unity files, they will be recognized and parsed automatically.\n");
|
||||
|
||||
// Demonstrate parsing from YAML
|
||||
let yaml_str = r#"
|
||||
volume: 0.75
|
||||
startTime: 1.5
|
||||
endTime: 3.0
|
||||
isLoop: 1
|
||||
"#;
|
||||
|
||||
let yaml: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
|
||||
let mapping = yaml.as_mapping().unwrap();
|
||||
|
||||
// Create a dummy context
|
||||
use unity_parser::{ComponentContext, FileID};
|
||||
let ctx = ComponentContext {
|
||||
type_id: 114,
|
||||
file_id: FileID::from_i64(12345),
|
||||
class_name: "PlaySFX",
|
||||
entity: None,
|
||||
linking_ctx: None,
|
||||
yaml: mapping,
|
||||
};
|
||||
|
||||
// Parse the component
|
||||
if let Some(play_sfx) = PlaySFX::parse(mapping, &ctx) {
|
||||
println!("Successfully parsed PlaySFX component:");
|
||||
println!(" volume: {}", play_sfx.volume);
|
||||
println!(" start_time: {}", play_sfx.start_time);
|
||||
println!(" end_time: {}", play_sfx.end_time);
|
||||
println!(" is_loop: {}", play_sfx.is_loop);
|
||||
} else {
|
||||
println!("Failed to parse PlaySFX component");
|
||||
}
|
||||
|
||||
println!("\nTo use in your own code:");
|
||||
println!(" 1. Define a struct matching your C# MonoBehaviour fields");
|
||||
println!(" 2. Add #[derive(UnityComponent)] to the struct");
|
||||
println!(" 3. Add #[unity_class(\"YourClassName\")] to specify the Unity class name");
|
||||
println!(" 4. Add #[unity_field(\"fieldName\")] to each field");
|
||||
println!(" 5. The component will be automatically registered and parsed!");
|
||||
}
|
||||
146
unity-parser/examples/ecs_integration.rs
Normal file
146
unity-parser/examples/ecs_integration.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
//! Example demonstrating ECS integration and selective type parsing
|
||||
//!
|
||||
//! This example shows:
|
||||
//! 1. Custom components being automatically inserted into the ECS world
|
||||
//! 2. Using the parse_with_types! macro for selective parsing
|
||||
//! 3. Querying the ECS world for components
|
||||
|
||||
use unity_parser::{parse_with_types, ComponentContext, EcsInsertable, FileID, TypeFilter, UnityComponent};
|
||||
|
||||
/// Custom Unity MonoBehaviour component
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// Another custom component
|
||||
#[derive(Debug, Clone, UnityComponent)]
|
||||
#[unity_class("Interactable")]
|
||||
pub struct Interactable {
|
||||
#[unity_field("interactionRadius")]
|
||||
pub interaction_radius: f32,
|
||||
|
||||
#[unity_field("interactionText")]
|
||||
pub interaction_text: String,
|
||||
|
||||
#[unity_field("canInteract")]
|
||||
pub can_interact: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("ECS Integration & Selective Parsing Example");
|
||||
println!("{}", "=".repeat(60));
|
||||
|
||||
// Example 1: Using parse_with_types! macro
|
||||
println!("\n1. Creating type filters:");
|
||||
println!("{}", "-".repeat(60));
|
||||
|
||||
let _filter_all = TypeFilter::parse_all();
|
||||
println!("✓ Filter that parses ALL types");
|
||||
|
||||
let filter_selective = parse_with_types! {
|
||||
unity_types(Transform, Camera),
|
||||
custom_types(PlaySFX)
|
||||
};
|
||||
println!("✓ Filter for Transform, Camera, and PlaySFX only");
|
||||
|
||||
let filter_custom_only = parse_with_types! {
|
||||
custom_types(PlaySFX, Interactable)
|
||||
};
|
||||
println!("✓ Filter for PlaySFX and Interactable only (no Unity types)");
|
||||
|
||||
// Example 2: Demonstrating ECS insertion
|
||||
println!("\n2. ECS Integration:");
|
||||
println!("{}", "-".repeat(60));
|
||||
|
||||
// Simulate parsing a PlaySFX component
|
||||
let yaml_str = r#"
|
||||
volume: 0.8
|
||||
startTime: 0.0
|
||||
endTime: 5.0
|
||||
isLoop: 0
|
||||
"#;
|
||||
|
||||
let yaml: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
|
||||
let mapping = yaml.as_mapping().unwrap();
|
||||
|
||||
let ctx = ComponentContext {
|
||||
type_id: 114,
|
||||
file_id: FileID::from_i64(12345),
|
||||
class_name: "PlaySFX",
|
||||
entity: None,
|
||||
linking_ctx: None,
|
||||
yaml: mapping,
|
||||
};
|
||||
|
||||
// Parse the component
|
||||
if let Some(play_sfx) = PlaySFX::parse(mapping, &ctx) {
|
||||
println!("✓ Parsed PlaySFX component:");
|
||||
println!(" - volume: {}", play_sfx.volume);
|
||||
println!(" - start_time: {}", play_sfx.start_time);
|
||||
println!(" - end_time: {}", play_sfx.end_time);
|
||||
println!(" - is_loop: {}", play_sfx.is_loop);
|
||||
|
||||
// Create a minimal ECS world to demonstrate insertion
|
||||
use sparsey::World;
|
||||
let mut world = World::builder().register::<PlaySFX>().build();
|
||||
let entity = world.create(());
|
||||
|
||||
println!("\n✓ Created ECS entity: {:?}", entity);
|
||||
|
||||
// Insert the component into the world
|
||||
play_sfx.clone().insert_into_world(&mut world, entity);
|
||||
println!("✓ Inserted PlaySFX component into ECS world");
|
||||
|
||||
// Query it back
|
||||
{
|
||||
let view = world.borrow::<PlaySFX>();
|
||||
if let Some(component) = view.get(entity) {
|
||||
println!("✓ Successfully queried component from ECS:");
|
||||
println!(" - volume: {}", component.volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example 3: Type filter usage
|
||||
println!("\n3. Type Filter Behavior:");
|
||||
println!("{}", "-".repeat(60));
|
||||
|
||||
println!("Filter checks:");
|
||||
println!(" Transform in selective filter: {}", filter_selective.should_parse_unity("Transform"));
|
||||
println!(" Camera in selective filter: {}", filter_selective.should_parse_unity("Camera"));
|
||||
println!(" Light in selective filter: {}", filter_selective.should_parse_unity("Light"));
|
||||
println!(" PlaySFX in selective filter: {}", filter_selective.should_parse_custom("PlaySFX"));
|
||||
println!(" Interactable in selective filter: {}", filter_selective.should_parse_custom("Interactable"));
|
||||
|
||||
println!("\n PlaySFX in custom-only filter: {}", filter_custom_only.should_parse_custom("PlaySFX"));
|
||||
println!(" Transform in custom-only filter: {}", filter_custom_only.should_parse_unity("Transform"));
|
||||
|
||||
// Example 4: Benefits of selective parsing
|
||||
println!("\n4. Benefits of Selective Parsing:");
|
||||
println!("{}", "-".repeat(60));
|
||||
println!("When parsing a large Unity project:");
|
||||
println!(" • Parse ALL types: Parse everything (default)");
|
||||
println!(" • Parse specific types: Faster parsing & less memory");
|
||||
println!(" • Parse only what you need for your tool/analysis");
|
||||
println!("\nExample use cases:");
|
||||
println!(" • Animation tool: Only parse Animator, AnimationClip");
|
||||
println!(" • Audio tool: Only parse AudioSource, PlaySFX");
|
||||
println!(" • Transform analyzer: Only parse Transform, RectTransform");
|
||||
|
||||
println!();
|
||||
println!("{}", "=".repeat(60));
|
||||
println!("Complete! Custom components now work with ECS!");
|
||||
println!("{}", "=".repeat(60));
|
||||
}
|
||||
194
unity-parser/examples/find_playsfx.rs
Normal file
194
unity-parser/examples/find_playsfx.rs
Normal 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
|
||||
}
|
||||
121
unity-parser/examples/guid_resolution.rs.disabled
Normal file
121
unity-parser/examples/guid_resolution.rs.disabled
Normal file
@@ -0,0 +1,121 @@
|
||||
//! 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(())
|
||||
}
|
||||
218
unity-parser/examples/parse_resource_prefabs.rs
Normal file
218
unity-parser/examples/parse_resource_prefabs.rs
Normal 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(())
|
||||
}
|
||||
147
unity-parser/examples/parse_resources.rs
Normal file
147
unity-parser/examples/parse_resources.rs
Normal 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(())
|
||||
}
|
||||
19
unity-parser/resources_output.txt
Normal file
19
unity-parser/resources_output.txt
Normal 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
|
||||
434
unity-parser/src/ecs/builder.rs
Normal file
434
unity-parser/src/ecs/builder.rs
Normal 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(())
|
||||
}
|
||||
12
unity-parser/src/ecs/mod.rs
Normal file
12
unity-parser/src/ecs/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! ECS world construction from Unity files
|
||||
//!
|
||||
//! This module provides functionality to build a Sparsey ECS World from
|
||||
//! parsed Unity documents, creating entities for GameObjects and attaching
|
||||
//! components with resolved hierarchy.
|
||||
|
||||
mod builder;
|
||||
|
||||
pub use builder::{build_world_from_documents, build_world_from_documents_into};
|
||||
|
||||
// TODO: Add project-level world building once UnityProject is updated to work with new architecture
|
||||
// pub use builder::build_world_from_project;
|
||||
110
unity-parser/src/error.rs
Normal file
110
unity-parser/src/error.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Result type alias for parser operations
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Errors that can occur during Unity file parsing
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
/// IO error when reading files
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// YAML parsing error
|
||||
#[error("YAML parsing error: {0}")]
|
||||
Yaml(#[from] serde_yaml::Error),
|
||||
|
||||
/// Invalid Unity file format
|
||||
#[error("Invalid Unity file format: {0}")]
|
||||
InvalidFormat(String),
|
||||
|
||||
/// Missing required Unity header
|
||||
#[error("Missing required Unity YAML header in file: {}", .0.display())]
|
||||
MissingHeader(PathBuf),
|
||||
|
||||
/// Invalid Unity type tag
|
||||
#[error("Invalid Unity type tag: {0}")]
|
||||
InvalidTypeTag(String),
|
||||
|
||||
/// Invalid anchor ID
|
||||
#[error("Invalid anchor ID: {0}")]
|
||||
InvalidAnchor(String),
|
||||
|
||||
/// Missing document in file
|
||||
#[error("No documents found in Unity file")]
|
||||
EmptyFile,
|
||||
|
||||
/// Reference resolution error
|
||||
#[error("Failed to resolve reference: {0}")]
|
||||
ReferenceError(String),
|
||||
|
||||
/// Property not found
|
||||
#[error("Property not found: {0}")]
|
||||
PropertyNotFound(String),
|
||||
|
||||
/// Type conversion error
|
||||
#[error("Type conversion error: expected {expected}, found {found}")]
|
||||
TypeMismatch { expected: String, found: String },
|
||||
|
||||
/// Property value conversion error
|
||||
#[error("Failed to convert property value from {from} to {to}")]
|
||||
PropertyConversion { from: String, to: String },
|
||||
|
||||
/// Invalid property path
|
||||
#[error("Invalid property path: {0}")]
|
||||
InvalidPropertyPath(String),
|
||||
|
||||
/// Failed to resolve GUID reference
|
||||
#[error("Failed to resolve GUID reference: {0}")]
|
||||
GuidResolutionError(String),
|
||||
|
||||
/// Unknown Unity type ID
|
||||
#[error("Unknown Unity type ID: {0} (this type is not in the registry)")]
|
||||
UnknownTypeId(u32),
|
||||
|
||||
/// Circular reference detected
|
||||
#[error("Circular reference detected in reference chain")]
|
||||
CircularReference,
|
||||
|
||||
/// ECS world construction error
|
||||
#[error("Failed to build ECS world: {0}")]
|
||||
WorldBuildError(String),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Create an invalid format error
|
||||
pub fn invalid_format(msg: impl Into<String>) -> Self {
|
||||
Error::InvalidFormat(msg.into())
|
||||
}
|
||||
|
||||
/// Create a reference error
|
||||
pub fn reference_error(msg: impl Into<String>) -> Self {
|
||||
Error::ReferenceError(msg.into())
|
||||
}
|
||||
|
||||
/// Create a property not found error
|
||||
pub fn property_not_found(msg: impl Into<String>) -> Self {
|
||||
Error::PropertyNotFound(msg.into())
|
||||
}
|
||||
|
||||
/// Create a GUID resolution error
|
||||
pub fn guid_resolution_error(msg: impl Into<String>) -> Self {
|
||||
Error::GuidResolutionError(msg.into())
|
||||
}
|
||||
|
||||
/// Create an unknown type ID error
|
||||
pub fn unknown_type_id(type_id: u32) -> Self {
|
||||
Error::UnknownTypeId(type_id)
|
||||
}
|
||||
|
||||
/// Create a circular reference error
|
||||
pub fn circular_reference() -> Self {
|
||||
Error::CircularReference
|
||||
}
|
||||
|
||||
/// Create a world build error
|
||||
pub fn world_build_error(msg: impl Into<String>) -> Self {
|
||||
Error::WorldBuildError(msg.into())
|
||||
}
|
||||
}
|
||||
56
unity-parser/src/lib.rs
Normal file
56
unity-parser/src/lib.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! Cursebreaker Unity Parser
|
||||
//!
|
||||
//! A high-performance Rust library for parsing Unity project files (.unity scenes,
|
||||
//! .prefab prefabs, and .asset ScriptableObjects).
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use unity_parser::UnityFile;
|
||||
//!
|
||||
//! let file = UnityFile::from_path("Scene.unity")?;
|
||||
//! match file {
|
||||
//! UnityFile::Scene(scene) => {
|
||||
//! println!("Scene with {} entities", scene.entity_map.len());
|
||||
//! // Access scene.world for ECS queries
|
||||
//! }
|
||||
//! UnityFile::Prefab(prefab) => {
|
||||
//! println!("Prefab with {} documents", prefab.documents.len());
|
||||
//! }
|
||||
//! UnityFile::Asset(asset) => {
|
||||
//! println!("Asset with {} documents", asset.documents.len());
|
||||
//! }
|
||||
//! }
|
||||
//! # Ok::<(), unity_parser::Error>(())
|
||||
//! ```
|
||||
|
||||
// Public modules
|
||||
pub mod ecs;
|
||||
pub mod error;
|
||||
pub mod macros;
|
||||
pub mod model;
|
||||
pub mod parser;
|
||||
// TODO: Update project module to work with new UnityFile enum architecture
|
||||
// pub mod project;
|
||||
pub mod property;
|
||||
pub mod types;
|
||||
|
||||
// Re-exports
|
||||
pub use error::{Error, Result};
|
||||
pub use model::{RawDocument, UnityAsset, UnityFile, UnityPrefab, UnityScene};
|
||||
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, 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 unity_parser_macros::UnityComponent;
|
||||
111
unity-parser/src/macros.rs
Normal file
111
unity-parser/src/macros.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! Declarative macros for convenient API usage
|
||||
|
||||
/// Create a TypeFilter with specific Unity and custom types
|
||||
///
|
||||
/// # Syntax
|
||||
/// ```ignore
|
||||
/// parse_with_types! {
|
||||
/// unity_types(Transform, Camera, Light),
|
||||
/// custom_types(PlaySFX, Interact)
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// You can omit either section:
|
||||
/// ```ignore
|
||||
/// // Only Unity types
|
||||
/// parse_with_types! {
|
||||
/// unity_types(Transform, Camera)
|
||||
/// }
|
||||
///
|
||||
/// // Only custom types
|
||||
/// parse_with_types! {
|
||||
/// custom_types(PlaySFX)
|
||||
/// }
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! parse_with_types {
|
||||
// Full syntax with both Unity and custom types
|
||||
(unity_types($($unity:ident),+ $(,)?), custom_types($($custom:ident),+ $(,)?)) => {
|
||||
$crate::TypeFilter::new(
|
||||
vec![$(stringify!($unity)),+],
|
||||
vec![$(stringify!($custom)),+]
|
||||
)
|
||||
};
|
||||
|
||||
// Only Unity types
|
||||
(unity_types($($unity:ident),+ $(,)?)) => {
|
||||
$crate::TypeFilter::unity_only(
|
||||
vec![$(stringify!($unity)),+]
|
||||
)
|
||||
};
|
||||
|
||||
// Only custom types
|
||||
(custom_types($($custom:ident),+ $(,)?)) => {
|
||||
$crate::TypeFilter::custom_only(
|
||||
vec![$(stringify!($custom)),+]
|
||||
)
|
||||
};
|
||||
|
||||
// Alternative order: custom_types first
|
||||
(custom_types($($custom:ident),+ $(,)?), unity_types($($unity:ident),+ $(,)?)) => {
|
||||
$crate::TypeFilter::new(
|
||||
vec![$(stringify!($unity)),+],
|
||||
vec![$(stringify!($custom)),+]
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::TypeFilter;
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_types_macro() {
|
||||
let filter = parse_with_types! {
|
||||
unity_types(Transform, Camera, Light),
|
||||
custom_types(PlaySFX, Interact)
|
||||
};
|
||||
|
||||
assert!(filter.should_parse_unity("Transform"));
|
||||
assert!(filter.should_parse_unity("Camera"));
|
||||
assert!(filter.should_parse_unity("Light"));
|
||||
assert!(!filter.should_parse_unity("AudioSource"));
|
||||
|
||||
assert!(filter.should_parse_custom("PlaySFX"));
|
||||
assert!(filter.should_parse_custom("Interact"));
|
||||
assert!(!filter.should_parse_custom("OtherComponent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_types_unity_only() {
|
||||
let filter = parse_with_types! {
|
||||
unity_types(Transform, Camera)
|
||||
};
|
||||
|
||||
assert!(filter.should_parse_unity("Transform"));
|
||||
assert!(!filter.should_parse_unity("Light"));
|
||||
assert!(!filter.should_parse_custom("PlaySFX"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_types_custom_only() {
|
||||
let filter = parse_with_types! {
|
||||
custom_types(PlaySFX, Interact)
|
||||
};
|
||||
|
||||
assert!(!filter.should_parse_unity("Transform"));
|
||||
assert!(filter.should_parse_custom("PlaySFX"));
|
||||
assert!(filter.should_parse_custom("Interact"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_with_types_reversed_order() {
|
||||
let filter = parse_with_types! {
|
||||
custom_types(PlaySFX),
|
||||
unity_types(Transform)
|
||||
};
|
||||
|
||||
assert!(filter.should_parse_unity("Transform"));
|
||||
assert!(filter.should_parse_custom("PlaySFX"));
|
||||
}
|
||||
}
|
||||
212
unity-parser/src/model/mod.rs
Normal file
212
unity-parser/src/model/mod.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
//! Core data model for Unity files
|
||||
//!
|
||||
//! This module provides the fundamental types for representing parsed Unity files.
|
||||
//! Unity files can be Scenes (.unity), Prefabs (.prefab), or Assets (.asset), each
|
||||
//! with different handling requirements.
|
||||
|
||||
use crate::types::FileID;
|
||||
use sparsey::{Entity, World};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A parsed Unity file - can be a Scene, Prefab, or Asset
|
||||
#[derive(Debug)]
|
||||
pub enum UnityFile {
|
||||
/// Scene file (.unity) with fully-parsed ECS World
|
||||
Scene(UnityScene),
|
||||
/// Prefab file (.prefab) with raw YAML for instancing
|
||||
Prefab(UnityPrefab),
|
||||
/// Asset file (.asset) with raw YAML
|
||||
Asset(UnityAsset),
|
||||
}
|
||||
|
||||
impl UnityFile {
|
||||
/// Parse a Unity file from the given path
|
||||
pub fn from_path(path: impl Into<PathBuf>) -> crate::Result<Self> {
|
||||
let path = path.into();
|
||||
crate::parser::parse_unity_file(&path)
|
||||
}
|
||||
|
||||
/// Get the file path
|
||||
pub fn path(&self) -> &PathBuf {
|
||||
match self {
|
||||
UnityFile::Scene(s) => &s.path,
|
||||
UnityFile::Prefab(p) => &p.path,
|
||||
UnityFile::Asset(a) => &a.path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a scene
|
||||
pub fn is_scene(&self) -> bool {
|
||||
matches!(self, UnityFile::Scene(_))
|
||||
}
|
||||
|
||||
/// Check if this is a prefab
|
||||
pub fn is_prefab(&self) -> bool {
|
||||
matches!(self, UnityFile::Prefab(_))
|
||||
}
|
||||
|
||||
/// Check if this is an asset
|
||||
pub fn is_asset(&self) -> bool {
|
||||
matches!(self, UnityFile::Asset(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// A Unity scene with fully-parsed ECS World
|
||||
///
|
||||
/// Scenes contain GameObjects and Components parsed into a Sparsey ECS world.
|
||||
/// The entity_map provides mapping from Unity FileIDs to ECS entities.
|
||||
#[derive(Debug)]
|
||||
pub struct UnityScene {
|
||||
/// Path to the scene file
|
||||
pub path: PathBuf,
|
||||
|
||||
/// ECS World containing all entities and components
|
||||
pub world: World,
|
||||
|
||||
/// Mapping from Unity FileID to ECS Entity
|
||||
pub entity_map: HashMap<FileID, Entity>,
|
||||
}
|
||||
|
||||
impl UnityScene {
|
||||
/// Create a new UnityScene
|
||||
pub fn new(path: PathBuf, world: World, entity_map: HashMap<FileID, Entity>) -> Self {
|
||||
Self {
|
||||
path,
|
||||
world,
|
||||
entity_map,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get an entity by its Unity FileID
|
||||
pub fn get_entity(&self, file_id: FileID) -> Option<Entity> {
|
||||
self.entity_map.get(&file_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// A Unity prefab with raw YAML for instancing
|
||||
///
|
||||
/// Prefabs are kept as raw YAML documents to enable efficient cloning and
|
||||
/// value overriding during instantiation into scenes.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnityPrefab {
|
||||
/// Path to the prefab file
|
||||
pub path: PathBuf,
|
||||
|
||||
/// Raw YAML documents that make up this prefab
|
||||
pub documents: Vec<RawDocument>,
|
||||
}
|
||||
|
||||
impl UnityPrefab {
|
||||
/// Create a new UnityPrefab
|
||||
pub fn new(path: PathBuf, documents: Vec<RawDocument>) -> Self {
|
||||
Self { path, documents }
|
||||
}
|
||||
|
||||
/// Get a document by its FileID
|
||||
pub fn get_document(&self, file_id: FileID) -> Option<&RawDocument> {
|
||||
self.documents.iter().find(|doc| doc.file_id == file_id)
|
||||
}
|
||||
|
||||
/// Get all documents of a specific type
|
||||
pub fn get_documents_by_type(&self, type_id: u32) -> Vec<&RawDocument> {
|
||||
self.documents
|
||||
.iter()
|
||||
.filter(|doc| doc.type_id == type_id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all documents with a specific class name
|
||||
pub fn get_documents_by_class(&self, class_name: &str) -> Vec<&RawDocument> {
|
||||
self.documents
|
||||
.iter()
|
||||
.filter(|doc| doc.class_name == class_name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Create a new instance of this prefab for spawning into a scene
|
||||
///
|
||||
/// This clones the prefab's documents and prepares them for instantiation
|
||||
/// with unique FileIDs to avoid collisions.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `PrefabInstance` that can be customized with overrides and spawned
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let mut instance = prefab.instantiate();
|
||||
/// instance.override_value(file_id, "m_Name", "Player1".into())?;
|
||||
/// let entities = instance.spawn_into(&mut world, &mut entity_map)?;
|
||||
/// ```
|
||||
pub fn instantiate(&self) -> crate::types::PrefabInstance {
|
||||
crate::types::PrefabInstance::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A Unity asset file with raw YAML
|
||||
///
|
||||
/// Assets (like ScriptableObjects) are handled similarly to prefabs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnityAsset {
|
||||
/// Path to the asset file
|
||||
pub path: PathBuf,
|
||||
|
||||
/// Raw YAML documents that make up this asset
|
||||
pub documents: Vec<RawDocument>,
|
||||
}
|
||||
|
||||
impl UnityAsset {
|
||||
/// Create a new UnityAsset
|
||||
pub fn new(path: PathBuf, documents: Vec<RawDocument>) -> Self {
|
||||
Self { path, documents }
|
||||
}
|
||||
|
||||
/// Get a document by its FileID
|
||||
pub fn get_document(&self, file_id: FileID) -> Option<&RawDocument> {
|
||||
self.documents.iter().find(|doc| doc.file_id == file_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw YAML document with Unity metadata
|
||||
///
|
||||
/// This represents a single Unity object (GameObject, Component, etc.) with
|
||||
/// its metadata (type_id, file_id, class_name) and raw YAML content.
|
||||
///
|
||||
/// The `yaml` field contains the inner YAML mapping (the contents after the
|
||||
/// class name wrapper like `GameObject: { ... }`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RawDocument {
|
||||
/// Unity type ID (from !u!N tag)
|
||||
pub type_id: u32,
|
||||
|
||||
/// File ID (from &ID anchor)
|
||||
pub file_id: FileID,
|
||||
|
||||
/// Class name (e.g., "GameObject", "Transform", "RectTransform")
|
||||
pub class_name: String,
|
||||
|
||||
/// Raw YAML value (inner mapping after class wrapper)
|
||||
pub yaml: serde_yaml::Value,
|
||||
}
|
||||
|
||||
impl RawDocument {
|
||||
/// Create a new RawDocument
|
||||
pub fn new(
|
||||
type_id: u32,
|
||||
file_id: FileID,
|
||||
class_name: String,
|
||||
yaml: serde_yaml::Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
type_id,
|
||||
file_id,
|
||||
class_name,
|
||||
yaml,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the YAML as a mapping, if it is one
|
||||
pub fn as_mapping(&self) -> Option<&serde_yaml::Mapping> {
|
||||
self.yaml.as_mapping()
|
||||
}
|
||||
}
|
||||
501
unity-parser/src/parser/guid_resolver.rs
Normal file
501
unity-parser/src/parser/guid_resolver.rs
Normal 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 cursebreaker_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::<(), cursebreaker_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 cursebreaker_parser::parser::GuidResolver;
|
||||
/// use std::path::Path;
|
||||
///
|
||||
/// let resolver = GuidResolver::from_project(Path::new("MyUnityProject"))?;
|
||||
/// # Ok::<(), cursebreaker_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 cursebreaker_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::<(), cursebreaker_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 cursebreaker_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 cursebreaker_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::<(), cursebreaker_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
|
||||
);
|
||||
}
|
||||
}
|
||||
213
unity-parser/src/parser/meta.rs
Normal file
213
unity-parser/src/parser/meta.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
//! Unity .meta file parser
|
||||
//!
|
||||
//! Unity creates .meta files alongside assets to store metadata including
|
||||
//! the unique GUID that identifies each asset.
|
||||
|
||||
use crate::{Error, Result};
|
||||
use std::path::Path;
|
||||
|
||||
/// Represents a Unity .meta file
|
||||
///
|
||||
/// .meta files contain metadata about Unity assets, most importantly
|
||||
/// the GUID which uniquely identifies the asset across the project.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MetaFile {
|
||||
/// The unique GUID for this asset
|
||||
pub guid: String,
|
||||
|
||||
/// The file format version
|
||||
pub file_format_version: Option<i64>,
|
||||
}
|
||||
|
||||
impl MetaFile {
|
||||
/// Parse a Unity .meta file from the given path
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - Path to the .meta file
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use cursebreaker_parser::parser::meta::MetaFile;
|
||||
///
|
||||
/// let meta = MetaFile::from_path("Assets/Prefabs/Player.prefab.meta")?;
|
||||
/// println!("GUID: {}", meta.guid);
|
||||
/// # Ok::<(), cursebreaker_parser::Error>(())
|
||||
/// ```
|
||||
pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let path = path.as_ref();
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
Self::from_str(&content)
|
||||
}
|
||||
|
||||
/// Parse a Unity .meta file from a string
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `content` - The YAML content of the .meta file
|
||||
pub fn from_str(content: &str) -> Result<Self> {
|
||||
// Parse as YAML
|
||||
let yaml: serde_yaml::Value = serde_yaml::from_str(content)?;
|
||||
|
||||
// Extract GUID (required field)
|
||||
let guid = yaml
|
||||
.get("guid")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::invalid_format("Missing 'guid' field in .meta file"))?
|
||||
.to_string();
|
||||
|
||||
// Extract file format version (optional)
|
||||
let file_format_version = yaml
|
||||
.get("fileFormatVersion")
|
||||
.and_then(|v| v.as_i64());
|
||||
|
||||
Ok(Self {
|
||||
guid,
|
||||
file_format_version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the GUID from this .meta file
|
||||
pub fn guid(&self) -> &str {
|
||||
&self.guid
|
||||
}
|
||||
|
||||
/// Get the file format version
|
||||
pub fn file_format_version(&self) -> Option<i64> {
|
||||
self.file_format_version
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the .meta file path for a given asset path
|
||||
///
|
||||
/// Unity creates .meta files alongside assets with the .meta extension.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `asset_path` - Path to the Unity asset
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::parser::meta::get_meta_path;
|
||||
/// use std::path::PathBuf;
|
||||
///
|
||||
/// let asset = PathBuf::from("Assets/Scenes/MainMenu.unity");
|
||||
/// let meta = get_meta_path(&asset);
|
||||
/// assert_eq!(meta, PathBuf::from("Assets/Scenes/MainMenu.unity.meta"));
|
||||
/// ```
|
||||
pub fn get_meta_path(asset_path: &Path) -> std::path::PathBuf {
|
||||
let mut meta_path = asset_path.to_path_buf();
|
||||
let mut filename = meta_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_os_string();
|
||||
filename.push(".meta");
|
||||
meta_path.set_file_name(filename);
|
||||
meta_path
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_parse_meta_file() {
|
||||
let content = r#"
|
||||
fileFormatVersion: 2
|
||||
guid: 4ab6bfb0ff54cdf4c8dd38ca244d6f15
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
"#;
|
||||
|
||||
let meta = MetaFile::from_str(content).unwrap();
|
||||
assert_eq!(meta.guid(), "4ab6bfb0ff54cdf4c8dd38ca244d6f15");
|
||||
assert_eq!(meta.file_format_version(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_meta_file_minimal() {
|
||||
let content = r#"
|
||||
guid: abc123def456
|
||||
"#;
|
||||
|
||||
let meta = MetaFile::from_str(content).unwrap();
|
||||
assert_eq!(meta.guid(), "abc123def456");
|
||||
assert_eq!(meta.file_format_version(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_meta_file_missing_guid() {
|
||||
let content = r#"
|
||||
fileFormatVersion: 2
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
"#;
|
||||
|
||||
let result = MetaFile::from_str(content);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_meta_path() {
|
||||
let asset = PathBuf::from("Assets/Scenes/MainMenu.unity");
|
||||
let meta = get_meta_path(&asset);
|
||||
assert_eq!(meta, PathBuf::from("Assets/Scenes/MainMenu.unity.meta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_meta_path_prefab() {
|
||||
let asset = PathBuf::from("Assets/Prefabs/Player.prefab");
|
||||
let meta = get_meta_path(&asset);
|
||||
assert_eq!(meta, PathBuf::from("Assets/Prefabs/Player.prefab.meta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_meta_path_asset() {
|
||||
let asset = PathBuf::from("Assets/ScriptableObjects/Config.asset");
|
||||
let meta = get_meta_path(&asset);
|
||||
assert_eq!(meta, PathBuf::from("Assets/ScriptableObjects/Config.asset.meta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_real_meta_file() {
|
||||
// This tests with a realistic Unity .meta file structure
|
||||
let content = r#"
|
||||
fileFormatVersion: 2
|
||||
guid: 06560ff19ed0c0b43918260dee8775dd
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
"#;
|
||||
|
||||
let meta = MetaFile::from_str(content).unwrap();
|
||||
assert_eq!(meta.guid(), "06560ff19ed0c0b43918260dee8775dd");
|
||||
assert_eq!(meta.file_format_version(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_script_meta_file() {
|
||||
let content = r#"
|
||||
fileFormatVersion: 2
|
||||
guid: b12cf7f429956b944a0d0e4b85516679
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
"#;
|
||||
|
||||
let meta = MetaFile::from_str(content).unwrap();
|
||||
assert_eq!(meta.guid(), "b12cf7f429956b944a0d0e4b85516679");
|
||||
assert_eq!(meta.file_format_version(), Some(2));
|
||||
}
|
||||
}
|
||||
638
unity-parser/src/parser/mod.rs
Normal file
638
unity-parser/src/parser/mod.rs
Normal 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 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> {
|
||||
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 cursebreaker_parser::parser::{parse_unity_file_filtered};
|
||||
/// use cursebreaker_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::<(), cursebreaker_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());
|
||||
}
|
||||
}
|
||||
314
unity-parser/src/parser/prefab_guid_resolver.rs
Normal file
314
unity-parser/src/parser/prefab_guid_resolver.rs
Normal 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 cursebreaker_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::<(), cursebreaker_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 cursebreaker_parser::parser::PrefabGuidResolver;
|
||||
/// use std::path::Path;
|
||||
///
|
||||
/// let resolver = PrefabGuidResolver::from_project(Path::new("MyUnityProject"))?;
|
||||
/// # Ok::<(), cursebreaker_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 cursebreaker_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::<(), cursebreaker_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 cursebreaker_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
|
||||
);
|
||||
}
|
||||
}
|
||||
97
unity-parser/src/parser/unity_tag.rs
Normal file
97
unity-parser/src/parser/unity_tag.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
//! Unity type tag parser
|
||||
//!
|
||||
//! Handles parsing of Unity's special YAML tags like:
|
||||
//! - `--- !u!1 &12345` (GameObject with file ID)
|
||||
//! - `--- !u!224 &8151827567463220614` (RectTransform)
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// A parsed Unity type tag
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UnityTag {
|
||||
/// Unity type ID (the number after !u!)
|
||||
pub type_id: u32,
|
||||
|
||||
/// File ID (the number after &)
|
||||
pub file_id: i64,
|
||||
}
|
||||
|
||||
/// Get the Unity tag regex (compiled once and cached)
|
||||
fn unity_tag_regex() -> &'static Regex {
|
||||
static REGEX: OnceLock<Regex> = OnceLock::new();
|
||||
REGEX.get_or_init(|| {
|
||||
// Matches: --- !u!<type_id> &<file_id>
|
||||
// Example: --- !u!1 &1866116814460599870
|
||||
Regex::new(r"^---\s+!u!(\d+)\s+&(-?\d+)").unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a Unity type tag from a document string
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::parser::parse_unity_tag;
|
||||
///
|
||||
/// let doc = "--- !u!1 &12345\nGameObject:\n m_Name: Test";
|
||||
/// let tag = parse_unity_tag(doc).unwrap();
|
||||
/// assert_eq!(tag.type_id, 1);
|
||||
/// assert_eq!(tag.file_id, 12345);
|
||||
/// ```
|
||||
pub fn parse_unity_tag(document: &str) -> Option<UnityTag> {
|
||||
let re = unity_tag_regex();
|
||||
|
||||
// Get the first line
|
||||
let first_line = document.lines().next()?;
|
||||
|
||||
// Try to match the pattern
|
||||
let captures = re.captures(first_line)?;
|
||||
|
||||
// Extract type ID and file ID
|
||||
let type_id = captures.get(1)?.as_str().parse::<u32>().ok()?;
|
||||
let file_id = captures.get(2)?.as_str().parse::<i64>().ok()?;
|
||||
|
||||
Some(UnityTag { type_id, file_id })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_unity_tag() {
|
||||
let doc = "--- !u!1 &1866116814460599870\nGameObject:\n m_Name: CardGrabber";
|
||||
let tag = parse_unity_tag(doc).unwrap();
|
||||
assert_eq!(tag.type_id, 1);
|
||||
assert_eq!(tag.file_id, 1866116814460599870);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unity_tag_rect_transform() {
|
||||
let doc = "--- !u!224 &8151827567463220614\nRectTransform:\n m_GameObject: {fileID: 1866116814460599870}";
|
||||
let tag = parse_unity_tag(doc).unwrap();
|
||||
assert_eq!(tag.type_id, 224);
|
||||
assert_eq!(tag.file_id, 8151827567463220614);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unity_tag_negative_id() {
|
||||
let doc = "--- !u!114 &-12345\nMonoBehaviour:\n m_Script: {fileID: 11500000}";
|
||||
let tag = parse_unity_tag(doc).unwrap();
|
||||
assert_eq!(tag.type_id, 114);
|
||||
assert_eq!(tag.file_id, -12345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unity_tag_invalid() {
|
||||
let doc = "Not a Unity document";
|
||||
assert!(parse_unity_tag(doc).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_unity_tag_no_anchor() {
|
||||
let doc = "--- !u!1\nGameObject:";
|
||||
assert!(parse_unity_tag(doc).is_none());
|
||||
}
|
||||
}
|
||||
153
unity-parser/src/parser/yaml.rs
Normal file
153
unity-parser/src/parser/yaml.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! YAML document splitting utilities
|
||||
//!
|
||||
//! Unity files contain multiple YAML documents separated by `---` markers.
|
||||
//! This module handles splitting these multi-document files.
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Split a Unity YAML file into individual documents
|
||||
///
|
||||
/// Unity files use the YAML 1.1 multi-document format, where each document
|
||||
/// starts with `---`. This function splits the file into individual documents.
|
||||
///
|
||||
/// Returns string slices referencing the original content, avoiding allocations.
|
||||
/// Callers can convert slices to owned strings with `.to_string()` if needed.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_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();
|
||||
/// assert_eq!(docs.len(), 2);
|
||||
/// ```
|
||||
pub fn split_yaml_documents<'a>(content: &'a str) -> Result<Vec<&'a str>> {
|
||||
let mut documents = Vec::new();
|
||||
let mut doc_start: Option<usize> = None;
|
||||
let mut pos = 0;
|
||||
|
||||
// Use split_inclusive to keep newlines, making byte position tracking easier
|
||||
for line in content.split_inclusive('\n') {
|
||||
// Get the line content without line endings for checking
|
||||
let trimmed = line.trim_end_matches(&['\r', '\n'][..]);
|
||||
|
||||
// Skip empty lines before first document
|
||||
if trimmed.is_empty() && doc_start.is_none() {
|
||||
pos += line.len();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip YAML headers (%YAML and %TAG)
|
||||
if trimmed.starts_with('%') {
|
||||
pos += line.len();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a document separator
|
||||
if trimmed.starts_with("---") {
|
||||
// Save previous document if exists
|
||||
if let Some(start) = doc_start {
|
||||
documents.push(content[start..pos].trim());
|
||||
}
|
||||
|
||||
// Mark the start of the new document
|
||||
doc_start = Some(pos);
|
||||
}
|
||||
|
||||
pos += line.len();
|
||||
}
|
||||
|
||||
// Add the last document if it exists
|
||||
if let Some(start) = doc_start {
|
||||
documents.push(content[start..].trim());
|
||||
}
|
||||
|
||||
// Validate we found at least one document
|
||||
if documents.is_empty() {
|
||||
return Err(Error::EmptyFile);
|
||||
}
|
||||
|
||||
Ok(documents)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_split_yaml_documents_simple() {
|
||||
let content = r#"%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!1 &123
|
||||
GameObject:
|
||||
m_Name: Test
|
||||
--- !u!4 &456
|
||||
Transform:
|
||||
m_GameObject: {fileID: 123}"#;
|
||||
|
||||
let docs = split_yaml_documents(content).unwrap();
|
||||
assert_eq!(docs.len(), 2);
|
||||
assert!(docs[0].contains("GameObject"));
|
||||
assert!(docs[1].contains("Transform"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_yaml_documents_single() {
|
||||
let content = r#"%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!1 &123
|
||||
GameObject:
|
||||
m_Name: Test"#;
|
||||
|
||||
let docs = split_yaml_documents(content).unwrap();
|
||||
assert_eq!(docs.len(), 1);
|
||||
assert!(docs[0].contains("GameObject"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_yaml_documents_empty() {
|
||||
let content = "%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n";
|
||||
let result = split_yaml_documents(content);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_yaml_documents_with_empty_lines() {
|
||||
let content = r#"%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
|
||||
--- !u!1 &123
|
||||
GameObject:
|
||||
m_Name: Test
|
||||
|
||||
--- !u!4 &456
|
||||
Transform:
|
||||
m_GameObject: {fileID: 123}"#;
|
||||
|
||||
let docs = split_yaml_documents(content).unwrap();
|
||||
assert_eq!(docs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_yaml_documents_complex() {
|
||||
let content = r#"%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!1 &1866116814460599870
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_Component:
|
||||
- component: {fileID: 8151827567463220614}
|
||||
- component: {fileID: 8755205353704683373}
|
||||
m_Name: CardGrabber
|
||||
--- !u!224 &8151827567463220614
|
||||
RectTransform:
|
||||
m_GameObject: {fileID: 1866116814460599870}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}"#;
|
||||
|
||||
let docs = split_yaml_documents(content).unwrap();
|
||||
assert_eq!(docs.len(), 2);
|
||||
assert!(docs[0].contains("CardGrabber"));
|
||||
assert!(docs[1].contains("RectTransform"));
|
||||
}
|
||||
}
|
||||
512
unity-parser/src/project/mod.rs
Normal file
512
unity-parser/src/project/mod.rs
Normal file
@@ -0,0 +1,512 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
280
unity-parser/src/project/query.rs
Normal file
280
unity-parser/src/project/query.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
//! Query helper methods for UnityProject
|
||||
//!
|
||||
//! This module extends UnityProject with convenient query methods
|
||||
//! for finding and filtering Unity objects.
|
||||
|
||||
use crate::{UnityDocument, UnityProject};
|
||||
|
||||
impl UnityProject {
|
||||
/// Find all documents of a specific type across all files
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `type_id` - Unity type ID to search for
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use cursebreaker_parser::UnityProject;
|
||||
///
|
||||
/// let project = UnityProject::new(100);
|
||||
/// let game_objects = project.find_all_by_type(1); // GameObject = type 1
|
||||
/// println!("Found {} GameObjects", game_objects.len());
|
||||
/// ```
|
||||
pub fn find_all_by_type(&self, type_id: u32) -> Vec<&UnityDocument> {
|
||||
self.files
|
||||
.values()
|
||||
.flat_map(|file| file.get_documents_by_type(type_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find all documents with a specific class name across all files
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `class_name` - Unity class name to search for (e.g., "GameObject", "Transform")
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use cursebreaker_parser::UnityProject;
|
||||
///
|
||||
/// let project = UnityProject::new(100);
|
||||
/// let transforms = project.find_all_by_class("Transform");
|
||||
/// println!("Found {} Transforms", transforms.len());
|
||||
/// ```
|
||||
pub fn find_all_by_class(&self, class_name: &str) -> Vec<&UnityDocument> {
|
||||
self.files
|
||||
.values()
|
||||
.flat_map(|file| file.get_documents_by_class(class_name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find documents by name (searches for m_Name property)
|
||||
///
|
||||
/// Searches for GameObjects with a specific m_Name value.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - The name to search for
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use cursebreaker_parser::UnityProject;
|
||||
///
|
||||
/// let project = UnityProject::new(100);
|
||||
/// let players = project.find_by_name("Player");
|
||||
/// for player in players {
|
||||
/// println!("Found Player at FileID: {}", player.file_id);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn find_by_name(&self, name: &str) -> Vec<&UnityDocument> {
|
||||
self.files
|
||||
.values()
|
||||
.flat_map(|file| &file.documents)
|
||||
.filter(|doc| {
|
||||
// Check for m_Name in the root object
|
||||
if let Some(obj) = doc.get(&doc.class_name).and_then(|v| v.as_object()) {
|
||||
if let Some(doc_name) = obj.get("m_Name").and_then(|v| v.as_str()) {
|
||||
return doc_name == name;
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get a component from a GameObject by component type
|
||||
///
|
||||
/// Searches the GameObject's m_Component array for a component with the specified class name.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `game_object` - The GameObject document
|
||||
/// * `component_type` - The component class name to find (e.g., "Transform", "SpriteRenderer")
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use cursebreaker_parser::UnityProject;
|
||||
///
|
||||
/// let project = UnityProject::new(100);
|
||||
/// # let game_object = &project.find_all_by_type(1)[0];
|
||||
/// if let Some(transform) = project.get_component(game_object, "Transform") {
|
||||
/// println!("Found Transform component");
|
||||
/// }
|
||||
/// ```
|
||||
pub fn get_component(
|
||||
&self,
|
||||
game_object: &UnityDocument,
|
||||
component_type: &str,
|
||||
) -> Option<&UnityDocument> {
|
||||
if !game_object.is_game_object() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Get the m_Component array
|
||||
let components = game_object
|
||||
.get("GameObject")
|
||||
.and_then(|v| v.as_object())?
|
||||
.get("m_Component")
|
||||
.and_then(|v| v.as_array())?;
|
||||
|
||||
// Search for the component type
|
||||
for comp_entry in components {
|
||||
if let Some(obj) = comp_entry.as_object() {
|
||||
if let Some(comp_ref) = obj.get("component").and_then(|v| v.as_file_ref()) {
|
||||
if let Some(comp_doc) = self.get_document(comp_ref.file_id) {
|
||||
if comp_doc.class_name == component_type {
|
||||
return Some(comp_doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get all components from a GameObject
|
||||
///
|
||||
/// Returns all components attached to the specified GameObject.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `game_object` - The GameObject document
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use cursebreaker_parser::UnityProject;
|
||||
///
|
||||
/// let project = UnityProject::new(100);
|
||||
/// # let game_object = &project.find_all_by_type(1)[0];
|
||||
/// let components = project.get_all_components(game_object);
|
||||
/// for component in components {
|
||||
/// println!("Component: {}", component.class_name);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn get_all_components(&self, game_object: &UnityDocument) -> Vec<&UnityDocument> {
|
||||
if !game_object.is_game_object() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let components = match game_object
|
||||
.get("GameObject")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|obj| obj.get("m_Component"))
|
||||
.and_then(|v| v.as_array())
|
||||
{
|
||||
Some(c) => c,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
components
|
||||
.iter()
|
||||
.filter_map(|comp_entry| {
|
||||
comp_entry
|
||||
.as_object()?
|
||||
.get("component")
|
||||
.and_then(|v| v.as_file_ref())
|
||||
.and_then(|r| self.get_document(r.file_id))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_find_all_by_type() {
|
||||
let mut project = UnityProject::new(100);
|
||||
let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab";
|
||||
|
||||
if Path::new(path).exists() {
|
||||
project.load_file(path).unwrap();
|
||||
|
||||
// Find all GameObjects (type 1)
|
||||
let game_objects = project.find_all_by_type(1);
|
||||
assert!(game_objects.len() > 0, "Should find at least one GameObject");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_all_by_class() {
|
||||
let mut project = UnityProject::new(100);
|
||||
let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab";
|
||||
|
||||
if Path::new(path).exists() {
|
||||
project.load_file(path).unwrap();
|
||||
|
||||
// Find all Transform or RectTransform components (UI prefabs typically use RectTransform)
|
||||
let transforms = project.find_all_by_class("Transform");
|
||||
let rect_transforms = project.find_all_by_class("RectTransform");
|
||||
|
||||
assert!(
|
||||
transforms.len() > 0 || rect_transforms.len() > 0,
|
||||
"Should find at least one Transform or RectTransform"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_by_name() {
|
||||
let mut project = UnityProject::new(100);
|
||||
let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab";
|
||||
|
||||
if Path::new(path).exists() {
|
||||
project.load_file(path).unwrap();
|
||||
|
||||
// The prefab should have a root GameObject with the name "CardGrabber"
|
||||
let results = project.find_by_name("CardGrabber");
|
||||
assert!(results.len() > 0, "Should find GameObject named 'CardGrabber'");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_component() {
|
||||
let mut project = UnityProject::new(100);
|
||||
let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab";
|
||||
|
||||
if Path::new(path).exists() {
|
||||
project.load_file(path).unwrap();
|
||||
|
||||
// Find a GameObject
|
||||
let game_objects = project.find_all_by_type(1);
|
||||
if let Some(go) = game_objects.first() {
|
||||
// Every GameObject should have a Transform
|
||||
let transform = project.get_component(go, "Transform");
|
||||
assert!(transform.is_some() || project.get_component(go, "RectTransform").is_some(),
|
||||
"GameObject should have Transform or RectTransform component");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_components() {
|
||||
let mut project = UnityProject::new(100);
|
||||
let path = "data/tests/unity-sampleproject/PiratePanic/Assets/PiratePanic/Prefabs/Menu/Battle/Hand/CardGrabber.prefab";
|
||||
|
||||
if Path::new(path).exists() {
|
||||
project.load_file(path).unwrap();
|
||||
|
||||
// Find a GameObject
|
||||
let game_objects = project.find_all_by_type(1);
|
||||
if let Some(go) = game_objects.first() {
|
||||
let components = project.get_all_components(go);
|
||||
assert!(components.len() > 0, "GameObject should have at least one component");
|
||||
|
||||
// Verify all returned items are actual components
|
||||
for component in components {
|
||||
assert!(!component.is_game_object(), "Should not return GameObject as component");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
533
unity-parser/src/property/mod.rs
Normal file
533
unity-parser/src/property/mod.rs
Normal file
@@ -0,0 +1,533 @@
|
||||
//! Property value types and conversion
|
||||
//!
|
||||
//! This module provides the `PropertyValue` enum which represents
|
||||
//! typed Unity property values, and conversion logic from YAML values.
|
||||
|
||||
use crate::types::{Color, ExternalRef, FileID, FileRef, Quaternion, Vector2, Vector3};
|
||||
use crate::Error;
|
||||
use indexmap::IndexMap;
|
||||
use std::fmt;
|
||||
|
||||
/// A typed property value in a Unity object
|
||||
///
|
||||
/// This enum represents all possible value types that can appear
|
||||
/// in Unity YAML files, including Unity-specific types like Vector3 and Color.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PropertyValue {
|
||||
/// Integer value
|
||||
Integer(i64),
|
||||
/// Floating-point value
|
||||
Float(f64),
|
||||
/// String value
|
||||
String(String),
|
||||
/// Boolean value
|
||||
Boolean(bool),
|
||||
/// Null value
|
||||
Null,
|
||||
|
||||
// Unity-specific types
|
||||
/// 2D vector (x, y)
|
||||
Vector2(Vector2),
|
||||
/// 3D vector (x, y, z)
|
||||
Vector3(Vector3),
|
||||
/// Color (r, g, b, a)
|
||||
Color(Color),
|
||||
/// Quaternion rotation (x, y, z, w)
|
||||
Quaternion(Quaternion),
|
||||
/// Reference to another object by file ID
|
||||
FileRef(FileRef),
|
||||
/// Reference to an external asset by GUID
|
||||
ExternalRef(ExternalRef),
|
||||
|
||||
// Collections
|
||||
/// Array of values
|
||||
Array(Vec<PropertyValue>),
|
||||
/// Nested object with properties
|
||||
Object(IndexMap<String, PropertyValue>),
|
||||
}
|
||||
|
||||
impl PropertyValue {
|
||||
/// Try to get this value as an integer
|
||||
pub fn as_i64(&self) -> Option<i64> {
|
||||
match self {
|
||||
PropertyValue::Integer(v) => Some(*v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get this value as a float
|
||||
pub fn as_f64(&self) -> Option<f64> {
|
||||
match self {
|
||||
PropertyValue::Float(v) => Some(*v),
|
||||
PropertyValue::Integer(v) => Some(*v as f64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get this value as a string reference
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
PropertyValue::String(s) => Some(s.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get this value as a boolean
|
||||
pub fn as_bool(&self) -> Option<bool> {
|
||||
match self {
|
||||
PropertyValue::Boolean(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get this value as a Vector2
|
||||
pub fn as_vector2(&self) -> Option<&Vector2> {
|
||||
match self {
|
||||
PropertyValue::Vector2(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get this value as a Vector3
|
||||
pub fn as_vector3(&self) -> Option<&Vector3> {
|
||||
match self {
|
||||
PropertyValue::Vector3(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get this value as a Color
|
||||
pub fn as_color(&self) -> Option<&Color> {
|
||||
match self {
|
||||
PropertyValue::Color(c) => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get this value as a Quaternion
|
||||
pub fn as_quaternion(&self) -> Option<&Quaternion> {
|
||||
match self {
|
||||
PropertyValue::Quaternion(q) => Some(q),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get this value as a FileRef
|
||||
pub fn as_file_ref(&self) -> Option<&FileRef> {
|
||||
match self {
|
||||
PropertyValue::FileRef(r) => Some(r),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get this value as an ExternalRef
|
||||
pub fn as_external_ref(&self) -> Option<&ExternalRef> {
|
||||
match self {
|
||||
PropertyValue::ExternalRef(r) => Some(r),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get this value as an array
|
||||
pub fn as_array(&self) -> Option<&Vec<PropertyValue>> {
|
||||
match self {
|
||||
PropertyValue::Array(arr) => Some(arr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get this value as an object
|
||||
pub fn as_object(&self) -> Option<&IndexMap<String, PropertyValue>> {
|
||||
match self {
|
||||
PropertyValue::Object(obj) => Some(obj),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this value is null
|
||||
pub fn is_null(&self) -> bool {
|
||||
matches!(self, PropertyValue::Null)
|
||||
}
|
||||
|
||||
/// Check if this value is an array
|
||||
pub fn is_array(&self) -> bool {
|
||||
matches!(self, PropertyValue::Array(_))
|
||||
}
|
||||
|
||||
/// Check if this value is an object
|
||||
pub fn is_object(&self) -> bool {
|
||||
matches!(self, PropertyValue::Object(_))
|
||||
}
|
||||
|
||||
/// Check if this value is a Vector3
|
||||
pub fn is_vector3(&self) -> bool {
|
||||
matches!(self, PropertyValue::Vector3(_))
|
||||
}
|
||||
|
||||
/// Check if this value is a Color
|
||||
pub fn is_color(&self) -> bool {
|
||||
matches!(self, PropertyValue::Color(_))
|
||||
}
|
||||
|
||||
/// Check if this value is a FileRef
|
||||
pub fn is_file_ref(&self) -> bool {
|
||||
matches!(self, PropertyValue::FileRef(_))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PropertyValue {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
PropertyValue::Integer(v) => write!(f, "{}", v),
|
||||
PropertyValue::Float(v) => write!(f, "{}", v),
|
||||
PropertyValue::String(s) => write!(f, "\"{}\"", s),
|
||||
PropertyValue::Boolean(b) => write!(f, "{}", b),
|
||||
PropertyValue::Null => write!(f, "null"),
|
||||
PropertyValue::Vector2(v) => write!(f, "({}, {})", v.x, v.y),
|
||||
PropertyValue::Vector3(v) => write!(f, "({}, {}, {})", v.x, v.y, v.z),
|
||||
PropertyValue::Color(c) => write!(f, "rgba({}, {}, {}, {})", c.x, c.y, c.z, c.w),
|
||||
PropertyValue::Quaternion(q) => write!(f, "({}, {}, {}, {})", q.x, q.y, q.z, q.w),
|
||||
PropertyValue::FileRef(r) => write!(f, "{{fileID: {}}}", r.file_id),
|
||||
PropertyValue::ExternalRef(r) => write!(f, "{{guid: {}, type: {}}}", r.guid, r.type_id),
|
||||
PropertyValue::Array(arr) => {
|
||||
write!(f, "[")?;
|
||||
for (i, item) in arr.iter().enumerate() {
|
||||
if i > 0 {
|
||||
write!(f, ", ")?;
|
||||
}
|
||||
write!(f, "{}", item)?;
|
||||
}
|
||||
write!(f, "]")
|
||||
}
|
||||
PropertyValue::Object(obj) => {
|
||||
write!(f, "{{")?;
|
||||
for (i, (k, v)) in obj.iter().enumerate() {
|
||||
if i > 0 {
|
||||
write!(f, ", ")?;
|
||||
}
|
||||
write!(f, "{}: {}", k, v)?;
|
||||
}
|
||||
write!(f, "}}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a serde_yaml::Value to a PropertyValue
|
||||
///
|
||||
/// This function recognizes Unity-specific patterns in YAML mappings:
|
||||
/// - `{fileID: N}` → `PropertyValue::FileRef`
|
||||
/// - `{x, y, z}` → `PropertyValue::Vector3`
|
||||
/// - `{x, y}` → `PropertyValue::Vector2`
|
||||
/// - `{r, g, b, a}` → `PropertyValue::Color`
|
||||
/// - `{x, y, z, w}` → `PropertyValue::Quaternion`
|
||||
/// - `{guid, type}` → `PropertyValue::ExternalRef`
|
||||
pub fn convert_yaml_value(value: &serde_yaml::Value) -> crate::Result<PropertyValue> {
|
||||
match value {
|
||||
serde_yaml::Value::Null => Ok(PropertyValue::Null),
|
||||
serde_yaml::Value::Bool(b) => Ok(PropertyValue::Boolean(*b)),
|
||||
serde_yaml::Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Ok(PropertyValue::Integer(i))
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
Ok(PropertyValue::Float(f))
|
||||
} else {
|
||||
Err(Error::invalid_format(format!(
|
||||
"Unsupported number format: {}",
|
||||
n
|
||||
)))
|
||||
}
|
||||
}
|
||||
serde_yaml::Value::String(s) => Ok(PropertyValue::String(s.clone())),
|
||||
serde_yaml::Value::Sequence(seq) => {
|
||||
let mut array = Vec::with_capacity(seq.len());
|
||||
for item in seq {
|
||||
array.push(convert_yaml_value(item)?);
|
||||
}
|
||||
Ok(PropertyValue::Array(array))
|
||||
}
|
||||
serde_yaml::Value::Mapping(map) => {
|
||||
// Check for Unity-specific patterns
|
||||
if let Some(unity_type) = try_convert_unity_type(map)? {
|
||||
Ok(unity_type)
|
||||
} else {
|
||||
// Convert to generic object
|
||||
let mut object = IndexMap::new();
|
||||
for (k, v) in map {
|
||||
if let Some(key) = k.as_str() {
|
||||
object.insert(key.to_string(), convert_yaml_value(v)?);
|
||||
}
|
||||
}
|
||||
Ok(PropertyValue::Object(object))
|
||||
}
|
||||
}
|
||||
_ => Err(Error::invalid_format(format!(
|
||||
"Unsupported YAML value type: {:?}",
|
||||
value
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to convert a YAML mapping to a Unity-specific type
|
||||
fn try_convert_unity_type(
|
||||
map: &serde_yaml::Mapping,
|
||||
) -> crate::Result<Option<PropertyValue>> {
|
||||
// Helper to get a float from a mapping
|
||||
let get_f32 = |key: &str| -> Option<f32> {
|
||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|f| f as f32)
|
||||
};
|
||||
|
||||
// Helper to get an i64 from a mapping
|
||||
let get_i64 = |key: &str| -> Option<i64> {
|
||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_i64())
|
||||
};
|
||||
|
||||
// Helper to get a string from a mapping
|
||||
let get_string = |key: &str| -> Option<String> {
|
||||
map.get(&serde_yaml::Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
};
|
||||
|
||||
// Check for {fileID: N} pattern
|
||||
if map.len() == 1 && map.contains_key(&serde_yaml::Value::String("fileID".to_string())) {
|
||||
if let Some(file_id) = get_i64("fileID") {
|
||||
return Ok(Some(PropertyValue::FileRef(FileRef::new(
|
||||
FileID::from_i64(file_id),
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for {guid: ..., type: N} pattern
|
||||
if map.len() == 2
|
||||
&& map.contains_key(&serde_yaml::Value::String("guid".to_string()))
|
||||
&& map.contains_key(&serde_yaml::Value::String("type".to_string()))
|
||||
{
|
||||
if let (Some(guid), Some(type_id)) = (get_string("guid"), get_i64("type")) {
|
||||
return Ok(Some(PropertyValue::ExternalRef(ExternalRef::new(
|
||||
guid,
|
||||
type_id as i32,
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for {r, g, b, a} pattern (Color)
|
||||
if map.len() == 4
|
||||
&& map.contains_key(&serde_yaml::Value::String("r".to_string()))
|
||||
&& map.contains_key(&serde_yaml::Value::String("g".to_string()))
|
||||
&& map.contains_key(&serde_yaml::Value::String("b".to_string()))
|
||||
&& map.contains_key(&serde_yaml::Value::String("a".to_string()))
|
||||
{
|
||||
if let (Some(r), Some(g), Some(b), Some(a)) =
|
||||
(get_f32("r"), get_f32("g"), get_f32("b"), get_f32("a"))
|
||||
{
|
||||
// Color is Vec4 where (r,g,b,a) maps to (x,y,z,w)
|
||||
return Ok(Some(PropertyValue::Color(Color::new(r, g, b, a))));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for {x, y, z, w} pattern (Quaternion)
|
||||
if map.len() == 4
|
||||
&& map.contains_key(&serde_yaml::Value::String("x".to_string()))
|
||||
&& map.contains_key(&serde_yaml::Value::String("y".to_string()))
|
||||
&& map.contains_key(&serde_yaml::Value::String("z".to_string()))
|
||||
&& map.contains_key(&serde_yaml::Value::String("w".to_string()))
|
||||
{
|
||||
if let (Some(x), Some(y), Some(z), Some(w)) =
|
||||
(get_f32("x"), get_f32("y"), get_f32("z"), get_f32("w"))
|
||||
{
|
||||
// Quaternion uses from_xyzw constructor
|
||||
return Ok(Some(PropertyValue::Quaternion(Quaternion::from_xyzw(
|
||||
x, y, z, w,
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for {x, y, z} pattern (Vector3)
|
||||
if map.len() == 3
|
||||
&& map.contains_key(&serde_yaml::Value::String("x".to_string()))
|
||||
&& map.contains_key(&serde_yaml::Value::String("y".to_string()))
|
||||
&& map.contains_key(&serde_yaml::Value::String("z".to_string()))
|
||||
{
|
||||
if let (Some(x), Some(y), Some(z)) = (get_f32("x"), get_f32("y"), get_f32("z")) {
|
||||
return Ok(Some(PropertyValue::Vector3(Vector3::new(x, y, z))));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for {x, y} pattern (Vector2)
|
||||
if map.len() == 2
|
||||
&& map.contains_key(&serde_yaml::Value::String("x".to_string()))
|
||||
&& map.contains_key(&serde_yaml::Value::String("y".to_string()))
|
||||
{
|
||||
if let (Some(x), Some(y)) = (get_f32("x"), get_f32("y")) {
|
||||
return Ok(Some(PropertyValue::Vector2(Vector2::new(x, y))));
|
||||
}
|
||||
}
|
||||
|
||||
// Not a Unity-specific type
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_yaml::Value;
|
||||
|
||||
#[test]
|
||||
fn test_convert_primitives() {
|
||||
assert_eq!(
|
||||
convert_yaml_value(&Value::Null).unwrap(),
|
||||
PropertyValue::Null
|
||||
);
|
||||
assert_eq!(
|
||||
convert_yaml_value(&Value::Bool(true)).unwrap(),
|
||||
PropertyValue::Boolean(true)
|
||||
);
|
||||
assert_eq!(
|
||||
convert_yaml_value(&Value::Number(42.into())).unwrap(),
|
||||
PropertyValue::Integer(42)
|
||||
);
|
||||
assert_eq!(
|
||||
convert_yaml_value(&Value::String("test".to_string())).unwrap(),
|
||||
PropertyValue::String("test".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_vector3() {
|
||||
let yaml = serde_yaml::from_str("{ x: 1.0, y: 2.0, z: 3.0 }").unwrap();
|
||||
let result = convert_yaml_value(&yaml).unwrap();
|
||||
|
||||
if let PropertyValue::Vector3(v) = result {
|
||||
assert_eq!(v.x, 1.0);
|
||||
assert_eq!(v.y, 2.0);
|
||||
assert_eq!(v.z, 3.0);
|
||||
} else {
|
||||
panic!("Expected Vector3, got {:?}", result);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_vector2() {
|
||||
let yaml = serde_yaml::from_str("{ x: 1.0, y: 2.0 }").unwrap();
|
||||
let result = convert_yaml_value(&yaml).unwrap();
|
||||
|
||||
if let PropertyValue::Vector2(v) = result {
|
||||
assert_eq!(v.x, 1.0);
|
||||
assert_eq!(v.y, 2.0);
|
||||
} else {
|
||||
panic!("Expected Vector2, got {:?}", result);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_color() {
|
||||
let yaml = serde_yaml::from_str("{ r: 1.0, g: 0.5, b: 0.0, a: 1.0 }").unwrap();
|
||||
let result = convert_yaml_value(&yaml).unwrap();
|
||||
|
||||
if let PropertyValue::Color(c) = result {
|
||||
assert_eq!(c.x, 1.0); // r
|
||||
assert_eq!(c.y, 0.5); // g
|
||||
assert_eq!(c.z, 0.0); // b
|
||||
assert_eq!(c.w, 1.0); // a
|
||||
} else {
|
||||
panic!("Expected Color, got {:?}", result);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_quaternion() {
|
||||
let yaml = serde_yaml::from_str("{ x: 0.0, y: 0.0, z: 0.0, w: 1.0 }").unwrap();
|
||||
let result = convert_yaml_value(&yaml).unwrap();
|
||||
|
||||
if let PropertyValue::Quaternion(q) = result {
|
||||
assert_eq!(q.x, 0.0);
|
||||
assert_eq!(q.w, 1.0);
|
||||
} else {
|
||||
panic!("Expected Quaternion, got {:?}", result);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_file_ref() {
|
||||
let yaml = serde_yaml::from_str("{ fileID: 12345 }").unwrap();
|
||||
let result = convert_yaml_value(&yaml).unwrap();
|
||||
|
||||
if let PropertyValue::FileRef(r) = result {
|
||||
assert_eq!(r.file_id.as_i64(), 12345);
|
||||
} else {
|
||||
panic!("Expected FileRef, got {:?}", result);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_external_ref() {
|
||||
let yaml = serde_yaml::from_str("{ guid: abc123, type: 2 }").unwrap();
|
||||
let result = convert_yaml_value(&yaml).unwrap();
|
||||
|
||||
if let PropertyValue::ExternalRef(r) = result {
|
||||
assert_eq!(r.guid, "abc123");
|
||||
assert_eq!(r.type_id, 2);
|
||||
} else {
|
||||
panic!("Expected ExternalRef, got {:?}", result);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_array() {
|
||||
let yaml = serde_yaml::from_str("[1, 2, 3]").unwrap();
|
||||
let result = convert_yaml_value(&yaml).unwrap();
|
||||
|
||||
if let PropertyValue::Array(arr) = result {
|
||||
assert_eq!(arr.len(), 3);
|
||||
assert_eq!(arr[0].as_i64(), Some(1));
|
||||
assert_eq!(arr[1].as_i64(), Some(2));
|
||||
assert_eq!(arr[2].as_i64(), Some(3));
|
||||
} else {
|
||||
panic!("Expected Array, got {:?}", result);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_object() {
|
||||
let yaml = serde_yaml::from_str("{ name: Test, value: 42 }").unwrap();
|
||||
let result = convert_yaml_value(&yaml).unwrap();
|
||||
|
||||
if let PropertyValue::Object(obj) = result {
|
||||
assert_eq!(obj.get("name").and_then(|v| v.as_str()), Some("Test"));
|
||||
assert_eq!(obj.get("value").and_then(|v| v.as_i64()), Some(42));
|
||||
} else {
|
||||
panic!("Expected Object, got {:?}", result);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accessors() {
|
||||
let val = PropertyValue::Integer(42);
|
||||
assert_eq!(val.as_i64(), Some(42));
|
||||
assert_eq!(val.as_f64(), Some(42.0));
|
||||
assert_eq!(val.as_str(), None);
|
||||
|
||||
let val = PropertyValue::String("test".to_string());
|
||||
assert_eq!(val.as_str(), Some("test"));
|
||||
assert_eq!(val.as_i64(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_checks() {
|
||||
assert!(PropertyValue::Null.is_null());
|
||||
assert!(PropertyValue::Array(vec![]).is_array());
|
||||
assert!(PropertyValue::Vector3(Vector3::ZERO).is_vector3());
|
||||
assert!(PropertyValue::Color(Color::ONE).is_color());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display() {
|
||||
assert_eq!(format!("{}", PropertyValue::Integer(42)), "42");
|
||||
assert_eq!(format!("{}", PropertyValue::String("test".to_string())), "\"test\"");
|
||||
assert_eq!(format!("{}", PropertyValue::Vector3(Vector3::new(1.0, 2.0, 3.0))), "(1, 2, 3)");
|
||||
}
|
||||
}
|
||||
268
unity-parser/src/types/component.rs
Normal file
268
unity-parser/src/types/component.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
//! Component trait and YAML parsing helpers
|
||||
|
||||
use crate::types::*;
|
||||
use serde_yaml::{Mapping, Value};
|
||||
use sparsey::world::WorldBuilder;
|
||||
use sparsey::Entity;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A callback for deferred linking after all components are parsed
|
||||
pub type LinkCallback = Box<dyn FnOnce(&mut sparsey::World, &HashMap<FileID, Entity>) + 'static>;
|
||||
|
||||
/// Context for managing entity linking during world building
|
||||
pub struct LinkingContext {
|
||||
/// Map from FileID to Entity
|
||||
entity_map: HashMap<FileID, Entity>,
|
||||
/// Callbacks to execute after all components are parsed
|
||||
callbacks: Vec<LinkCallback>,
|
||||
}
|
||||
|
||||
impl LinkingContext {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entity_map: HashMap::new(),
|
||||
callbacks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to resolve a FileID to an Entity
|
||||
pub fn resolve_entity(&self, file_id: FileID) -> Option<Entity> {
|
||||
self.entity_map.get(&file_id).copied()
|
||||
}
|
||||
|
||||
/// Register a callback to be executed during the linking pass
|
||||
pub fn register_callback(&mut self, callback: LinkCallback) {
|
||||
self.callbacks.push(callback);
|
||||
}
|
||||
|
||||
/// Get the entity map
|
||||
pub fn entity_map(&self) -> &HashMap<FileID, Entity> {
|
||||
&self.entity_map
|
||||
}
|
||||
|
||||
/// Get mutable access to the entity map
|
||||
pub fn entity_map_mut(&mut self) -> &mut HashMap<FileID, Entity> {
|
||||
&mut self.entity_map
|
||||
}
|
||||
|
||||
/// Execute all registered callbacks
|
||||
pub fn execute_callbacks(self, world: &mut sparsey::World) -> HashMap<FileID, Entity> {
|
||||
let entity_map = self.entity_map.clone();
|
||||
for callback in self.callbacks {
|
||||
callback(world, &entity_map);
|
||||
}
|
||||
entity_map
|
||||
}
|
||||
}
|
||||
|
||||
/// Context information for parsing components from YAML
|
||||
pub struct ComponentContext<'a> {
|
||||
/// Unity type ID (from !u!N tag)
|
||||
pub type_id: u32,
|
||||
/// File ID (from &ID anchor)
|
||||
pub file_id: FileID,
|
||||
/// Class name (e.g., "GameObject", "Transform")
|
||||
pub class_name: &'a str,
|
||||
/// Entity that owns this component
|
||||
pub entity: Option<Entity>,
|
||||
/// Linking context for deferred entity resolution (wrapped in RefCell for interior mutability)
|
||||
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
|
||||
pub trait UnityComponent: Sized {
|
||||
/// Parse a component directly from YAML
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `yaml`: The YAML mapping containing component data (already unwrapped from class name)
|
||||
/// - `ctx`: Context with metadata (type_id, file_id, class_name)
|
||||
///
|
||||
/// # Returns
|
||||
/// Some(component) if parsing succeeds, None if this YAML doesn't represent this component type
|
||||
fn parse(yaml: &Mapping, ctx: &ComponentContext) -> Option<Self>;
|
||||
}
|
||||
|
||||
/// Trait for components that can be inserted into the ECS world
|
||||
///
|
||||
/// This enables dynamic component insertion for both built-in and custom components.
|
||||
pub trait EcsInsertable: UnityComponent {
|
||||
/// Insert this component into the ECS world
|
||||
fn insert_into_world(self, world: &mut sparsey::World, entity: Entity);
|
||||
|
||||
/// Parse and insert in one step
|
||||
fn parse_and_insert(
|
||||
yaml: &Mapping,
|
||||
ctx: &ComponentContext,
|
||||
world: &mut sparsey::World,
|
||||
entity: Entity,
|
||||
) -> bool {
|
||||
if let Some(component) = Self::parse(yaml, ctx) {
|
||||
component.insert_into_world(world, entity);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registration entry for custom Unity components
|
||||
///
|
||||
/// This is submitted via the `inventory` crate by the `#[derive(UnityComponent)]` macro
|
||||
/// to enable automatic component discovery and parsing.
|
||||
pub struct ComponentRegistration {
|
||||
/// Unity type ID (usually 114 for MonoBehaviour)
|
||||
pub type_id: u32,
|
||||
/// Unity class name (e.g., "PlaySFX")
|
||||
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
|
||||
inventory::collect!(ComponentRegistration);
|
||||
|
||||
/// Helper functions for parsing typed values from YAML mappings
|
||||
pub mod yaml_helpers {
|
||||
use super::*;
|
||||
|
||||
/// Get a string value from a YAML mapping
|
||||
pub fn get_string(map: &Mapping, key: &str) -> Option<String> {
|
||||
map.get(&Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// Get an i64 value from a YAML mapping
|
||||
pub fn get_i64(map: &Mapping, key: &str) -> Option<i64> {
|
||||
map.get(&Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_i64())
|
||||
}
|
||||
|
||||
/// Get an f64 value from a YAML mapping
|
||||
pub fn get_f64(map: &Mapping, key: &str) -> Option<f64> {
|
||||
map.get(&Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_f64())
|
||||
}
|
||||
|
||||
/// Get a bool value from a YAML mapping
|
||||
pub fn get_bool(map: &Mapping, key: &str) -> Option<bool> {
|
||||
map.get(&Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_bool())
|
||||
.or_else(|| {
|
||||
// Unity sometimes uses 0/1 for booleans
|
||||
map.get(&Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(|i| i != 0)
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a Vector2 from a YAML mapping with x, y fields
|
||||
pub fn get_vector2(map: &Mapping, key: &str) -> Option<Vector2> {
|
||||
let obj = map
|
||||
.get(&Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_mapping())?;
|
||||
|
||||
let x = obj.get(&Value::String("x".to_string()))?.as_f64()? as f32;
|
||||
let y = obj.get(&Value::String("y".to_string()))?.as_f64()? as f32;
|
||||
|
||||
Some(Vector2::new(x, y))
|
||||
}
|
||||
|
||||
/// Parse a Vector3 from a YAML mapping with x, y, z fields
|
||||
pub fn get_vector3(map: &Mapping, key: &str) -> Option<Vector3> {
|
||||
let obj = map
|
||||
.get(&Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_mapping())?;
|
||||
|
||||
let x = obj.get(&Value::String("x".to_string()))?.as_f64()? as f32;
|
||||
let y = obj.get(&Value::String("y".to_string()))?.as_f64()? as f32;
|
||||
let z = obj.get(&Value::String("z".to_string()))?.as_f64()? as f32;
|
||||
|
||||
Some(Vector3::new(x, y, z))
|
||||
}
|
||||
|
||||
/// Parse a Quaternion from a YAML mapping with x, y, z, w fields
|
||||
pub fn get_quaternion(map: &Mapping, key: &str) -> Option<Quaternion> {
|
||||
let obj = map
|
||||
.get(&Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_mapping())?;
|
||||
|
||||
let x = obj.get(&Value::String("x".to_string()))?.as_f64()? as f32;
|
||||
let y = obj.get(&Value::String("y".to_string()))?.as_f64()? as f32;
|
||||
let z = obj.get(&Value::String("z".to_string()))?.as_f64()? as f32;
|
||||
let w = obj.get(&Value::String("w".to_string()))?.as_f64()? as f32;
|
||||
|
||||
Some(Quaternion::from_xyzw(x, y, z, w))
|
||||
}
|
||||
|
||||
/// Parse a Color from a YAML mapping with r, g, b, a fields
|
||||
pub fn get_color(map: &Mapping, key: &str) -> Option<Color> {
|
||||
let obj = map
|
||||
.get(&Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_mapping())?;
|
||||
|
||||
let r = obj.get(&Value::String("r".to_string()))?.as_f64()? as f32;
|
||||
let g = obj.get(&Value::String("g".to_string()))?.as_f64()? as f32;
|
||||
let b = obj.get(&Value::String("b".to_string()))?.as_f64()? as f32;
|
||||
let a = obj.get(&Value::String("a".to_string()))?.as_f64()? as f32;
|
||||
|
||||
Some(Color::new(r, g, b, a))
|
||||
}
|
||||
|
||||
/// Parse a FileRef from a YAML mapping with fileID field
|
||||
pub fn get_file_ref(map: &Mapping, key: &str) -> Option<FileRef> {
|
||||
let obj = map
|
||||
.get(&Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_mapping())?;
|
||||
|
||||
get_file_ref_from_mapping(obj)
|
||||
}
|
||||
|
||||
/// Parse a FileRef directly from a mapping (used for m_GameObject, m_Father, etc.)
|
||||
pub fn get_file_ref_from_mapping(map: &Mapping) -> Option<FileRef> {
|
||||
let file_id = map
|
||||
.get(&Value::String("fileID".to_string()))
|
||||
.and_then(|v| v.as_i64())?;
|
||||
|
||||
Some(FileRef::new(FileID::from_i64(file_id)))
|
||||
}
|
||||
|
||||
/// Parse an ExternalRef from a YAML mapping with guid and type fields
|
||||
pub fn get_external_ref(map: &Mapping, key: &str) -> Option<ExternalRef> {
|
||||
let obj = map
|
||||
.get(&Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_mapping())?;
|
||||
|
||||
let guid = obj
|
||||
.get(&Value::String("guid".to_string()))
|
||||
.and_then(|v| v.as_str())?
|
||||
.to_string();
|
||||
|
||||
let type_id = obj
|
||||
.get(&Value::String("type".to_string()))
|
||||
.and_then(|v| v.as_i64())? as i32;
|
||||
|
||||
Some(ExternalRef::new(guid, type_id))
|
||||
}
|
||||
|
||||
/// Parse an array of FileRefs (used for m_Children, m_Components, etc.)
|
||||
pub fn get_file_ref_array(map: &Mapping, key: &str) -> Option<Vec<FileRef>> {
|
||||
let array = map
|
||||
.get(&Value::String(key.to_string()))
|
||||
.and_then(|v| v.as_sequence())?;
|
||||
|
||||
array
|
||||
.iter()
|
||||
.filter_map(|v| v.as_mapping())
|
||||
.filter_map(|m| get_file_ref_from_mapping(m))
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
}
|
||||
}
|
||||
255
unity-parser/src/types/guid.rs
Normal file
255
unity-parser/src/types/guid.rs
Normal 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 cursebreaker_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 cursebreaker_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 cursebreaker_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 cursebreaker_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 cursebreaker_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);
|
||||
}
|
||||
}
|
||||
131
unity-parser/src/types/ids.rs
Normal file
131
unity-parser/src/types/ids.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! Unity ID types
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// A Unity file ID, used to reference Unity objects within a file
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::FileID;
|
||||
///
|
||||
/// let file_id = FileID::from_i64(1866116814460599870);
|
||||
/// assert_eq!(file_id.as_i64(), 1866116814460599870);
|
||||
/// ```
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct FileID(i64);
|
||||
|
||||
impl FileID {
|
||||
/// Create a FileID from an i64
|
||||
pub fn from_i64(id: i64) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
/// Get the underlying i64 value
|
||||
pub fn as_i64(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FileID {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for FileID {
|
||||
fn from(id: i64) -> Self {
|
||||
Self::from_i64(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FileID> for i64 {
|
||||
fn from(file_id: FileID) -> Self {
|
||||
file_id.as_i64()
|
||||
}
|
||||
}
|
||||
|
||||
/// A local ID for objects within a Unity file
|
||||
///
|
||||
/// This is currently an alias for FileID but may have different
|
||||
/// semantics in future versions.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct LocalID(i64);
|
||||
|
||||
impl LocalID {
|
||||
/// Create a LocalID from an i64
|
||||
pub fn from_i64(id: i64) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
/// Get the underlying i64 value
|
||||
pub fn as_i64(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for LocalID {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for LocalID {
|
||||
fn from(id: i64) -> Self {
|
||||
Self::from_i64(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LocalID> for i64 {
|
||||
fn from(local_id: LocalID) -> Self {
|
||||
local_id.as_i64()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_file_id_creation() {
|
||||
let file_id = FileID::from_i64(12345);
|
||||
assert_eq!(file_id.as_i64(), 12345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_id_display() {
|
||||
let file_id = FileID::from_i64(1866116814460599870);
|
||||
assert_eq!(format!("{}", file_id), "1866116814460599870");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_id_equality() {
|
||||
let id1 = FileID::from_i64(12345);
|
||||
let id2 = FileID::from_i64(12345);
|
||||
let id3 = FileID::from_i64(67890);
|
||||
|
||||
assert_eq!(id1, id2);
|
||||
assert_ne!(id1, id3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_id_conversion() {
|
||||
let id: FileID = 12345.into();
|
||||
assert_eq!(id.as_i64(), 12345);
|
||||
|
||||
let value: i64 = id.into();
|
||||
assert_eq!(value, 12345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_id_creation() {
|
||||
let local_id = LocalID::from_i64(12345);
|
||||
assert_eq!(local_id.as_i64(), 12345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_id_display() {
|
||||
let local_id = LocalID::from_i64(67890);
|
||||
assert_eq!(format!("{}", local_id), "67890");
|
||||
}
|
||||
}
|
||||
29
unity-parser/src/types/mod.rs
Normal file
29
unity-parser/src/types/mod.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//! Unity-specific types and wrappers
|
||||
//!
|
||||
//! This module provides type-safe representations of Unity types,
|
||||
//! including IDs, value types (Vector3, Color, etc.), and high-level
|
||||
//! wrappers for GameObjects and Components.
|
||||
|
||||
mod component;
|
||||
mod guid;
|
||||
mod ids;
|
||||
mod reference;
|
||||
mod type_filter;
|
||||
mod type_registry;
|
||||
mod unity_types;
|
||||
mod values;
|
||||
|
||||
pub use component::{
|
||||
yaml_helpers, ComponentContext, ComponentRegistration, EcsInsertable, LinkCallback,
|
||||
LinkingContext, UnityComponent,
|
||||
};
|
||||
pub use guid::Guid;
|
||||
pub use ids::{FileID, LocalID};
|
||||
pub use reference::UnityReference;
|
||||
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};
|
||||
291
unity-parser/src/types/reference.rs
Normal file
291
unity-parser/src/types/reference.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
//! Unified reference type for Unity object references
|
||||
//!
|
||||
//! This module provides the UnityReference enum which abstracts over
|
||||
//! local references (FileRef) and external references (ExternalRef).
|
||||
|
||||
use crate::types::{ExternalRef, FileID, FileRef};
|
||||
|
||||
/// A unified reference type that can be local, external, or null
|
||||
///
|
||||
/// Unity uses different reference patterns:
|
||||
/// - Local: `{fileID: N}` - references an object in the same file
|
||||
/// - External: `{fileID: M, guid: XXXX, type: N}` - references an external asset
|
||||
/// - Null: `{fileID: 0}` - null/empty reference
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum UnityReference {
|
||||
/// Reference to an object in the same file by FileID
|
||||
Local(FileID),
|
||||
|
||||
/// Reference to an external asset by GUID
|
||||
External {
|
||||
/// The GUID of the external asset
|
||||
guid: String,
|
||||
/// The Unity type ID
|
||||
type_id: i32,
|
||||
/// Optional resolved FileID within the external file
|
||||
file_id: Option<FileID>,
|
||||
},
|
||||
|
||||
/// Null reference (fileID: 0)
|
||||
Null,
|
||||
}
|
||||
|
||||
impl UnityReference {
|
||||
/// Create a UnityReference from a FileRef
|
||||
///
|
||||
/// FileID of 0 is treated as a null reference.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::types::{UnityReference, FileRef, FileID};
|
||||
///
|
||||
/// // Local reference
|
||||
/// let file_ref = FileRef::new(FileID::from_i64(12345));
|
||||
/// let reference = UnityReference::from_file_ref(&file_ref);
|
||||
/// assert!(reference.is_local());
|
||||
///
|
||||
/// // Null reference
|
||||
/// let null_ref = FileRef::new(FileID::from_i64(0));
|
||||
/// let reference = UnityReference::from_file_ref(&null_ref);
|
||||
/// assert!(reference.is_null());
|
||||
/// ```
|
||||
pub fn from_file_ref(file_ref: &FileRef) -> Self {
|
||||
if file_ref.file_id.as_i64() == 0 {
|
||||
UnityReference::Null
|
||||
} else {
|
||||
UnityReference::Local(file_ref.file_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a UnityReference from an ExternalRef
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::types::{UnityReference, ExternalRef};
|
||||
///
|
||||
/// let ext_ref = ExternalRef::new("abc123".to_string(), 2);
|
||||
/// let reference = UnityReference::from_external_ref(&ext_ref);
|
||||
/// assert!(reference.is_external());
|
||||
/// ```
|
||||
pub fn from_external_ref(ext_ref: &ExternalRef) -> Self {
|
||||
UnityReference::External {
|
||||
guid: ext_ref.guid.clone(),
|
||||
type_id: ext_ref.type_id,
|
||||
file_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a null reference
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::types::UnityReference;
|
||||
///
|
||||
/// let reference = UnityReference::Null;
|
||||
/// assert!(reference.is_null());
|
||||
/// ```
|
||||
pub fn is_null(&self) -> bool {
|
||||
matches!(self, UnityReference::Null)
|
||||
}
|
||||
|
||||
/// Check if this is a local reference
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::types::{UnityReference, FileID};
|
||||
///
|
||||
/// let reference = UnityReference::Local(FileID::from_i64(12345));
|
||||
/// assert!(reference.is_local());
|
||||
/// ```
|
||||
pub fn is_local(&self) -> bool {
|
||||
matches!(self, UnityReference::Local(_))
|
||||
}
|
||||
|
||||
/// Check if this is an external reference
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::types::UnityReference;
|
||||
///
|
||||
/// let reference = UnityReference::External {
|
||||
/// guid: "abc123".to_string(),
|
||||
/// type_id: 2,
|
||||
/// file_id: None,
|
||||
/// };
|
||||
/// assert!(reference.is_external());
|
||||
/// ```
|
||||
pub fn is_external(&self) -> bool {
|
||||
matches!(self, UnityReference::External { .. })
|
||||
}
|
||||
|
||||
/// Get the FileID for local references
|
||||
///
|
||||
/// Returns None for external or null references.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::types::{UnityReference, FileID};
|
||||
///
|
||||
/// let reference = UnityReference::Local(FileID::from_i64(12345));
|
||||
/// assert_eq!(reference.as_file_id(), Some(FileID::from_i64(12345)));
|
||||
///
|
||||
/// let null_ref = UnityReference::Null;
|
||||
/// assert_eq!(null_ref.as_file_id(), None);
|
||||
/// ```
|
||||
pub fn as_file_id(&self) -> Option<FileID> {
|
||||
match self {
|
||||
UnityReference::Local(file_id) => Some(*file_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the GUID for external references
|
||||
///
|
||||
/// Returns None for local or null references.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::types::UnityReference;
|
||||
///
|
||||
/// let reference = UnityReference::External {
|
||||
/// guid: "abc123".to_string(),
|
||||
/// type_id: 2,
|
||||
/// file_id: None,
|
||||
/// };
|
||||
/// assert_eq!(reference.as_guid(), Some("abc123"));
|
||||
/// ```
|
||||
pub fn as_guid(&self) -> Option<&str> {
|
||||
match self {
|
||||
UnityReference::External { guid, .. } => Some(guid),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_from_file_ref_local() {
|
||||
let file_ref = FileRef::new(FileID::from_i64(12345));
|
||||
let reference = UnityReference::from_file_ref(&file_ref);
|
||||
assert!(reference.is_local());
|
||||
assert_eq!(reference.as_file_id(), Some(FileID::from_i64(12345)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_file_ref_null() {
|
||||
let file_ref = FileRef::new(FileID::from_i64(0));
|
||||
let reference = UnityReference::from_file_ref(&file_ref);
|
||||
assert!(reference.is_null());
|
||||
assert!(!reference.is_local());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_external_ref() {
|
||||
let ext_ref = ExternalRef::new("abc123".to_string(), 2);
|
||||
let reference = UnityReference::from_external_ref(&ext_ref);
|
||||
assert!(reference.is_external());
|
||||
assert_eq!(reference.as_guid(), Some("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_null_reference() {
|
||||
let reference = UnityReference::Null;
|
||||
assert!(reference.is_null());
|
||||
assert!(!reference.is_local());
|
||||
assert!(!reference.is_external());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_reference() {
|
||||
let reference = UnityReference::Local(FileID::from_i64(999));
|
||||
assert!(reference.is_local());
|
||||
assert!(!reference.is_null());
|
||||
assert!(!reference.is_external());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_external_reference() {
|
||||
let reference = UnityReference::External {
|
||||
guid: "test-guid".to_string(),
|
||||
type_id: 3,
|
||||
file_id: None,
|
||||
};
|
||||
assert!(reference.is_external());
|
||||
assert!(!reference.is_null());
|
||||
assert!(!reference.is_local());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_equality() {
|
||||
let ref1 = UnityReference::Local(FileID::from_i64(123));
|
||||
let ref2 = UnityReference::Local(FileID::from_i64(123));
|
||||
let ref3 = UnityReference::Local(FileID::from_i64(456));
|
||||
|
||||
assert_eq!(ref1, ref2);
|
||||
assert_ne!(ref1, ref3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_external_equality() {
|
||||
let ref1 = UnityReference::External {
|
||||
guid: "abc".to_string(),
|
||||
type_id: 2,
|
||||
file_id: None,
|
||||
};
|
||||
let ref2 = UnityReference::External {
|
||||
guid: "abc".to_string(),
|
||||
type_id: 2,
|
||||
file_id: None,
|
||||
};
|
||||
let ref3 = UnityReference::External {
|
||||
guid: "xyz".to_string(),
|
||||
type_id: 2,
|
||||
file_id: None,
|
||||
};
|
||||
|
||||
assert_eq!(ref1, ref2);
|
||||
assert_ne!(ref1, ref3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_as_file_id() {
|
||||
let local = UnityReference::Local(FileID::from_i64(12345));
|
||||
assert_eq!(local.as_file_id(), Some(FileID::from_i64(12345)));
|
||||
|
||||
let null = UnityReference::Null;
|
||||
assert_eq!(null.as_file_id(), None);
|
||||
|
||||
let external = UnityReference::External {
|
||||
guid: "test".to_string(),
|
||||
type_id: 1,
|
||||
file_id: None,
|
||||
};
|
||||
assert_eq!(external.as_file_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_as_guid() {
|
||||
let external = UnityReference::External {
|
||||
guid: "test-guid".to_string(),
|
||||
type_id: 1,
|
||||
file_id: None,
|
||||
};
|
||||
assert_eq!(external.as_guid(), Some("test-guid"));
|
||||
|
||||
let local = UnityReference::Local(FileID::from_i64(123));
|
||||
assert_eq!(local.as_guid(), None);
|
||||
|
||||
let null = UnityReference::Null;
|
||||
assert_eq!(null.as_guid(), None);
|
||||
}
|
||||
}
|
||||
326
unity-parser/src/types/type_filter.rs
Normal file
326
unity-parser/src/types/type_filter.rs
Normal 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 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()),
|
||||
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());
|
||||
}
|
||||
}
|
||||
354
unity-parser/src/types/type_registry.rs
Normal file
354
unity-parser/src/types/type_registry.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
//! Unity type ID to class name mapping
|
||||
//!
|
||||
//! This module provides a centralized registry for mapping Unity type IDs
|
||||
//! to their corresponding class names and vice versa.
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Unity type ID to class name registry
|
||||
pub struct UnityTypeRegistry {
|
||||
type_to_name: HashMap<u32, &'static str>,
|
||||
name_to_type: HashMap<&'static str, u32>,
|
||||
}
|
||||
|
||||
impl UnityTypeRegistry {
|
||||
/// Create a new empty registry
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
type_to_name: HashMap::new(),
|
||||
name_to_type: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a Unity type ID with its class name
|
||||
fn register(&mut self, type_id: u32, class_name: &'static str) {
|
||||
self.type_to_name.insert(type_id, class_name);
|
||||
self.name_to_type.insert(class_name, type_id);
|
||||
}
|
||||
|
||||
/// Get the class name for a Unity type ID
|
||||
pub fn get_class_name(&self, type_id: u32) -> Option<&'static str> {
|
||||
self.type_to_name.get(&type_id).copied()
|
||||
}
|
||||
|
||||
/// Get the type ID for a Unity class name
|
||||
pub fn get_type_id(&self, class_name: &str) -> Option<u32> {
|
||||
self.name_to_type.get(class_name).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Global Unity type registry (lazily initialized)
|
||||
pub static UNITY_TYPE_REGISTRY: Lazy<UnityTypeRegistry> = Lazy::new(|| {
|
||||
let mut registry = UnityTypeRegistry::new();
|
||||
|
||||
// Core Unity types
|
||||
registry.register(1, "GameObject");
|
||||
registry.register(2, "Component");
|
||||
registry.register(3, "LevelGameManager");
|
||||
registry.register(4, "Transform");
|
||||
registry.register(5, "TimeManager");
|
||||
registry.register(8, "Behaviour");
|
||||
registry.register(9, "GameManager");
|
||||
registry.register(11, "AudioManager");
|
||||
registry.register(13, "InputManager");
|
||||
registry.register(18, "EditorExtension");
|
||||
registry.register(19, "Physics2DSettings");
|
||||
registry.register(20, "Camera");
|
||||
registry.register(21, "Material");
|
||||
registry.register(23, "MeshRenderer");
|
||||
registry.register(25, "Renderer");
|
||||
registry.register(27, "Texture");
|
||||
registry.register(28, "Texture2D");
|
||||
registry.register(29, "OcclusionCullingSettings");
|
||||
registry.register(33, "MeshFilter");
|
||||
registry.register(41, "OcclusionPortal");
|
||||
registry.register(43, "Mesh");
|
||||
registry.register(45, "Skybox");
|
||||
registry.register(47, "QualitySettings");
|
||||
registry.register(48, "Shader");
|
||||
registry.register(49, "TextAsset");
|
||||
registry.register(50, "Rigidbody2D");
|
||||
registry.register(53, "Collider2D");
|
||||
registry.register(54, "Rigidbody");
|
||||
registry.register(55, "PhysicMaterial");
|
||||
registry.register(56, "Collider");
|
||||
registry.register(57, "Joint");
|
||||
registry.register(58, "CircleCollider2D");
|
||||
registry.register(59, "HingeJoint");
|
||||
registry.register(60, "PolygonCollider2D");
|
||||
registry.register(61, "BoxCollider2D");
|
||||
registry.register(62, "PhysicsMaterial2D");
|
||||
registry.register(64, "MeshCollider");
|
||||
registry.register(65, "BoxCollider");
|
||||
registry.register(68, "EdgeCollider2D");
|
||||
registry.register(70, "CapsuleCollider2D");
|
||||
registry.register(72, "CompositeCollider2D");
|
||||
registry.register(74, "AnimationClip");
|
||||
registry.register(75, "ConstantForce");
|
||||
registry.register(81, "AudioListener");
|
||||
registry.register(82, "AudioSource");
|
||||
registry.register(83, "AudioClip");
|
||||
registry.register(84, "RenderTexture");
|
||||
registry.register(87, "MeshParticleEmitter");
|
||||
registry.register(88, "ParticleEmitter");
|
||||
registry.register(89, "Cubemap");
|
||||
registry.register(90, "Avatar");
|
||||
registry.register(91, "AnimatorController");
|
||||
registry.register(92, "GUILayer");
|
||||
registry.register(93, "RuntimeAnimatorController");
|
||||
registry.register(94, "ScriptMapper");
|
||||
registry.register(95, "Animator");
|
||||
registry.register(96, "TrailRenderer");
|
||||
registry.register(98, "TextMesh");
|
||||
registry.register(102, "TextureImporter");
|
||||
registry.register(104, "RenderSettings");
|
||||
registry.register(108, "Light");
|
||||
registry.register(109, "CGProgram");
|
||||
registry.register(110, "BaseAnimationTrack");
|
||||
registry.register(111, "Animation");
|
||||
registry.register(114, "MonoBehaviour");
|
||||
registry.register(115, "MonoScript");
|
||||
registry.register(116, "MonoManager");
|
||||
registry.register(117, "Texture3D");
|
||||
registry.register(118, "NewAnimationTrack");
|
||||
registry.register(119, "Projector");
|
||||
registry.register(120, "LineRenderer");
|
||||
registry.register(121, "Flare");
|
||||
registry.register(122, "Halo");
|
||||
registry.register(123, "LensFlare");
|
||||
registry.register(124, "FlareLayer");
|
||||
registry.register(125, "HaloLayer");
|
||||
registry.register(126, "NavMeshAreas");
|
||||
registry.register(127, "HaloManager");
|
||||
registry.register(128, "Font");
|
||||
registry.register(129, "PlayerSettings");
|
||||
registry.register(130, "NamedObject");
|
||||
registry.register(131, "GUITexture");
|
||||
registry.register(132, "GUIText");
|
||||
registry.register(133, "GUIElement");
|
||||
registry.register(134, "PhysicMaterial");
|
||||
registry.register(135, "SphereCollider");
|
||||
registry.register(136, "CapsuleCollider");
|
||||
registry.register(137, "SkinnedMeshRenderer");
|
||||
registry.register(138, "FixedJoint");
|
||||
registry.register(141, "BuildSettings");
|
||||
registry.register(143, "AssetBundle");
|
||||
registry.register(144, "CharacterController");
|
||||
registry.register(145, "CharacterJoint");
|
||||
registry.register(146, "SpringJoint");
|
||||
registry.register(147, "WheelCollider");
|
||||
registry.register(148, "ResourceManager");
|
||||
registry.register(149, "NetworkView");
|
||||
registry.register(150, "NetworkManager");
|
||||
registry.register(152, "MovieTexture");
|
||||
registry.register(153, "ConfigurableJoint");
|
||||
registry.register(154, "TerrainCollider");
|
||||
registry.register(155, "MasterServerInterface");
|
||||
registry.register(156, "TerrainData");
|
||||
registry.register(157, "LightmapSettings");
|
||||
registry.register(158, "WebCamTexture");
|
||||
registry.register(159, "EditorSettings");
|
||||
registry.register(162, "EditorUserSettings");
|
||||
registry.register(164, "AudioReverbFilter");
|
||||
registry.register(165, "AudioHighPassFilter");
|
||||
registry.register(166, "AudioChorusFilter");
|
||||
registry.register(167, "AudioReverbZone");
|
||||
registry.register(168, "AudioEchoFilter");
|
||||
registry.register(169, "AudioLowPassFilter");
|
||||
registry.register(170, "AudioDistortionFilter");
|
||||
registry.register(171, "SparseTexture");
|
||||
registry.register(180, "AudioBehaviour");
|
||||
registry.register(181, "AudioFilter");
|
||||
registry.register(182, "WindZone");
|
||||
registry.register(183, "Cloth");
|
||||
registry.register(184, "SubstanceArchive");
|
||||
registry.register(185, "ProceduralMaterial");
|
||||
registry.register(186, "ProceduralTexture");
|
||||
registry.register(191, "OffMeshLink");
|
||||
registry.register(192, "OcclusionArea");
|
||||
registry.register(193, "Tree");
|
||||
registry.register(194, "NavMeshObsolete");
|
||||
registry.register(195, "NavMeshAgent");
|
||||
registry.register(196, "NavMeshSettings");
|
||||
registry.register(197, "LightProbesLegacy");
|
||||
registry.register(198, "ParticleSystem");
|
||||
registry.register(199, "ParticleSystemRenderer");
|
||||
registry.register(200, "ShaderVariantCollection");
|
||||
registry.register(205, "LODGroup");
|
||||
registry.register(206, "BlendTree");
|
||||
registry.register(207, "Motion");
|
||||
registry.register(208, "NavMeshObstacle");
|
||||
registry.register(210, "TerrainInstance");
|
||||
registry.register(212, "SpriteRenderer");
|
||||
registry.register(213, "Sprite");
|
||||
registry.register(214, "CachedSpriteAtlas");
|
||||
registry.register(215, "ReflectionProbe");
|
||||
registry.register(218, "Terrain");
|
||||
registry.register(220, "LightProbeGroup");
|
||||
registry.register(221, "AnimatorOverrideController");
|
||||
registry.register(222, "CanvasRenderer");
|
||||
registry.register(223, "Canvas");
|
||||
registry.register(224, "RectTransform");
|
||||
registry.register(225, "CanvasGroup");
|
||||
registry.register(226, "BillboardAsset");
|
||||
registry.register(227, "BillboardRenderer");
|
||||
registry.register(228, "SpeedTreeWindAsset");
|
||||
registry.register(229, "AnchoredJoint2D");
|
||||
registry.register(230, "Joint2D");
|
||||
registry.register(231, "SpringJoint2D");
|
||||
registry.register(232, "DistanceJoint2D");
|
||||
registry.register(233, "HingeJoint2D");
|
||||
registry.register(234, "SliderJoint2D");
|
||||
registry.register(235, "WheelJoint2D");
|
||||
registry.register(238, "NavMeshData");
|
||||
registry.register(240, "AudioMixer");
|
||||
registry.register(241, "AudioMixerController");
|
||||
registry.register(243, "AudioMixerGroupController");
|
||||
registry.register(244, "AudioMixerEffectController");
|
||||
registry.register(245, "AudioMixerSnapshotController");
|
||||
registry.register(246, "PhysicsUpdateBehaviour2D");
|
||||
registry.register(247, "ConstantForce2D");
|
||||
registry.register(248, "Effector2D");
|
||||
registry.register(249, "AreaEffector2D");
|
||||
registry.register(250, "PointEffector2D");
|
||||
registry.register(251, "PlatformEffector2D");
|
||||
registry.register(252, "SurfaceEffector2D");
|
||||
registry.register(258, "LightProbes");
|
||||
registry.register(271, "SampleClip");
|
||||
registry.register(272, "AudioMixerSnapshot");
|
||||
registry.register(273, "AudioMixerGroup");
|
||||
registry.register(290, "AssetBundleManifest");
|
||||
registry.register(1001, "PrefabInstance");
|
||||
registry.register(1002, "EditorExtensionImpl");
|
||||
registry.register(1003, "AssetImporter");
|
||||
registry.register(1004, "AssetDatabase");
|
||||
registry.register(1005, "Mesh3DSImporter");
|
||||
registry.register(1006, "TextureImporter");
|
||||
registry.register(1007, "ShaderImporter");
|
||||
registry.register(1008, "ComputeShaderImporter");
|
||||
registry.register(1020, "AudioImporter");
|
||||
registry.register(1026, "HierarchyState");
|
||||
registry.register(1027, "GUIDSerializer");
|
||||
registry.register(1028, "AssetMetaData");
|
||||
registry.register(1029, "DefaultAsset");
|
||||
registry.register(1030, "DefaultImporter");
|
||||
registry.register(1031, "TextScriptImporter");
|
||||
registry.register(1032, "SceneAsset");
|
||||
registry.register(1034, "NativeFormatImporter");
|
||||
registry.register(1035, "MonoImporter");
|
||||
registry.register(1038, "LibraryAssetImporter");
|
||||
registry.register(1040, "ModelImporter");
|
||||
registry.register(1041, "FBXImporter");
|
||||
registry.register(1042, "TrueTypeFontImporter");
|
||||
registry.register(1045, "MovieImporter");
|
||||
registry.register(1050, "EditorBuildSettings");
|
||||
registry.register(1051, "DDSImporter");
|
||||
registry.register(1052, "InspectorExpandedState");
|
||||
registry.register(1053, "AnnotationManager");
|
||||
registry.register(1055, "MonoManager");
|
||||
registry.register(1101, "AnimatorStateMachine");
|
||||
registry.register(1102, "AnimatorState");
|
||||
registry.register(1105, "AnimatorStateTransition");
|
||||
registry.register(1107, "AnimatorTransition");
|
||||
|
||||
registry
|
||||
});
|
||||
|
||||
/// Get the class name for a Unity type ID
|
||||
///
|
||||
/// Returns None if the type ID is not registered.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::types::get_class_name;
|
||||
///
|
||||
/// assert_eq!(get_class_name(1), Some("GameObject"));
|
||||
/// assert_eq!(get_class_name(4), Some("Transform"));
|
||||
/// assert_eq!(get_class_name(999999), None);
|
||||
/// ```
|
||||
pub fn get_class_name(type_id: u32) -> Option<&'static str> {
|
||||
UNITY_TYPE_REGISTRY.get_class_name(type_id)
|
||||
}
|
||||
|
||||
/// Get the type ID for a Unity class name
|
||||
///
|
||||
/// Returns None if the class name is not registered.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::types::get_type_id;
|
||||
///
|
||||
/// assert_eq!(get_type_id("GameObject"), Some(1));
|
||||
/// assert_eq!(get_type_id("Transform"), Some(4));
|
||||
/// assert_eq!(get_type_id("UnknownType"), None);
|
||||
/// ```
|
||||
pub fn get_type_id(class_name: &str) -> Option<u32> {
|
||||
UNITY_TYPE_REGISTRY.get_type_id(class_name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_class_name_game_object() {
|
||||
assert_eq!(get_class_name(1), Some("GameObject"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_class_name_transform() {
|
||||
assert_eq!(get_class_name(4), Some("Transform"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_class_name_rect_transform() {
|
||||
assert_eq!(get_class_name(224), Some("RectTransform"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_class_name_mono_behaviour() {
|
||||
assert_eq!(get_class_name(114), Some("MonoBehaviour"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_class_name_sprite_renderer() {
|
||||
assert_eq!(get_class_name(212), Some("SpriteRenderer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_class_name_unknown() {
|
||||
assert_eq!(get_class_name(999999), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_type_id_game_object() {
|
||||
assert_eq!(get_type_id("GameObject"), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_type_id_transform() {
|
||||
assert_eq!(get_type_id("Transform"), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_type_id_rect_transform() {
|
||||
assert_eq!(get_type_id("RectTransform"), Some(224));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_type_id_unknown() {
|
||||
assert_eq!(get_type_id("UnknownType"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bidirectional_mapping() {
|
||||
// Test that type_id -> name -> type_id works
|
||||
let type_id = 114;
|
||||
let class_name = get_class_name(type_id).unwrap();
|
||||
assert_eq!(class_name, "MonoBehaviour");
|
||||
assert_eq!(get_type_id(class_name), Some(type_id));
|
||||
}
|
||||
}
|
||||
65
unity-parser/src/types/unity_types/game_object.rs
Normal file
65
unity-parser/src/types/unity_types/game_object.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
//! GameObject component
|
||||
|
||||
use crate::types::{yaml_helpers, ComponentContext, UnityComponent};
|
||||
use sparsey::Entity;
|
||||
|
||||
/// A GameObject component
|
||||
///
|
||||
/// GameObjects are the fundamental objects in Unity that represent entities in a scene.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameObject {
|
||||
name: Option<String>,
|
||||
is_active: bool,
|
||||
layer: Option<i64>,
|
||||
tag: Option<i64>,
|
||||
}
|
||||
|
||||
impl GameObject {
|
||||
/// Get the GameObject's name
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.name.as_deref()
|
||||
}
|
||||
|
||||
/// Check if the GameObject is active
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.is_active
|
||||
}
|
||||
|
||||
/// Get the GameObject's layer
|
||||
pub fn layer(&self) -> Option<i64> {
|
||||
self.layer
|
||||
}
|
||||
|
||||
/// Get the GameObject's tag
|
||||
pub fn tag(&self) -> Option<i64> {
|
||||
self.tag
|
||||
}
|
||||
}
|
||||
|
||||
impl UnityComponent for GameObject {
|
||||
/// Parse a GameObject from YAML
|
||||
///
|
||||
/// Note: Caller is responsible for ensuring this is called on the correct document type.
|
||||
fn parse(yaml: &serde_yaml::Mapping, _ctx: &ComponentContext) -> Option<Self> {
|
||||
let name = yaml_helpers::get_string(yaml, "m_Name");
|
||||
|
||||
let is_active = yaml_helpers::get_bool(yaml, "m_IsActive").unwrap_or(true);
|
||||
|
||||
let layer = yaml_helpers::get_i64(yaml, "m_Layer");
|
||||
|
||||
let tag = yaml_helpers::get_i64(yaml, "m_TagString");
|
||||
|
||||
Some(Self {
|
||||
name,
|
||||
is_active,
|
||||
layer,
|
||||
tag,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::types::EcsInsertable for GameObject {
|
||||
fn insert_into_world(self, world: &mut sparsey::World, entity: Entity) {
|
||||
world.insert(entity, (self,));
|
||||
}
|
||||
}
|
||||
11
unity-parser/src/types/unity_types/mod.rs
Normal file
11
unity-parser/src/types/unity_types/mod.rs
Normal 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};
|
||||
827
unity-parser/src/types/unity_types/prefab_instance.rs
Normal file
827
unity-parser/src/types/unity_types/prefab_instance.rs
Normal file
@@ -0,0 +1,827 @@
|
||||
//! Prefab instancing system for cloning and spawning Unity prefabs
|
||||
|
||||
use crate::model::{RawDocument, UnityPrefab};
|
||||
use crate::types::{yaml_helpers, ComponentContext, ExternalRef, FileID, UnityComponent};
|
||||
use crate::{Error, Result};
|
||||
use serde_yaml::{Mapping, Value};
|
||||
use sparsey::{Entity, World};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An instance of a Unity prefab ready for spawning into a scene
|
||||
///
|
||||
/// PrefabInstance represents a cloned prefab with unique FileIDs to avoid
|
||||
/// collisions when spawning multiple instances. It supports:
|
||||
/// - FileID remapping to ensure uniqueness
|
||||
/// - YAML value overrides before spawning
|
||||
/// - Spawning into existing ECS worlds
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let prefab = /* load UnityPrefab */;
|
||||
/// let mut instance = prefab.instantiate();
|
||||
/// instance.override_value(file_id, "m_Name", "Player1".into())?;
|
||||
/// instance.override_value(file_id, "m_LocalPosition.x", 100.0.into())?;
|
||||
/// let entities = instance.spawn_into(&mut world, &mut entity_map)?;
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PrefabInstance {
|
||||
/// Cloned YAML documents from the source prefab
|
||||
documents: Vec<RawDocument>,
|
||||
|
||||
/// FileID remapping table: original FileID → new FileID
|
||||
/// This ensures no collisions when spawning into existing worlds
|
||||
file_id_map: HashMap<FileID, FileID>,
|
||||
|
||||
/// Overrides to apply before spawning
|
||||
/// Maps (original FileID, YAML path) → new value
|
||||
overrides: HashMap<(FileID, String), Value>,
|
||||
|
||||
/// Sequential counter for generating new FileIDs
|
||||
/// Starts at i64::MAX and decrements to avoid collisions with scene FileIDs
|
||||
next_file_id: i64,
|
||||
|
||||
/// Source prefab path for debugging
|
||||
source_path: PathBuf,
|
||||
}
|
||||
|
||||
impl PrefabInstance {
|
||||
/// Create a new instance from a Unity prefab
|
||||
///
|
||||
/// This clones all documents from the prefab and initializes FileID remapping.
|
||||
pub fn new(prefab: &UnityPrefab) -> Self {
|
||||
// Clone all documents from the prefab
|
||||
let documents = prefab.documents.clone();
|
||||
|
||||
let mut instance = Self {
|
||||
documents,
|
||||
file_id_map: HashMap::new(),
|
||||
overrides: HashMap::new(),
|
||||
next_file_id: i64::MAX,
|
||||
source_path: prefab.path.clone(),
|
||||
};
|
||||
|
||||
// Initialize FileID mapping and remap all references
|
||||
instance.initialize_file_id_mapping();
|
||||
instance.remap_yaml_file_refs();
|
||||
|
||||
instance
|
||||
}
|
||||
|
||||
/// Generate a new unique FileID
|
||||
///
|
||||
/// Uses a sequential counter starting from i64::MAX and decrementing.
|
||||
/// This avoids collisions with typical scene FileIDs which are positive.
|
||||
fn generate_file_id(&mut self) -> FileID {
|
||||
let id = self.next_file_id;
|
||||
self.next_file_id -= 1;
|
||||
FileID::from_i64(id)
|
||||
}
|
||||
|
||||
/// Initialize FileID remapping for all documents
|
||||
///
|
||||
/// Creates a mapping from original FileID → new unique FileID for each document.
|
||||
fn initialize_file_id_mapping(&mut self) {
|
||||
// Collect original IDs first to avoid borrowing conflicts
|
||||
let original_ids: Vec<FileID> = self.documents.iter().map(|doc| doc.file_id).collect();
|
||||
|
||||
for original_id in original_ids {
|
||||
let new_id = self.generate_file_id();
|
||||
self.file_id_map.insert(original_id, new_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remap all FileID references in YAML documents
|
||||
///
|
||||
/// This recursively traverses all YAML values and replaces FileID references
|
||||
/// with their remapped values from `file_id_map`.
|
||||
fn remap_yaml_file_refs(&mut self) {
|
||||
// First, update each document's own file_id
|
||||
for doc in &mut self.documents {
|
||||
if let Some(&new_id) = self.file_id_map.get(&doc.file_id) {
|
||||
doc.file_id = new_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Clone the map to avoid borrow conflicts
|
||||
let file_id_map = self.file_id_map.clone();
|
||||
|
||||
// Then, remap all FileRef references in the YAML
|
||||
for doc in &mut self.documents {
|
||||
Self::remap_value(&mut doc.yaml, &file_id_map);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively traverse YAML and remap FileID references
|
||||
///
|
||||
/// Looks for patterns like `{fileID: 12345}` and replaces the FileID
|
||||
/// with the remapped value from `file_id_map`.
|
||||
fn remap_value(value: &mut Value, file_id_map: &HashMap<FileID, FileID>) {
|
||||
match value {
|
||||
Value::Mapping(map) => {
|
||||
// Check if this is a FileRef: {fileID: N}
|
||||
if let Some(file_id_value) = map.get(&Value::String("fileID".to_string())) {
|
||||
if let Some(num) = file_id_value.as_i64() {
|
||||
let original = FileID::from_i64(num);
|
||||
|
||||
// Remap if it's a local reference (not 0, not external)
|
||||
if num != 0 {
|
||||
if let Some(&new_id) = file_id_map.get(&original) {
|
||||
map.insert(
|
||||
Value::String("fileID".to_string()),
|
||||
Value::Number(new_id.as_i64().into()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process all values in the mapping
|
||||
for (_, v) in map.iter_mut() {
|
||||
Self::remap_value(v, file_id_map);
|
||||
}
|
||||
}
|
||||
Value::Sequence(seq) => {
|
||||
// Recursively process array elements
|
||||
for item in seq.iter_mut() {
|
||||
Self::remap_value(item, file_id_map);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Scalars (strings, numbers, bools, null) don't need remapping
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Override a YAML value before spawning
|
||||
///
|
||||
/// This allows modifying prefab data before instantiation. The override
|
||||
/// is applied to the document with the given FileID at the specified YAML path.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `file_id` - The original FileID (before remapping) of the document to modify
|
||||
/// * `path` - Dot-notation path to the value (e.g., "m_LocalPosition.x")
|
||||
/// * `value` - The new value to set
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// instance.override_value(file_id, "m_Name", "Player1".into())?;
|
||||
/// instance.override_value(file_id, "m_LocalPosition.x", 100.0.into())?;
|
||||
/// ```
|
||||
pub fn override_value(
|
||||
&mut self,
|
||||
file_id: FileID,
|
||||
path: &str,
|
||||
value: Value,
|
||||
) -> Result<()> {
|
||||
// Store override to be applied during spawn
|
||||
// Note: We store using the original FileID for easier API
|
||||
self.overrides.insert((file_id, path.to_string()), value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply all stored overrides to the documents
|
||||
///
|
||||
/// This is called internally before spawning. It navigates to each
|
||||
/// override path and sets the new value.
|
||||
fn apply_overrides(&mut self) -> Result<()> {
|
||||
for ((file_id, path), value) in &self.overrides {
|
||||
// Find the document with this FileID (after remapping)
|
||||
let remapped_id = self
|
||||
.file_id_map
|
||||
.get(file_id)
|
||||
.ok_or_else(|| Error::reference_error(format!("FileID not found: {}", file_id)))?;
|
||||
|
||||
let doc = self
|
||||
.documents
|
||||
.iter_mut()
|
||||
.find(|d| d.file_id == *remapped_id)
|
||||
.ok_or_else(|| {
|
||||
Error::reference_error(format!("Document not found: {}", remapped_id))
|
||||
})?;
|
||||
|
||||
// Navigate to the path and set the value
|
||||
Self::set_yaml_value(&mut doc.yaml, path, value.clone())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Navigate YAML path and set value
|
||||
///
|
||||
/// This parses the dot-notation path and navigates through the YAML
|
||||
/// structure to set the value at the target location.
|
||||
fn set_yaml_value(yaml: &mut Value, path: &str, new_value: Value) -> Result<()> {
|
||||
let segments = parse_yaml_path(path);
|
||||
let mut current = yaml;
|
||||
|
||||
// Navigate to parent of target
|
||||
for segment in &segments[..segments.len() - 1] {
|
||||
current = match segment {
|
||||
PathSegment::Field(field) => current
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| Error::InvalidPropertyPath(path.to_string()))?
|
||||
.get_mut(&Value::String(field.clone()))
|
||||
.ok_or_else(|| Error::PropertyNotFound(field.clone()))?,
|
||||
PathSegment::ArrayIndex { field, index } => {
|
||||
let mapping = current
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| Error::InvalidPropertyPath(path.to_string()))?;
|
||||
let array = mapping
|
||||
.get_mut(&Value::String(field.clone()))
|
||||
.and_then(|v| v.as_sequence_mut())
|
||||
.ok_or_else(|| Error::PropertyNotFound(field.clone()))?;
|
||||
array.get_mut(*index).ok_or_else(|| {
|
||||
Error::InvalidPropertyPath(format!("{}[{}]", field, index))
|
||||
})?
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Set the final value
|
||||
match segments.last().unwrap() {
|
||||
PathSegment::Field(field) => {
|
||||
let mapping = current
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| Error::InvalidPropertyPath(path.to_string()))?;
|
||||
mapping.insert(Value::String(field.clone()), new_value);
|
||||
}
|
||||
PathSegment::ArrayIndex { field, index } => {
|
||||
let mapping = current
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| Error::InvalidPropertyPath(path.to_string()))?;
|
||||
let array = mapping
|
||||
.get_mut(&Value::String(field.clone()))
|
||||
.and_then(|v| v.as_sequence_mut())
|
||||
.ok_or_else(|| Error::PropertyNotFound(field.clone()))?;
|
||||
if *index < array.len() {
|
||||
array[*index] = new_value;
|
||||
} else {
|
||||
return Err(Error::InvalidPropertyPath(format!(
|
||||
"{}[{}] out of bounds",
|
||||
field, index
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawn this prefab instance into an existing world
|
||||
///
|
||||
/// This applies any overrides and then uses the ECS builder to create
|
||||
/// entities and components in the target world.
|
||||
///
|
||||
/// # 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, 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,
|
||||
guid_resolver,
|
||||
prefab_guid_resolver,
|
||||
)
|
||||
}
|
||||
|
||||
/// Get the source prefab path (for debugging)
|
||||
pub fn source_path(&self) -> &PathBuf {
|
||||
&self.source_path
|
||||
}
|
||||
|
||||
/// Get the FileID mapping table (for debugging)
|
||||
pub fn file_id_map(&self) -> &HashMap<FileID, FileID> {
|
||||
&self.file_id_map
|
||||
}
|
||||
}
|
||||
|
||||
/// Unity component representing a reference to another prefab (nested prefab)
|
||||
///
|
||||
/// This component appears in prefabs that contain instances of other prefabs.
|
||||
/// It stores the GUID of the referenced prefab and any modifications applied.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PrefabInstanceComponent {
|
||||
/// External reference to the source prefab (by GUID)
|
||||
pub prefab_ref: ExternalRef,
|
||||
|
||||
/// Modifications applied to the nested prefab
|
||||
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)
|
||||
let prefab_ref = yaml_helpers::get_external_ref(yaml, "m_SourcePrefab")?;
|
||||
|
||||
// Extract m_Modification array (if any)
|
||||
let modifications = parse_modifications(yaml).unwrap_or_default();
|
||||
|
||||
Some(Self {
|
||||
prefab_ref,
|
||||
modifications,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::types::EcsInsertable for PrefabInstanceComponent {
|
||||
fn insert_into_world(self, world: &mut sparsey::World, entity: Entity) {
|
||||
world.insert(entity, (self,));
|
||||
}
|
||||
}
|
||||
|
||||
/// A modification applied to a nested prefab
|
||||
///
|
||||
/// Unity stores modifications as changes to specific properties of objects
|
||||
/// within the nested prefab.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PrefabModification {
|
||||
/// The FileID of the target object within the nested prefab
|
||||
pub target_file_id: FileID,
|
||||
|
||||
/// The property path to modify (dot notation)
|
||||
pub property_path: String,
|
||||
|
||||
/// The new value to apply
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
/// Parse modifications array from Unity YAML
|
||||
fn parse_modifications(yaml: &Mapping) -> Option<Vec<PrefabModification>> {
|
||||
let mods_array = yaml
|
||||
.get(&Value::String("m_Modification".to_string()))
|
||||
.and_then(|v| v.as_sequence())?;
|
||||
|
||||
let mut mods = Vec::new();
|
||||
for mod_yaml in mods_array {
|
||||
if let Some(mod_map) = mod_yaml.as_mapping() {
|
||||
// Parse target FileID, property path, and value
|
||||
// Unity format: {target: {fileID: N}, propertyPath: "m_Name", value: "NewName"}
|
||||
if let Some(modification) = parse_single_modification(mod_map) {
|
||||
mods.push(modification);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(mods)
|
||||
}
|
||||
|
||||
/// Parse a single modification entry
|
||||
fn parse_single_modification(yaml: &Mapping) -> Option<PrefabModification> {
|
||||
// Get target FileID
|
||||
let target = yaml
|
||||
.get(&Value::String("target".to_string()))
|
||||
.and_then(|v| v.as_mapping())?;
|
||||
let target_file_id = yaml_helpers::get_file_ref_from_mapping(target)?.file_id;
|
||||
|
||||
// Get property path
|
||||
let property_path = yaml
|
||||
.get(&Value::String("propertyPath".to_string()))
|
||||
.and_then(|v| v.as_str())?
|
||||
.to_string();
|
||||
|
||||
// Get value
|
||||
let value = yaml
|
||||
.get(&Value::String("value".to_string()))?
|
||||
.clone();
|
||||
|
||||
Some(PrefabModification {
|
||||
target_file_id,
|
||||
property_path,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolver for loading and recursively instantiating prefabs
|
||||
///
|
||||
/// PrefabResolver handles:
|
||||
/// - Loading prefabs by GUID
|
||||
/// - Caching loaded prefabs
|
||||
/// - Detecting circular prefab references
|
||||
/// - Recursively instantiating nested prefabs
|
||||
pub struct PrefabResolver<'a> {
|
||||
/// Cache of loaded prefabs (GUID → Prefab)
|
||||
prefab_cache: HashMap<String, Arc<UnityPrefab>>,
|
||||
|
||||
/// Mapping from GUID to file path
|
||||
guid_to_path: HashMap<String, PathBuf>,
|
||||
|
||||
/// 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<'a> PrefabResolver<'a> {
|
||||
/// Create a new PrefabResolver
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `guid_to_path` - Mapping from asset GUID to file path
|
||||
pub fn new(guid_to_path: HashMap<String, PathBuf>) -> Self {
|
||||
Self {
|
||||
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:
|
||||
/// 1. Checking for circular references
|
||||
/// 2. Creating a prefab instance
|
||||
/// 3. Finding any nested prefab references
|
||||
/// 4. Recursively instantiating nested prefabs
|
||||
/// 5. Spawning the prefab's entities into the world
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `prefab` - The prefab to instantiate
|
||||
/// * `world` - The ECS world to spawn entities into
|
||||
/// * `entity_map` - Entity mapping to update
|
||||
///
|
||||
/// # Returns
|
||||
/// Vec of spawned entities
|
||||
pub fn instantiate_recursive(
|
||||
&mut self,
|
||||
prefab: &UnityPrefab,
|
||||
world: &mut World,
|
||||
entity_map: &mut HashMap<FileID, Entity>,
|
||||
) -> Result<Vec<Entity>> {
|
||||
// For this implementation, we'll use the path as the identifier
|
||||
// In a full implementation, we'd extract GUID from .meta files
|
||||
let prefab_id = prefab.path.to_string_lossy().to_string();
|
||||
|
||||
// Check for circular references
|
||||
if self.instantiation_stack.contains(&prefab_id) {
|
||||
return Err(Error::circular_reference());
|
||||
}
|
||||
|
||||
// Push to stack
|
||||
self.instantiation_stack.push(prefab_id.clone());
|
||||
|
||||
// Create instance
|
||||
let instance = prefab.instantiate();
|
||||
|
||||
// Find nested prefab references
|
||||
let nested_prefabs = self.find_nested_prefabs(&instance)?;
|
||||
|
||||
// For each nested prefab, recursively instantiate it
|
||||
// (This is a simplified version - full implementation would need to
|
||||
// properly link nested entities to parent GameObjects)
|
||||
for (_parent_file_id, nested_component) in nested_prefabs {
|
||||
// Load the referenced prefab
|
||||
if let Ok(nested_prefab) = self.load_prefab(&nested_component.prefab_ref.guid) {
|
||||
// Apply modifications
|
||||
let mut nested_instance = nested_prefab.instantiate();
|
||||
for modification in &nested_component.modifications {
|
||||
nested_instance.override_value(
|
||||
modification.target_file_id,
|
||||
&modification.property_path,
|
||||
modification.value.clone(),
|
||||
)?;
|
||||
}
|
||||
|
||||
// Recursively spawn nested prefab
|
||||
self.instantiate_recursive(&nested_prefab, world, entity_map)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn this prefab's entities
|
||||
// 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();
|
||||
|
||||
Ok(spawned)
|
||||
}
|
||||
|
||||
/// Find all nested prefab references in an instance
|
||||
fn find_nested_prefabs(
|
||||
&self,
|
||||
instance: &PrefabInstance,
|
||||
) -> Result<Vec<(FileID, PrefabInstanceComponent)>> {
|
||||
let mut nested = Vec::new();
|
||||
|
||||
for doc in &instance.documents {
|
||||
if doc.class_name == "PrefabInstance" {
|
||||
if let Some(mapping) = doc.as_mapping() {
|
||||
// Create a minimal context for parsing
|
||||
let ctx = ComponentContext {
|
||||
type_id: doc.type_id,
|
||||
file_id: doc.file_id,
|
||||
class_name: &doc.class_name,
|
||||
entity: None,
|
||||
linking_ctx: None,
|
||||
yaml: mapping,
|
||||
guid_resolver: None,
|
||||
};
|
||||
|
||||
if let Some(component) = PrefabInstanceComponent::parse(mapping, &ctx) {
|
||||
nested.push((doc.file_id, component));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(nested)
|
||||
}
|
||||
|
||||
/// Load a prefab by GUID
|
||||
fn load_prefab(&mut self, guid: &str) -> Result<Arc<UnityPrefab>> {
|
||||
// Check cache first
|
||||
if let Some(prefab) = self.prefab_cache.get(guid) {
|
||||
return Ok(prefab.clone());
|
||||
}
|
||||
|
||||
// Resolve GUID to path
|
||||
let path = self
|
||||
.guid_to_path
|
||||
.get(guid)
|
||||
.ok_or_else(|| Error::guid_resolution_error(format!("GUID not found: {}", guid)))?
|
||||
.clone();
|
||||
|
||||
// Load prefab
|
||||
let unity_file = crate::model::UnityFile::from_path(&path)?;
|
||||
let prefab = match unity_file {
|
||||
crate::model::UnityFile::Prefab(p) => Arc::new(p),
|
||||
_ => {
|
||||
return Err(Error::invalid_format(
|
||||
"Expected prefab file for GUID resolution",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Cache for future use
|
||||
self.prefab_cache.insert(guid.to_string(), prefab.clone());
|
||||
|
||||
Ok(prefab)
|
||||
}
|
||||
}
|
||||
|
||||
/// A segment of a YAML property path
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum PathSegment {
|
||||
/// A simple field access (e.g., "m_Name")
|
||||
Field(String),
|
||||
/// An array element access (e.g., "m_Component[0]")
|
||||
ArrayIndex { field: String, index: usize },
|
||||
}
|
||||
|
||||
/// Parse a YAML path into segments
|
||||
///
|
||||
/// Supports dot notation and array indices:
|
||||
/// - "m_Name" → [Field("m_Name")]
|
||||
/// - "m_LocalPosition.x" → [Field("m_LocalPosition"), Field("x")]
|
||||
/// - "m_Component[0].fileID" → [ArrayIndex{field: "m_Component", index: 0}, Field("fileID")]
|
||||
fn parse_yaml_path(path: &str) -> Vec<PathSegment> {
|
||||
path.split('.')
|
||||
.map(|segment| {
|
||||
// Check if it's an array index like "m_Component[0]"
|
||||
if let Some(idx_start) = segment.find('[') {
|
||||
let field = &segment[..idx_start];
|
||||
if let Some(idx_end) = segment.find(']') {
|
||||
if let Ok(index) = segment[idx_start + 1..idx_end].parse::<usize>() {
|
||||
return PathSegment::ArrayIndex {
|
||||
field: field.to_string(),
|
||||
index,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
PathSegment::Field(segment.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_file_id_generation() {
|
||||
let mut instance = PrefabInstance {
|
||||
documents: Vec::new(),
|
||||
file_id_map: HashMap::new(),
|
||||
overrides: HashMap::new(),
|
||||
next_file_id: i64::MAX,
|
||||
source_path: PathBuf::from("test.prefab"),
|
||||
};
|
||||
|
||||
let id1 = instance.generate_file_id();
|
||||
let id2 = instance.generate_file_id();
|
||||
|
||||
// Should decrement
|
||||
assert!(id1.as_i64() > id2.as_i64());
|
||||
assert_eq!(id1.as_i64() - 1, id2.as_i64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_id_generation_starts_at_max() {
|
||||
let mut instance = PrefabInstance {
|
||||
documents: Vec::new(),
|
||||
file_id_map: HashMap::new(),
|
||||
overrides: HashMap::new(),
|
||||
next_file_id: i64::MAX,
|
||||
source_path: PathBuf::from("test.prefab"),
|
||||
};
|
||||
|
||||
let id1 = instance.generate_file_id();
|
||||
assert_eq!(id1.as_i64(), i64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yaml_path_parsing_simple() {
|
||||
let path = "m_Name";
|
||||
let segments = parse_yaml_path(path);
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(segments[0], PathSegment::Field("m_Name".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yaml_path_parsing_nested() {
|
||||
let path = "m_LocalPosition.x";
|
||||
let segments = parse_yaml_path(path);
|
||||
assert_eq!(segments.len(), 2);
|
||||
assert_eq!(
|
||||
segments[0],
|
||||
PathSegment::Field("m_LocalPosition".to_string())
|
||||
);
|
||||
assert_eq!(segments[1], PathSegment::Field("x".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yaml_path_parsing_array() {
|
||||
let path = "m_Component[0]";
|
||||
let segments = parse_yaml_path(path);
|
||||
assert_eq!(segments.len(), 1);
|
||||
assert_eq!(
|
||||
segments[0],
|
||||
PathSegment::ArrayIndex {
|
||||
field: "m_Component".to_string(),
|
||||
index: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yaml_path_parsing_array_with_field() {
|
||||
let path = "m_Component[0].fileID";
|
||||
let segments = parse_yaml_path(path);
|
||||
assert_eq!(segments.len(), 2);
|
||||
assert_eq!(
|
||||
segments[0],
|
||||
PathSegment::ArrayIndex {
|
||||
field: "m_Component".to_string(),
|
||||
index: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(segments[1], PathSegment::Field("fileID".to_string()));
|
||||
}
|
||||
}
|
||||
302
unity-parser/src/types/unity_types/transform.rs
Normal file
302
unity-parser/src/types/unity_types/transform.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
//! Transform and RectTransform component wrappers
|
||||
|
||||
use crate::types::{yaml_helpers, ComponentContext, Quaternion, UnityComponent, Vector2, Vector3};
|
||||
use sparsey::Entity;
|
||||
|
||||
/// A Transform component
|
||||
///
|
||||
/// Transform determines the Position, Rotation, and Scale of each object in the scene.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Transform {
|
||||
local_position: Option<Vector3>,
|
||||
local_rotation: Option<Quaternion>,
|
||||
local_scale: Option<Vector3>,
|
||||
parent: Option<Entity>,
|
||||
children: Vec<Entity>,
|
||||
}
|
||||
|
||||
impl Transform {
|
||||
/// Get the local position
|
||||
pub fn local_position(&self) -> Option<&Vector3> {
|
||||
self.local_position.as_ref()
|
||||
}
|
||||
|
||||
/// Get the local rotation
|
||||
pub fn local_rotation(&self) -> Option<&Quaternion> {
|
||||
self.local_rotation.as_ref()
|
||||
}
|
||||
|
||||
/// Get the local scale
|
||||
pub fn local_scale(&self) -> Option<&Vector3> {
|
||||
self.local_scale.as_ref()
|
||||
}
|
||||
|
||||
/// Get the parent entity
|
||||
pub fn parent(&self) -> Option<Entity> {
|
||||
self.parent
|
||||
}
|
||||
|
||||
/// Get the children entities
|
||||
pub fn children(&self) -> &[Entity] {
|
||||
&self.children
|
||||
}
|
||||
|
||||
/// Set the parent entity (used during hierarchy resolution)
|
||||
pub fn set_parent(&mut self, parent: Option<Entity>) {
|
||||
self.parent = parent;
|
||||
}
|
||||
|
||||
/// Set the children entities (used during hierarchy resolution)
|
||||
pub fn set_children(&mut self, children: Vec<Entity>) {
|
||||
self.children = children;
|
||||
}
|
||||
}
|
||||
|
||||
impl UnityComponent for Transform {
|
||||
/// Parse a Transform from YAML
|
||||
///
|
||||
/// Note: Caller is responsible for ensuring this is called on the correct document type.
|
||||
fn parse(yaml: &serde_yaml::Mapping, ctx: &ComponentContext) -> Option<Self> {
|
||||
let local_position = yaml_helpers::get_vector3(yaml, "m_LocalPosition");
|
||||
|
||||
let local_rotation = yaml_helpers::get_quaternion(yaml, "m_LocalRotation");
|
||||
|
||||
let local_scale = yaml_helpers::get_vector3(yaml, "m_LocalScale");
|
||||
|
||||
// Handle transform hierarchy linking if context is available
|
||||
if let (Some(entity), Some(linking_ctx_ref)) = (ctx.entity, ctx.linking_ctx) {
|
||||
// Extract parent FileRef (m_Father)
|
||||
let parent_ref = yaml_helpers::get_file_ref(yaml, "m_Father");
|
||||
|
||||
// Extract children FileRefs (m_Children)
|
||||
let children_refs =
|
||||
yaml_helpers::get_file_ref_array(yaml, "m_Children").unwrap_or_default();
|
||||
|
||||
// Try to resolve children immediately
|
||||
let mut children_entities = Vec::new();
|
||||
let mut unresolved_children = Vec::new();
|
||||
|
||||
{
|
||||
let linking_ctx = linking_ctx_ref.borrow();
|
||||
for child_ref in children_refs {
|
||||
if let Some(child_entity) = linking_ctx.resolve_entity(child_ref.file_id) {
|
||||
children_entities.push(child_entity);
|
||||
} else {
|
||||
unresolved_children.push(child_ref.file_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register callback to set parent and children (even if some are unresolved)
|
||||
linking_ctx_ref
|
||||
.borrow_mut()
|
||||
.register_callback(Box::new(move |world, entity_map| {
|
||||
// Get the transform component
|
||||
if let Some(transform) = world.borrow_mut::<Transform>().get_mut(entity) {
|
||||
// Set parent (might be None if unresolved)
|
||||
let resolved_parent =
|
||||
parent_ref.and_then(|r| entity_map.get(&r.file_id).copied());
|
||||
transform.set_parent(resolved_parent);
|
||||
|
||||
// Resolve any previously unresolved children
|
||||
let mut all_children = children_entities.clone();
|
||||
for child_file_id in &unresolved_children {
|
||||
if let Some(child_entity) = entity_map.get(child_file_id).copied() {
|
||||
all_children.push(child_entity);
|
||||
} else {
|
||||
eprintln!(
|
||||
"Warning: Could not resolve child Transform: {}",
|
||||
child_file_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
transform.set_children(all_children);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
local_position,
|
||||
local_rotation,
|
||||
local_scale,
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::types::EcsInsertable for Transform {
|
||||
fn insert_into_world(self, world: &mut sparsey::World, entity: Entity) {
|
||||
world.insert(entity, (self,));
|
||||
}
|
||||
}
|
||||
|
||||
/// A RectTransform component
|
||||
///
|
||||
/// RectTransform is used for UI elements and extends Transform with additional properties.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use cursebreaker_parser::{UnityFile, types::RectTransform};
|
||||
///
|
||||
/// let file = UnityFile::from_path("Canvas.prefab")?;
|
||||
/// match file {
|
||||
/// UnityFile::Prefab(prefab) => {
|
||||
/// for doc in &prefab.documents {
|
||||
/// if doc.class_name == "RectTransform" {
|
||||
/// // Can parse RectTransform from doc.yaml
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// _ => {}
|
||||
/// }
|
||||
/// # Ok::<(), cursebreaker_parser::Error>(())
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RectTransform {
|
||||
transform: Transform,
|
||||
anchor_min: Option<Vector2>,
|
||||
anchor_max: Option<Vector2>,
|
||||
anchored_position: Option<Vector2>,
|
||||
size_delta: Option<Vector2>,
|
||||
pivot: Option<Vector2>,
|
||||
}
|
||||
|
||||
impl RectTransform {
|
||||
/// Get the base Transform
|
||||
pub fn transform(&self) -> &Transform {
|
||||
&self.transform
|
||||
}
|
||||
|
||||
/// Get mutable access to the base Transform
|
||||
pub fn transform_mut(&mut self) -> &mut Transform {
|
||||
&mut self.transform
|
||||
}
|
||||
|
||||
/// Get the anchor min
|
||||
pub fn anchor_min(&self) -> Option<&Vector2> {
|
||||
self.anchor_min.as_ref()
|
||||
}
|
||||
|
||||
/// Get the anchor max
|
||||
pub fn anchor_max(&self) -> Option<&Vector2> {
|
||||
self.anchor_max.as_ref()
|
||||
}
|
||||
|
||||
/// Get the anchored position
|
||||
pub fn anchored_position(&self) -> Option<&Vector2> {
|
||||
self.anchored_position.as_ref()
|
||||
}
|
||||
|
||||
/// Get the size delta
|
||||
pub fn size_delta(&self) -> Option<&Vector2> {
|
||||
self.size_delta.as_ref()
|
||||
}
|
||||
|
||||
/// Get the pivot
|
||||
pub fn pivot(&self) -> Option<&Vector2> {
|
||||
self.pivot.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl UnityComponent for RectTransform {
|
||||
/// Parse a RectTransform from YAML
|
||||
///
|
||||
/// Note: Caller is responsible for ensuring this is called on the correct document type.
|
||||
fn parse(yaml: &serde_yaml::Mapping, ctx: &ComponentContext) -> Option<Self> {
|
||||
let anchor_min = yaml_helpers::get_vector2(yaml, "m_AnchorMin");
|
||||
|
||||
let anchor_max = yaml_helpers::get_vector2(yaml, "m_AnchorMax");
|
||||
|
||||
let anchored_position = yaml_helpers::get_vector2(yaml, "m_AnchoredPosition");
|
||||
|
||||
let size_delta = yaml_helpers::get_vector2(yaml, "m_SizeDelta");
|
||||
|
||||
let pivot = yaml_helpers::get_vector2(yaml, "m_Pivot");
|
||||
|
||||
// Parse transform data (without the base Transform to avoid double-linking)
|
||||
let local_position = yaml_helpers::get_vector3(yaml, "m_LocalPosition");
|
||||
let local_rotation = yaml_helpers::get_quaternion(yaml, "m_LocalRotation");
|
||||
let local_scale = yaml_helpers::get_vector3(yaml, "m_LocalScale");
|
||||
|
||||
// Handle transform hierarchy linking if context is available
|
||||
if let (Some(entity), Some(linking_ctx_ref)) = (ctx.entity, ctx.linking_ctx) {
|
||||
// Extract parent FileRef (m_Father)
|
||||
let parent_ref = yaml_helpers::get_file_ref(yaml, "m_Father");
|
||||
|
||||
// Extract children FileRefs (m_Children)
|
||||
let children_refs =
|
||||
yaml_helpers::get_file_ref_array(yaml, "m_Children").unwrap_or_default();
|
||||
|
||||
// Try to resolve children immediately
|
||||
let mut children_entities = Vec::new();
|
||||
let mut unresolved_children = Vec::new();
|
||||
|
||||
{
|
||||
let linking_ctx = linking_ctx_ref.borrow();
|
||||
for child_ref in children_refs {
|
||||
if let Some(child_entity) = linking_ctx.resolve_entity(child_ref.file_id) {
|
||||
children_entities.push(child_entity);
|
||||
} else {
|
||||
unresolved_children.push(child_ref.file_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register callback to set parent and children on the inner Transform
|
||||
linking_ctx_ref
|
||||
.borrow_mut()
|
||||
.register_callback(Box::new(move |world, entity_map| {
|
||||
// Get the RectTransform component and access its inner Transform
|
||||
if let Some(rect) = world.borrow_mut::<RectTransform>().get_mut(entity) {
|
||||
let transform = rect.transform_mut();
|
||||
|
||||
// Set parent (might be None if unresolved)
|
||||
let resolved_parent =
|
||||
parent_ref.and_then(|r| entity_map.get(&r.file_id).copied());
|
||||
transform.set_parent(resolved_parent);
|
||||
|
||||
// Resolve any previously unresolved children
|
||||
let mut all_children = children_entities.clone();
|
||||
for child_file_id in &unresolved_children {
|
||||
if let Some(child_entity) = entity_map.get(child_file_id).copied() {
|
||||
all_children.push(child_entity);
|
||||
} else {
|
||||
eprintln!(
|
||||
"Warning: Could not resolve child RectTransform: {}",
|
||||
child_file_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
transform.set_children(all_children);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let transform = Transform {
|
||||
local_position,
|
||||
local_rotation,
|
||||
local_scale,
|
||||
parent: None,
|
||||
children: Vec::new(),
|
||||
};
|
||||
|
||||
Some(Self {
|
||||
transform,
|
||||
anchor_min,
|
||||
anchor_max,
|
||||
anchored_position,
|
||||
size_delta,
|
||||
pivot,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::types::EcsInsertable for RectTransform {
|
||||
fn insert_into_world(self, world: &mut sparsey::World, entity: Entity) {
|
||||
world.insert(entity, (self,));
|
||||
}
|
||||
}
|
||||
132
unity-parser/src/types/values.rs
Normal file
132
unity-parser/src/types/values.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
//! Unity-specific value types
|
||||
|
||||
use super::FileID;
|
||||
|
||||
// Re-export glam types for Unity compatibility
|
||||
pub use glam::{Quat as Quaternion, Vec2 as Vector2, Vec3 as Vector3, Vec4 as Color};
|
||||
|
||||
/// A reference to another Unity object by file ID
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::{FileRef, FileID};
|
||||
///
|
||||
/// let file_ref = FileRef::new(FileID::from_i64(12345));
|
||||
/// assert_eq!(file_ref.file_id.as_i64(), 12345);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct FileRef {
|
||||
pub file_id: FileID,
|
||||
}
|
||||
|
||||
impl FileRef {
|
||||
/// Create a new FileRef
|
||||
pub fn new(file_id: FileID) -> Self {
|
||||
Self { file_id }
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to an external Unity asset by GUID
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use cursebreaker_parser::ExternalRef;
|
||||
///
|
||||
/// let ext_ref = ExternalRef::new("abc123".to_string(), 2);
|
||||
/// assert_eq!(ext_ref.guid, "abc123");
|
||||
/// assert_eq!(ext_ref.type_id, 2);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ExternalRef {
|
||||
pub guid: String,
|
||||
pub type_id: i32,
|
||||
}
|
||||
|
||||
impl ExternalRef {
|
||||
/// Create a new ExternalRef
|
||||
pub fn new(guid: String, type_id: i32) -> Self {
|
||||
Self { guid, type_id }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_vector2_creation() {
|
||||
let v = Vector2::new(1.0, 2.0);
|
||||
assert_eq!(v.x, 1.0);
|
||||
assert_eq!(v.y, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vector2_zero() {
|
||||
let v = Vector2::ZERO;
|
||||
assert_eq!(v, Vector2::new(0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vector3_creation() {
|
||||
let v = Vector3::new(1.0, 2.0, 3.0);
|
||||
assert_eq!(v.x, 1.0);
|
||||
assert_eq!(v.y, 2.0);
|
||||
assert_eq!(v.z, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vector3_zero() {
|
||||
let v = Vector3::ZERO;
|
||||
assert_eq!(v, Vector3::new(0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vector3_directions() {
|
||||
assert_eq!(Vector3::Y, Vector3::new(0.0, 1.0, 0.0));
|
||||
assert_eq!(Vector3::Z, Vector3::new(0.0, 0.0, 1.0));
|
||||
assert_eq!(Vector3::X, Vector3::new(1.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_color_creation() {
|
||||
let c = Color::new(1.0, 0.5, 0.0, 1.0);
|
||||
assert_eq!(c.x, 1.0); // r
|
||||
assert_eq!(c.y, 0.5); // g
|
||||
assert_eq!(c.z, 0.0); // b
|
||||
assert_eq!(c.w, 1.0); // a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_color_presets() {
|
||||
assert_eq!(Color::ONE, Color::new(1.0, 1.0, 1.0, 1.0)); // white
|
||||
assert_eq!(Color::new(0.0, 0.0, 0.0, 1.0), Color::new(0.0, 0.0, 0.0, 1.0)); // black
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quaternion_creation() {
|
||||
let q = Quaternion::from_xyzw(0.0, 0.0, 0.0, 1.0);
|
||||
assert_eq!(q.x, 0.0);
|
||||
assert_eq!(q.w, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quaternion_identity() {
|
||||
let q = Quaternion::IDENTITY;
|
||||
assert_eq!(q, Quaternion::from_xyzw(0.0, 0.0, 0.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_ref_creation() {
|
||||
let file_ref = FileRef::new(FileID::from_i64(12345));
|
||||
assert_eq!(file_ref.file_id.as_i64(), 12345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_external_ref_creation() {
|
||||
let ext_ref = ExternalRef::new("abc123".to_string(), 2);
|
||||
assert_eq!(ext_ref.guid, "abc123");
|
||||
assert_eq!(ext_ref.type_id, 2);
|
||||
}
|
||||
}
|
||||
554
unity-parser/tests/integration_tests.rs
Normal file
554
unity-parser/tests/integration_tests.rs
Normal file
@@ -0,0 +1,554 @@
|
||||
//! Integration tests for parsing real Unity projects
|
||||
|
||||
use unity_parser::{GuidResolver, UnityFile};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Test project configuration
|
||||
struct TestProject {
|
||||
name: &'static str,
|
||||
repo_url: &'static str,
|
||||
branch: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl TestProject {
|
||||
const VR_HORROR: TestProject = TestProject {
|
||||
name: "VR_Horror_YouCantRun",
|
||||
repo_url: "https://github.com/Unity3D-Projects/VR_Horror_YouCantRun.git",
|
||||
branch: None,
|
||||
};
|
||||
|
||||
const PIRATE_PANIC: TestProject = TestProject {
|
||||
name: "PiratePanic",
|
||||
repo_url: "https://github.com/Unity-Technologies/PiratePanic.git",
|
||||
branch: None,
|
||||
};
|
||||
}
|
||||
|
||||
/// Statistics gathered during parsing
|
||||
#[derive(Debug, Default)]
|
||||
struct ParsingStats {
|
||||
total_files: usize,
|
||||
scenes: usize,
|
||||
prefabs: usize,
|
||||
assets: usize,
|
||||
errors: Vec<(PathBuf, String)>,
|
||||
total_entities: usize,
|
||||
total_documents: usize,
|
||||
parse_time_ms: u128,
|
||||
}
|
||||
|
||||
impl ParsingStats {
|
||||
fn print_summary(&self) {
|
||||
println!("\n{}", "=".repeat(60));
|
||||
println!("Parsing Statistics");
|
||||
println!("{}", "=".repeat(60));
|
||||
println!(" Total files found: {}", self.total_files);
|
||||
println!(" Scenes parsed: {}", self.scenes);
|
||||
println!(" Prefabs parsed: {}", self.prefabs);
|
||||
println!(" Assets parsed: {}", self.assets);
|
||||
println!(" Total entities: {}", self.total_entities);
|
||||
println!(" Total documents: {}", self.total_documents);
|
||||
println!(" Parse time: {} ms", self.parse_time_ms);
|
||||
|
||||
if !self.errors.is_empty() {
|
||||
println!("\n Errors encountered: {}", self.errors.len());
|
||||
println!("\n Error details:");
|
||||
for (path, error) in self.errors.iter().take(10) {
|
||||
println!(" - {}", path.display());
|
||||
println!(" Error: {}", error);
|
||||
}
|
||||
if self.errors.len() > 10 {
|
||||
println!(" ... and {} more errors", self.errors.len() - 10);
|
||||
}
|
||||
}
|
||||
|
||||
let success_rate = if self.total_files > 0 {
|
||||
((self.total_files - self.errors.len()) as f64 / self.total_files as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
println!("\n Success rate: {:.2}%", success_rate);
|
||||
println!("{}", "=".repeat(60));
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone a git repository for testing
|
||||
fn clone_test_project(project: &TestProject) -> std::io::Result<PathBuf> {
|
||||
let test_data_dir = PathBuf::from("test_data");
|
||||
std::fs::create_dir_all(&test_data_dir)?;
|
||||
|
||||
let project_path = test_data_dir.join(project.name);
|
||||
|
||||
// Skip if already cloned
|
||||
if project_path.exists() {
|
||||
println!("Project already cloned at: {}", project_path.display());
|
||||
return Ok(project_path);
|
||||
}
|
||||
|
||||
println!("Cloning {} from {}...", project.name, project.repo_url);
|
||||
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("clone");
|
||||
|
||||
if let Some(branch) = project.branch {
|
||||
cmd.arg("--branch").arg(branch);
|
||||
}
|
||||
|
||||
cmd.arg("--depth").arg("1"); // Shallow clone for speed
|
||||
cmd.arg(project.repo_url);
|
||||
cmd.arg(&project_path);
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
eprintln!("Git clone failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"Git clone failed",
|
||||
));
|
||||
}
|
||||
|
||||
println!("Successfully cloned to: {}", project_path.display());
|
||||
Ok(project_path)
|
||||
}
|
||||
|
||||
/// Recursively find all Unity files in a directory
|
||||
fn find_unity_files(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
if !dir.exists() || !dir.is_dir() {
|
||||
return files;
|
||||
}
|
||||
|
||||
fn visit_dir(dir: &Path, files: &mut Vec<PathBuf>) {
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
// Skip common Unity directories that don't contain source assets
|
||||
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, files);
|
||||
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
if ext == "unity" || ext == "prefab" || ext == "asset" {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit_dir(dir, &mut files);
|
||||
files
|
||||
}
|
||||
|
||||
/// Parse all Unity files in a project and collect statistics
|
||||
fn parse_project(project_path: &Path) -> ParsingStats {
|
||||
let mut stats = ParsingStats::default();
|
||||
|
||||
println!("\nFinding Unity files in {}...", project_path.display());
|
||||
let files = find_unity_files(project_path);
|
||||
stats.total_files = files.len();
|
||||
|
||||
println!("Found {} Unity files", files.len());
|
||||
println!("\nParsing files...");
|
||||
|
||||
let start_time = Instant::now();
|
||||
|
||||
for (i, file_path) in files.iter().enumerate() {
|
||||
// Print progress
|
||||
if (i + 1) % 10 == 0 || i == 0 {
|
||||
println!(
|
||||
" [{}/{}] Parsing: {}",
|
||||
i + 1,
|
||||
files.len(),
|
||||
file_path.file_name().unwrap().to_string_lossy()
|
||||
);
|
||||
}
|
||||
|
||||
match UnityFile::from_path(file_path) {
|
||||
Ok(unity_file) => match unity_file {
|
||||
UnityFile::Scene(scene) => {
|
||||
stats.scenes += 1;
|
||||
stats.total_entities += scene.entity_map.len();
|
||||
}
|
||||
UnityFile::Prefab(prefab) => {
|
||||
stats.prefabs += 1;
|
||||
stats.total_documents += prefab.documents.len();
|
||||
}
|
||||
UnityFile::Asset(asset) => {
|
||||
stats.assets += 1;
|
||||
stats.total_documents += asset.documents.len();
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
stats.errors.push((file_path.clone(), e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats.parse_time_ms = start_time.elapsed().as_millis();
|
||||
stats
|
||||
}
|
||||
|
||||
/// Test parsing a specific project
|
||||
fn test_project(project: &TestProject) {
|
||||
println!("\n{}", "=".repeat(60));
|
||||
println!("Testing: {}", project.name);
|
||||
println!("{}", "=".repeat(60));
|
||||
|
||||
// Clone the project
|
||||
let project_path = match clone_test_project(project) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to clone project: {}", e);
|
||||
eprintln!("Skipping project test (git may not be available)");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse all files
|
||||
let stats = parse_project(&project_path);
|
||||
|
||||
// Print summary
|
||||
stats.print_summary();
|
||||
|
||||
// Assert basic expectations
|
||||
assert!(
|
||||
stats.total_files > 0,
|
||||
"Should find at least some Unity files"
|
||||
);
|
||||
|
||||
// Allow some errors but not too many
|
||||
let error_rate = if stats.total_files > 0 {
|
||||
(stats.errors.len() as f64 / stats.total_files as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if error_rate > 50.0 {
|
||||
panic!(
|
||||
"Error rate too high: {:.2}% ({}/{})",
|
||||
error_rate, stats.errors.len(), stats.total_files
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test detailed parsing of specific file types
|
||||
fn test_detailed_parsing(project_path: &Path) {
|
||||
println!("\n{}", "=".repeat(60));
|
||||
println!("Detailed Parsing Tests");
|
||||
println!("{}", "=".repeat(60));
|
||||
|
||||
let files = find_unity_files(project_path);
|
||||
|
||||
// Test scene parsing
|
||||
if let Some(scene_file) = files.iter().find(|f| {
|
||||
f.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map_or(false, |e| e == "unity")
|
||||
}) {
|
||||
println!(
|
||||
"\nTesting scene parsing: {}",
|
||||
scene_file.file_name().unwrap().to_string_lossy()
|
||||
);
|
||||
match UnityFile::from_path(scene_file) {
|
||||
Ok(UnityFile::Scene(scene)) => {
|
||||
println!(" ✓ Successfully parsed scene");
|
||||
println!(" - Entities: {}", scene.entity_map.len());
|
||||
println!(" - Path: {}", scene.path.display());
|
||||
|
||||
// Try to access entities
|
||||
for (file_id, entity) in scene.entity_map.iter().take(3) {
|
||||
println!(" - FileID {} -> Entity {:?}", file_id, entity);
|
||||
}
|
||||
}
|
||||
Ok(_) => println!(" ✗ File was not parsed as scene"),
|
||||
Err(e) => println!(" ✗ Parse error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// Test prefab parsing and instancing
|
||||
if let Some(prefab_file) = files.iter().find(|f| {
|
||||
f.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map_or(false, |e| e == "prefab")
|
||||
}) {
|
||||
println!(
|
||||
"\nTesting prefab parsing: {}",
|
||||
prefab_file.file_name().unwrap().to_string_lossy()
|
||||
);
|
||||
match UnityFile::from_path(prefab_file) {
|
||||
Ok(UnityFile::Prefab(prefab)) => {
|
||||
println!(" ✓ Successfully parsed prefab");
|
||||
println!(" - Documents: {}", prefab.documents.len());
|
||||
println!(" - Path: {}", prefab.path.display());
|
||||
|
||||
// Test instantiation
|
||||
println!("\n Testing prefab instantiation:");
|
||||
let instance = prefab.instantiate();
|
||||
println!(
|
||||
" ✓ Created instance with {} remapped FileIDs",
|
||||
instance.file_id_map().len()
|
||||
);
|
||||
|
||||
// Test override system
|
||||
if let Some(first_doc) = prefab.documents.first() {
|
||||
let mut instance2 = prefab.instantiate();
|
||||
let result = instance2.override_value(
|
||||
first_doc.file_id,
|
||||
"m_Name",
|
||||
serde_yaml::Value::String("TestName".to_string()),
|
||||
);
|
||||
if result.is_ok() {
|
||||
println!(" ✓ Override system working");
|
||||
} else {
|
||||
println!(" - Override test: {}", result.unwrap_err());
|
||||
}
|
||||
}
|
||||
|
||||
// List document types
|
||||
let mut type_counts = std::collections::HashMap::new();
|
||||
for doc in &prefab.documents {
|
||||
*type_counts.entry(&doc.class_name).or_insert(0) += 1;
|
||||
}
|
||||
println!(" - Component types:");
|
||||
for (class_name, count) in type_counts.iter() {
|
||||
println!(" - {}: {}", class_name, count);
|
||||
}
|
||||
}
|
||||
Ok(_) => println!(" ✗ File was not parsed as prefab"),
|
||||
Err(e) => println!(" ✗ Parse error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vr_horror_project() {
|
||||
test_project(&TestProject::VR_HORROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Ignore by default, run with --ignored to test
|
||||
fn test_pirate_panic_project() {
|
||||
test_project(&TestProject::PIRATE_PANIC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vr_horror_detailed() {
|
||||
let project_path = match clone_test_project(&TestProject::VR_HORROR) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to clone project: {}", e);
|
||||
eprintln!("Skipping detailed test (git may not be available)");
|
||||
return;
|
||||
}
|
||||
};
|
||||
test_detailed_parsing(&project_path);
|
||||
}
|
||||
|
||||
/// Benchmark parsing performance
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn benchmark_parsing() {
|
||||
let project_path = match clone_test_project(&TestProject::VR_HORROR) {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
eprintln!("Skipping benchmark (git not available)");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
println!("\n{}", "=".repeat(60));
|
||||
println!("Parsing Performance Benchmark");
|
||||
println!("{}", "=".repeat(60));
|
||||
|
||||
let files = find_unity_files(&project_path);
|
||||
let total_size: u64 = files
|
||||
.iter()
|
||||
.filter_map(|f| std::fs::metadata(f).ok())
|
||||
.map(|m| m.len())
|
||||
.sum();
|
||||
|
||||
println!("Total files: {}", files.len());
|
||||
println!("Total size: {} KB", total_size / 1024);
|
||||
|
||||
let start = Instant::now();
|
||||
let stats = parse_project(&project_path);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
println!("\nParsing completed in {:?}", elapsed);
|
||||
println!(
|
||||
"Average time per file: {:.2} ms",
|
||||
elapsed.as_millis() as f64 / files.len() as f64
|
||||
);
|
||||
println!(
|
||||
"Throughput: {:.2} files/sec",
|
||||
files.len() as f64 / elapsed.as_secs_f64()
|
||||
);
|
||||
println!(
|
||||
"Throughput: {:.2} KB/sec",
|
||||
(total_size / 1024) as f64 / elapsed.as_secs_f64()
|
||||
);
|
||||
|
||||
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));
|
||||
}
|
||||
197
unity-parser/tests/macro_tests.rs
Normal file
197
unity-parser/tests/macro_tests.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! Tests for the #[derive(UnityComponent)] macro
|
||||
|
||||
use unity_parser::{ComponentContext, FileID, UnityComponent};
|
||||
|
||||
/// Test component matching the PlaySFX script from VR_Horror_YouCantRun
|
||||
#[derive(Debug, Clone, UnityComponent)]
|
||||
#[unity_class("PlaySFX")]
|
||||
struct PlaySFX {
|
||||
#[unity_field("volume")]
|
||||
volume: f64,
|
||||
|
||||
#[unity_field("startTime")]
|
||||
start_time: f64,
|
||||
|
||||
#[unity_field("endTime")]
|
||||
end_time: f64,
|
||||
|
||||
#[unity_field("isLoop")]
|
||||
is_loop: bool,
|
||||
}
|
||||
|
||||
/// Test component with different field types
|
||||
#[derive(Debug, Clone, UnityComponent)]
|
||||
#[unity_class("TestComponent")]
|
||||
struct TestComponent {
|
||||
#[unity_field("floatValue")]
|
||||
float_value: f32,
|
||||
|
||||
#[unity_field("intValue")]
|
||||
int_value: i32,
|
||||
|
||||
#[unity_field("stringValue")]
|
||||
string_value: String,
|
||||
|
||||
#[unity_field("boolValue")]
|
||||
bool_value: bool,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_play_sfx_parsing() {
|
||||
let yaml_str = r#"
|
||||
volume: 0.75
|
||||
startTime: 1.5
|
||||
endTime: 3.0
|
||||
isLoop: 1
|
||||
"#;
|
||||
|
||||
let yaml: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
|
||||
let mapping = yaml.as_mapping().unwrap();
|
||||
|
||||
let ctx = ComponentContext {
|
||||
type_id: 114,
|
||||
file_id: FileID::from_i64(12345),
|
||||
class_name: "PlaySFX",
|
||||
entity: None,
|
||||
linking_ctx: None,
|
||||
yaml: mapping,
|
||||
};
|
||||
|
||||
let result = PlaySFX::parse(mapping, &ctx);
|
||||
assert!(result.is_some(), "Failed to parse PlaySFX component");
|
||||
|
||||
let component = result.unwrap();
|
||||
assert_eq!(component.volume, 0.75);
|
||||
assert_eq!(component.start_time, 1.5);
|
||||
assert_eq!(component.end_time, 3.0);
|
||||
assert_eq!(component.is_loop, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_play_sfx_default_values() {
|
||||
// Test with missing fields (should use Default::default())
|
||||
let yaml_str = r#"
|
||||
volume: 0.5
|
||||
"#;
|
||||
|
||||
let yaml: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
|
||||
let mapping = yaml.as_mapping().unwrap();
|
||||
|
||||
let ctx = ComponentContext {
|
||||
type_id: 114,
|
||||
file_id: FileID::from_i64(12345),
|
||||
class_name: "PlaySFX",
|
||||
entity: None,
|
||||
linking_ctx: None,
|
||||
yaml: mapping,
|
||||
};
|
||||
|
||||
let result = PlaySFX::parse(mapping, &ctx);
|
||||
assert!(result.is_some(), "Failed to parse PlaySFX component with defaults");
|
||||
|
||||
let component = result.unwrap();
|
||||
assert_eq!(component.volume, 0.5);
|
||||
assert_eq!(component.start_time, 0.0); // Default for f64
|
||||
assert_eq!(component.end_time, 0.0); // Default for f64
|
||||
assert_eq!(component.is_loop, false); // Default for bool
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_test_component_parsing() {
|
||||
let yaml_str = r#"
|
||||
floatValue: 3.14
|
||||
intValue: 42
|
||||
stringValue: "Hello, Unity!"
|
||||
boolValue: 1
|
||||
"#;
|
||||
|
||||
let yaml: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
|
||||
let mapping = yaml.as_mapping().unwrap();
|
||||
|
||||
let ctx = ComponentContext {
|
||||
type_id: 114,
|
||||
file_id: FileID::from_i64(67890),
|
||||
class_name: "TestComponent",
|
||||
entity: None,
|
||||
linking_ctx: None,
|
||||
yaml: mapping,
|
||||
};
|
||||
|
||||
let result = TestComponent::parse(mapping, &ctx);
|
||||
assert!(result.is_some(), "Failed to parse TestComponent");
|
||||
|
||||
let component = result.unwrap();
|
||||
assert!((component.float_value - 3.14_f32).abs() < 0.001);
|
||||
assert_eq!(component.int_value, 42);
|
||||
assert_eq!(component.string_value, "Hello, Unity!");
|
||||
assert_eq!(component.bool_value, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_registration() {
|
||||
// Verify that components are registered in the inventory
|
||||
let mut found_play_sfx = false;
|
||||
let mut found_test_component = false;
|
||||
|
||||
for reg in inventory::iter::<unity_parser::ComponentRegistration> {
|
||||
if reg.class_name == "PlaySFX" {
|
||||
found_play_sfx = true;
|
||||
assert_eq!(reg.type_id, 114);
|
||||
}
|
||||
if reg.class_name == "TestComponent" {
|
||||
found_test_component = true;
|
||||
assert_eq!(reg.type_id, 114);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
found_play_sfx,
|
||||
"PlaySFX component was not registered in inventory"
|
||||
);
|
||||
assert!(
|
||||
found_test_component,
|
||||
"TestComponent was not registered in inventory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_component_registration_parser() {
|
||||
// Test that the registered parser function works
|
||||
let yaml_str = r#"
|
||||
volume: 0.8
|
||||
startTime: 2.0
|
||||
endTime: 4.0
|
||||
isLoop: 0
|
||||
"#;
|
||||
|
||||
let yaml: serde_yaml::Value = serde_yaml::from_str(yaml_str).unwrap();
|
||||
let mapping = yaml.as_mapping().unwrap();
|
||||
|
||||
let ctx = ComponentContext {
|
||||
type_id: 114,
|
||||
file_id: FileID::from_i64(11111),
|
||||
class_name: "PlaySFX",
|
||||
entity: None,
|
||||
linking_ctx: None,
|
||||
yaml: mapping,
|
||||
};
|
||||
|
||||
// Find the PlaySFX registration and call its parser
|
||||
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");
|
||||
|
||||
// Downcast to verify it's the right type
|
||||
let boxed = result.unwrap();
|
||||
assert!(
|
||||
boxed.downcast_ref::<PlaySFX>().is_some(),
|
||||
"Parsed component is not PlaySFX type"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
panic!("PlaySFX registration not found");
|
||||
}
|
||||
83
unity-parser/tests/test_guid_resolution.rs.disabled
Normal file
83
unity-parser/tests/test_guid_resolution.rs.disabled
Normal file
@@ -0,0 +1,83 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user