unityflow 0.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unityflow/__init__.py +167 -0
- unityflow/asset_resolver.py +636 -0
- unityflow/asset_tracker.py +1687 -0
- unityflow/cli.py +2317 -0
- unityflow/data/__init__.py +1 -0
- unityflow/data/class_ids.json +336 -0
- unityflow/diff.py +234 -0
- unityflow/fast_parser.py +676 -0
- unityflow/formats.py +1558 -0
- unityflow/git_utils.py +307 -0
- unityflow/hierarchy.py +1672 -0
- unityflow/merge.py +226 -0
- unityflow/meta_generator.py +1291 -0
- unityflow/normalizer.py +529 -0
- unityflow/parser.py +698 -0
- unityflow/query.py +406 -0
- unityflow/script_parser.py +717 -0
- unityflow/sprite.py +378 -0
- unityflow/validator.py +783 -0
- unityflow-0.3.4.dist-info/METADATA +293 -0
- unityflow-0.3.4.dist-info/RECORD +25 -0
- unityflow-0.3.4.dist-info/WHEEL +5 -0
- unityflow-0.3.4.dist-info/entry_points.txt +2 -0
- unityflow-0.3.4.dist-info/licenses/LICENSE +21 -0
- unityflow-0.3.4.dist-info/top_level.txt +1 -0
unityflow/validator.py
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
"""Unity Prefab Validator.
|
|
2
|
+
|
|
3
|
+
Validates Unity YAML files for structural correctness and common issues.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from unityflow.parser import CLASS_IDS, UnityYAMLDocument, UnityYAMLObject
|
|
15
|
+
|
|
16
|
+
# Valid GUID pattern: 32 hexadecimal characters
|
|
17
|
+
GUID_PATTERN = re.compile(r"^[0-9a-fA-F]{32}$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_valid_guid(guid: Any) -> bool:
|
|
21
|
+
"""Check if a value is a valid Unity GUID.
|
|
22
|
+
|
|
23
|
+
Unity GUIDs are 32 hexadecimal characters.
|
|
24
|
+
"""
|
|
25
|
+
if guid is None:
|
|
26
|
+
return True # No guid is valid (internal reference)
|
|
27
|
+
if not isinstance(guid, str):
|
|
28
|
+
return False # GUID must be a string
|
|
29
|
+
return bool(GUID_PATTERN.match(guid))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Severity(Enum):
|
|
33
|
+
"""Validation issue severity."""
|
|
34
|
+
|
|
35
|
+
ERROR = "error"
|
|
36
|
+
WARNING = "warning"
|
|
37
|
+
INFO = "info"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ValidationIssue:
|
|
42
|
+
"""A single validation issue."""
|
|
43
|
+
|
|
44
|
+
severity: Severity
|
|
45
|
+
message: str
|
|
46
|
+
file_id: int | None = None
|
|
47
|
+
property_path: str | None = None
|
|
48
|
+
suggestion: str | None = None
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
parts = [f"[{self.severity.value.upper()}]"]
|
|
52
|
+
if self.file_id is not None:
|
|
53
|
+
parts.append(f"(fileID: {self.file_id})")
|
|
54
|
+
parts.append(self.message)
|
|
55
|
+
if self.property_path:
|
|
56
|
+
parts.append(f"at {self.property_path}")
|
|
57
|
+
if self.suggestion:
|
|
58
|
+
parts.append(f"- {self.suggestion}")
|
|
59
|
+
return " ".join(parts)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ValidationResult:
|
|
64
|
+
"""Result of validating a Unity file."""
|
|
65
|
+
|
|
66
|
+
path: str
|
|
67
|
+
is_valid: bool
|
|
68
|
+
issues: list[ValidationIssue] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def errors(self) -> list[ValidationIssue]:
|
|
72
|
+
"""Get all error-level issues."""
|
|
73
|
+
return [i for i in self.issues if i.severity == Severity.ERROR]
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def warnings(self) -> list[ValidationIssue]:
|
|
77
|
+
"""Get all warning-level issues."""
|
|
78
|
+
return [i for i in self.issues if i.severity == Severity.WARNING]
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def infos(self) -> list[ValidationIssue]:
|
|
82
|
+
"""Get all info-level issues."""
|
|
83
|
+
return [i for i in self.issues if i.severity == Severity.INFO]
|
|
84
|
+
|
|
85
|
+
def __str__(self) -> str:
|
|
86
|
+
lines = [f"Validation result for {self.path}:"]
|
|
87
|
+
lines.append(f" Status: {'VALID' if self.is_valid else 'INVALID'}")
|
|
88
|
+
lines.append(f" Errors: {len(self.errors)}, Warnings: {len(self.warnings)}, Info: {len(self.infos)}")
|
|
89
|
+
|
|
90
|
+
if self.issues:
|
|
91
|
+
lines.append("")
|
|
92
|
+
for issue in self.issues:
|
|
93
|
+
lines.append(f" {issue}")
|
|
94
|
+
|
|
95
|
+
return "\n".join(lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PrefabValidator:
|
|
99
|
+
"""Validates Unity prefab files."""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
check_references: bool = True,
|
|
104
|
+
check_structure: bool = True,
|
|
105
|
+
check_duplicates: bool = True,
|
|
106
|
+
strict: bool = False,
|
|
107
|
+
):
|
|
108
|
+
"""Initialize the validator.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
check_references: Validate internal fileID references
|
|
112
|
+
check_structure: Validate document structure
|
|
113
|
+
check_duplicates: Check for duplicate fileIDs
|
|
114
|
+
strict: Treat warnings as errors
|
|
115
|
+
"""
|
|
116
|
+
self.check_references = check_references
|
|
117
|
+
self.check_structure = check_structure
|
|
118
|
+
self.check_duplicates = check_duplicates
|
|
119
|
+
self.strict = strict
|
|
120
|
+
|
|
121
|
+
def validate_file(self, path: str | Path) -> ValidationResult:
|
|
122
|
+
"""Validate a Unity YAML file.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
path: Path to the file to validate
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
ValidationResult with any issues found
|
|
129
|
+
"""
|
|
130
|
+
path = Path(path)
|
|
131
|
+
issues: list[ValidationIssue] = []
|
|
132
|
+
|
|
133
|
+
# Check file exists and is readable
|
|
134
|
+
if not path.exists():
|
|
135
|
+
issues.append(
|
|
136
|
+
ValidationIssue(
|
|
137
|
+
severity=Severity.ERROR,
|
|
138
|
+
message=f"File not found: {path}",
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
return ValidationResult(path=str(path), is_valid=False, issues=issues)
|
|
142
|
+
|
|
143
|
+
# Try to parse the file
|
|
144
|
+
try:
|
|
145
|
+
doc = UnityYAMLDocument.load(path)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
issues.append(
|
|
148
|
+
ValidationIssue(
|
|
149
|
+
severity=Severity.ERROR,
|
|
150
|
+
message=f"Failed to parse file: {e}",
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return ValidationResult(path=str(path), is_valid=False, issues=issues)
|
|
154
|
+
|
|
155
|
+
# Run validation checks
|
|
156
|
+
issues.extend(self._validate_document(doc))
|
|
157
|
+
|
|
158
|
+
# Determine validity
|
|
159
|
+
is_valid = not any(i.severity == Severity.ERROR for i in issues)
|
|
160
|
+
if self.strict:
|
|
161
|
+
is_valid = is_valid and not any(i.severity == Severity.WARNING for i in issues)
|
|
162
|
+
|
|
163
|
+
return ValidationResult(path=str(path), is_valid=is_valid, issues=issues)
|
|
164
|
+
|
|
165
|
+
def validate_content(self, content: str, label: str = "<content>") -> ValidationResult:
|
|
166
|
+
"""Validate Unity YAML content from a string.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
content: The YAML content to validate
|
|
170
|
+
label: Label for the content in error messages
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
ValidationResult with any issues found
|
|
174
|
+
"""
|
|
175
|
+
issues: list[ValidationIssue] = []
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
doc = UnityYAMLDocument.parse(content)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
issues.append(
|
|
181
|
+
ValidationIssue(
|
|
182
|
+
severity=Severity.ERROR,
|
|
183
|
+
message=f"Failed to parse content: {e}",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
return ValidationResult(path=label, is_valid=False, issues=issues)
|
|
187
|
+
|
|
188
|
+
issues.extend(self._validate_document(doc))
|
|
189
|
+
|
|
190
|
+
is_valid = not any(i.severity == Severity.ERROR for i in issues)
|
|
191
|
+
if self.strict:
|
|
192
|
+
is_valid = is_valid and not any(i.severity == Severity.WARNING for i in issues)
|
|
193
|
+
|
|
194
|
+
return ValidationResult(path=label, is_valid=is_valid, issues=issues)
|
|
195
|
+
|
|
196
|
+
def _validate_document(self, doc: UnityYAMLDocument) -> list[ValidationIssue]:
|
|
197
|
+
"""Validate a parsed document."""
|
|
198
|
+
issues: list[ValidationIssue] = []
|
|
199
|
+
|
|
200
|
+
if not doc.objects:
|
|
201
|
+
issues.append(
|
|
202
|
+
ValidationIssue(
|
|
203
|
+
severity=Severity.WARNING,
|
|
204
|
+
message="Document contains no objects",
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
return issues
|
|
208
|
+
|
|
209
|
+
# Check for duplicate fileIDs
|
|
210
|
+
if self.check_duplicates:
|
|
211
|
+
issues.extend(self._check_duplicate_file_ids(doc))
|
|
212
|
+
|
|
213
|
+
# Build fileID index for reference checking
|
|
214
|
+
file_id_index = {obj.file_id for obj in doc.objects}
|
|
215
|
+
|
|
216
|
+
# Validate each object
|
|
217
|
+
for obj in doc.objects:
|
|
218
|
+
if self.check_structure:
|
|
219
|
+
issues.extend(self._validate_object_structure(obj))
|
|
220
|
+
|
|
221
|
+
if self.check_references:
|
|
222
|
+
issues.extend(self._validate_object_references(obj, file_id_index))
|
|
223
|
+
|
|
224
|
+
# Validate SceneRoots for scene files
|
|
225
|
+
if self.check_structure:
|
|
226
|
+
issues.extend(self._validate_scene_roots(doc))
|
|
227
|
+
|
|
228
|
+
return issues
|
|
229
|
+
|
|
230
|
+
def _check_duplicate_file_ids(self, doc: UnityYAMLDocument) -> list[ValidationIssue]:
|
|
231
|
+
"""Check for duplicate fileIDs."""
|
|
232
|
+
issues: list[ValidationIssue] = []
|
|
233
|
+
seen: dict[int, int] = {}
|
|
234
|
+
|
|
235
|
+
for i, obj in enumerate(doc.objects):
|
|
236
|
+
if obj.file_id in seen:
|
|
237
|
+
issues.append(
|
|
238
|
+
ValidationIssue(
|
|
239
|
+
severity=Severity.ERROR,
|
|
240
|
+
file_id=obj.file_id,
|
|
241
|
+
message=f"Duplicate fileID found (first at index {seen[obj.file_id]}, duplicate at index {i})",
|
|
242
|
+
suggestion="Each object must have a unique fileID",
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
seen[obj.file_id] = i
|
|
247
|
+
|
|
248
|
+
return issues
|
|
249
|
+
|
|
250
|
+
def _validate_object_structure(self, obj: UnityYAMLObject) -> list[ValidationIssue]:
|
|
251
|
+
"""Validate the structure of a single object."""
|
|
252
|
+
issues: list[ValidationIssue] = []
|
|
253
|
+
|
|
254
|
+
# Check for valid class ID
|
|
255
|
+
if obj.class_id <= 0:
|
|
256
|
+
issues.append(
|
|
257
|
+
ValidationIssue(
|
|
258
|
+
severity=Severity.ERROR,
|
|
259
|
+
file_id=obj.file_id,
|
|
260
|
+
message=f"Invalid class ID: {obj.class_id}",
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Check for empty data
|
|
265
|
+
if not obj.data:
|
|
266
|
+
if not obj.stripped:
|
|
267
|
+
issues.append(
|
|
268
|
+
ValidationIssue(
|
|
269
|
+
severity=Severity.WARNING,
|
|
270
|
+
file_id=obj.file_id,
|
|
271
|
+
message="Object has no data",
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Check root key matches expected class
|
|
276
|
+
root_key = obj.root_key
|
|
277
|
+
if root_key:
|
|
278
|
+
expected = CLASS_IDS.get(obj.class_id)
|
|
279
|
+
if expected and root_key != expected:
|
|
280
|
+
msg = f"Root key '{root_key}' doesn't match expected '{expected}' for class {obj.class_id}"
|
|
281
|
+
issues.append(
|
|
282
|
+
ValidationIssue(
|
|
283
|
+
severity=Severity.WARNING,
|
|
284
|
+
file_id=obj.file_id,
|
|
285
|
+
message=msg,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
# Validate classId matches root_key (detect mismatched classIds)
|
|
290
|
+
issues.extend(self._validate_class_id_root_key_match(obj))
|
|
291
|
+
|
|
292
|
+
# Class-specific validation
|
|
293
|
+
content = obj.get_content()
|
|
294
|
+
if content:
|
|
295
|
+
if obj.class_id == 1: # GameObject
|
|
296
|
+
issues.extend(self._validate_game_object(obj, content))
|
|
297
|
+
elif obj.class_id == 4: # Transform
|
|
298
|
+
issues.extend(self._validate_transform(obj, content))
|
|
299
|
+
elif obj.class_id == 1001: # PrefabInstance
|
|
300
|
+
issues.extend(self._validate_prefab_instance(obj, content))
|
|
301
|
+
|
|
302
|
+
return issues
|
|
303
|
+
|
|
304
|
+
def _validate_game_object(self, obj: UnityYAMLObject, content: dict[str, Any]) -> list[ValidationIssue]:
|
|
305
|
+
"""Validate a GameObject object."""
|
|
306
|
+
issues: list[ValidationIssue] = []
|
|
307
|
+
|
|
308
|
+
# Check required fields
|
|
309
|
+
if "m_Name" not in content:
|
|
310
|
+
issues.append(
|
|
311
|
+
ValidationIssue(
|
|
312
|
+
severity=Severity.WARNING,
|
|
313
|
+
file_id=obj.file_id,
|
|
314
|
+
message="GameObject missing m_Name",
|
|
315
|
+
property_path="GameObject.m_Name",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if "m_Component" not in content:
|
|
320
|
+
issues.append(
|
|
321
|
+
ValidationIssue(
|
|
322
|
+
severity=Severity.INFO,
|
|
323
|
+
file_id=obj.file_id,
|
|
324
|
+
message="GameObject has no components",
|
|
325
|
+
property_path="GameObject.m_Component",
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return issues
|
|
330
|
+
|
|
331
|
+
def _validate_transform(self, obj: UnityYAMLObject, content: dict[str, Any]) -> list[ValidationIssue]:
|
|
332
|
+
"""Validate a Transform object."""
|
|
333
|
+
issues: list[ValidationIssue] = []
|
|
334
|
+
|
|
335
|
+
# Check for required transform properties
|
|
336
|
+
for prop in ["m_LocalPosition", "m_LocalRotation", "m_LocalScale"]:
|
|
337
|
+
if prop not in content:
|
|
338
|
+
issues.append(
|
|
339
|
+
ValidationIssue(
|
|
340
|
+
severity=Severity.WARNING,
|
|
341
|
+
file_id=obj.file_id,
|
|
342
|
+
message=f"Transform missing {prop}",
|
|
343
|
+
property_path=f"Transform.{prop}",
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Validate quaternion if present
|
|
348
|
+
rotation = content.get("m_LocalRotation")
|
|
349
|
+
if rotation and isinstance(rotation, dict):
|
|
350
|
+
issues.extend(self._validate_quaternion(obj, rotation, "m_LocalRotation"))
|
|
351
|
+
|
|
352
|
+
return issues
|
|
353
|
+
|
|
354
|
+
def _validate_quaternion(
|
|
355
|
+
self,
|
|
356
|
+
obj: UnityYAMLObject,
|
|
357
|
+
q: dict[str, Any],
|
|
358
|
+
property_name: str,
|
|
359
|
+
) -> list[ValidationIssue]:
|
|
360
|
+
"""Validate a quaternion value."""
|
|
361
|
+
issues: list[ValidationIssue] = []
|
|
362
|
+
|
|
363
|
+
required = {"x", "y", "z", "w"}
|
|
364
|
+
missing = required - set(q.keys())
|
|
365
|
+
if missing:
|
|
366
|
+
issues.append(
|
|
367
|
+
ValidationIssue(
|
|
368
|
+
severity=Severity.ERROR,
|
|
369
|
+
file_id=obj.file_id,
|
|
370
|
+
message=f"Quaternion missing components: {missing}",
|
|
371
|
+
property_path=property_name,
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
return issues
|
|
375
|
+
|
|
376
|
+
# Check for valid values
|
|
377
|
+
try:
|
|
378
|
+
x = float(q["x"])
|
|
379
|
+
y = float(q["y"])
|
|
380
|
+
z = float(q["z"])
|
|
381
|
+
w = float(q["w"])
|
|
382
|
+
|
|
383
|
+
# Check unit length (with tolerance)
|
|
384
|
+
length = (x * x + y * y + z * z + w * w) ** 0.5
|
|
385
|
+
if abs(length - 1.0) > 0.01:
|
|
386
|
+
issues.append(
|
|
387
|
+
ValidationIssue(
|
|
388
|
+
severity=Severity.WARNING,
|
|
389
|
+
file_id=obj.file_id,
|
|
390
|
+
message=f"Quaternion is not normalized (length={length:.4f})",
|
|
391
|
+
property_path=property_name,
|
|
392
|
+
suggestion="Consider normalizing to unit length",
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
except (TypeError, ValueError) as e:
|
|
396
|
+
issues.append(
|
|
397
|
+
ValidationIssue(
|
|
398
|
+
severity=Severity.ERROR,
|
|
399
|
+
file_id=obj.file_id,
|
|
400
|
+
message=f"Invalid quaternion values: {e}",
|
|
401
|
+
property_path=property_name,
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
return issues
|
|
406
|
+
|
|
407
|
+
def _validate_class_id_root_key_match(self, obj: UnityYAMLObject) -> list[ValidationIssue]:
|
|
408
|
+
"""Validate that classId matches the root key in the data.
|
|
409
|
+
|
|
410
|
+
This detects cases where LLM generated incorrect classIds,
|
|
411
|
+
such as using SceneRoots classId (1660057539) for Light2D.
|
|
412
|
+
"""
|
|
413
|
+
issues: list[ValidationIssue] = []
|
|
414
|
+
root_key = obj.root_key
|
|
415
|
+
|
|
416
|
+
if not root_key:
|
|
417
|
+
return issues
|
|
418
|
+
|
|
419
|
+
# Special case: SceneRoots classId (1660057539) must have SceneRoots root key
|
|
420
|
+
if obj.class_id == 1660057539 and root_key != "SceneRoots":
|
|
421
|
+
msg = f"ClassID 1660057539 (SceneRoots) used for '{root_key}' - Unity will fail to cast"
|
|
422
|
+
suggestion = f"'{root_key}' needs a different classId. Check Unity docs."
|
|
423
|
+
issues.append(
|
|
424
|
+
ValidationIssue(
|
|
425
|
+
severity=Severity.ERROR,
|
|
426
|
+
file_id=obj.file_id,
|
|
427
|
+
message=msg,
|
|
428
|
+
property_path=root_key,
|
|
429
|
+
suggestion=suggestion,
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# Known classId -> root_key mismatches that cause Unity errors
|
|
434
|
+
known_class_ids = {
|
|
435
|
+
1: "GameObject",
|
|
436
|
+
4: "Transform",
|
|
437
|
+
20: "Camera",
|
|
438
|
+
23: "MeshRenderer",
|
|
439
|
+
33: "MeshFilter",
|
|
440
|
+
54: "Rigidbody",
|
|
441
|
+
65: "BoxCollider",
|
|
442
|
+
81: "AudioListener",
|
|
443
|
+
82: "AudioSource",
|
|
444
|
+
114: "MonoBehaviour",
|
|
445
|
+
124: "Behaviour",
|
|
446
|
+
212: "SpriteRenderer",
|
|
447
|
+
222: "CanvasRenderer",
|
|
448
|
+
223: "Canvas",
|
|
449
|
+
224: "RectTransform",
|
|
450
|
+
225: "CanvasGroup",
|
|
451
|
+
1001: "PrefabInstance",
|
|
452
|
+
1660057539: "SceneRoots",
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
expected_root_key = known_class_ids.get(obj.class_id)
|
|
456
|
+
if expected_root_key and root_key != expected_root_key:
|
|
457
|
+
# Only error for well-known types where mismatch is definitely wrong
|
|
458
|
+
if obj.class_id in (1, 4, 224, 1001, 1660057539): # Critical types
|
|
459
|
+
msg = f"ClassID {obj.class_id} expects '{expected_root_key}' but found '{root_key}'"
|
|
460
|
+
suggestion = f"Change classId to match '{root_key}' or root key to '{expected_root_key}'"
|
|
461
|
+
issues.append(
|
|
462
|
+
ValidationIssue(
|
|
463
|
+
severity=Severity.ERROR,
|
|
464
|
+
file_id=obj.file_id,
|
|
465
|
+
message=msg,
|
|
466
|
+
property_path=root_key,
|
|
467
|
+
suggestion=suggestion,
|
|
468
|
+
)
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
return issues
|
|
472
|
+
|
|
473
|
+
def _validate_prefab_instance(self, obj: UnityYAMLObject, content: dict[str, Any]) -> list[ValidationIssue]:
|
|
474
|
+
"""Validate a PrefabInstance object."""
|
|
475
|
+
issues: list[ValidationIssue] = []
|
|
476
|
+
|
|
477
|
+
# Check for m_SourcePrefab
|
|
478
|
+
source = content.get("m_SourcePrefab")
|
|
479
|
+
if not source:
|
|
480
|
+
issues.append(
|
|
481
|
+
ValidationIssue(
|
|
482
|
+
severity=Severity.WARNING,
|
|
483
|
+
file_id=obj.file_id,
|
|
484
|
+
message="PrefabInstance missing m_SourcePrefab",
|
|
485
|
+
property_path="PrefabInstance.m_SourcePrefab",
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
elif isinstance(source, dict):
|
|
489
|
+
if not source.get("guid"):
|
|
490
|
+
issues.append(
|
|
491
|
+
ValidationIssue(
|
|
492
|
+
severity=Severity.WARNING,
|
|
493
|
+
file_id=obj.file_id,
|
|
494
|
+
message="m_SourcePrefab has no GUID",
|
|
495
|
+
property_path="PrefabInstance.m_SourcePrefab.guid",
|
|
496
|
+
suggestion="Prefab reference may be broken",
|
|
497
|
+
)
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
return issues
|
|
501
|
+
|
|
502
|
+
def _validate_object_references(
|
|
503
|
+
self,
|
|
504
|
+
obj: UnityYAMLObject,
|
|
505
|
+
file_id_index: set[int],
|
|
506
|
+
) -> list[ValidationIssue]:
|
|
507
|
+
"""Validate fileID references within an object."""
|
|
508
|
+
issues: list[ValidationIssue] = []
|
|
509
|
+
|
|
510
|
+
def check_reference(value: Any, path: str) -> None:
|
|
511
|
+
if isinstance(value, dict):
|
|
512
|
+
# Check if this is a file reference
|
|
513
|
+
if "fileID" in value:
|
|
514
|
+
file_id = value.get("fileID")
|
|
515
|
+
guid = value.get("guid")
|
|
516
|
+
ref_type = value.get("type")
|
|
517
|
+
|
|
518
|
+
# Check GUID format if present
|
|
519
|
+
if guid is not None and not is_valid_guid(guid):
|
|
520
|
+
issues.append(
|
|
521
|
+
ValidationIssue(
|
|
522
|
+
severity=Severity.ERROR,
|
|
523
|
+
file_id=obj.file_id,
|
|
524
|
+
message=f"Invalid GUID format: {guid!r} (expected 32 hex chars or None)",
|
|
525
|
+
property_path=path,
|
|
526
|
+
suggestion="GUID must be a 32 character hexadecimal string",
|
|
527
|
+
)
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
# Check reference validity based on type
|
|
531
|
+
if file_id and file_id != 0:
|
|
532
|
+
is_internal_ref = not guid or ref_type == 0
|
|
533
|
+
|
|
534
|
+
if is_internal_ref:
|
|
535
|
+
# Internal reference - must exist in current file
|
|
536
|
+
if file_id not in file_id_index:
|
|
537
|
+
# Unity builtin assets use special fileIDs (typically < 100000)
|
|
538
|
+
# with type: 0 or type: 3, but should have a valid guid
|
|
539
|
+
if ref_type == 0 and not guid:
|
|
540
|
+
msg = f"Broken ref: fileID {file_id} with type:0 not in file"
|
|
541
|
+
sug = "Builtin assets need guid. Ensure target exists."
|
|
542
|
+
issues.append(
|
|
543
|
+
ValidationIssue(
|
|
544
|
+
severity=Severity.ERROR,
|
|
545
|
+
file_id=obj.file_id,
|
|
546
|
+
message=msg,
|
|
547
|
+
property_path=path,
|
|
548
|
+
suggestion=sug,
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
else:
|
|
552
|
+
msg = f"Internal ref to non-existent fileID: {file_id}"
|
|
553
|
+
sug = "Reference may be broken or external"
|
|
554
|
+
issues.append(
|
|
555
|
+
ValidationIssue(
|
|
556
|
+
severity=Severity.WARNING,
|
|
557
|
+
file_id=obj.file_id,
|
|
558
|
+
message=msg,
|
|
559
|
+
property_path=path,
|
|
560
|
+
suggestion=sug,
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# Recurse into dict values
|
|
565
|
+
for key, val in value.items():
|
|
566
|
+
check_reference(val, f"{path}.{key}")
|
|
567
|
+
|
|
568
|
+
elif isinstance(value, list):
|
|
569
|
+
for i, item in enumerate(value):
|
|
570
|
+
check_reference(item, f"{path}[{i}]")
|
|
571
|
+
|
|
572
|
+
if obj.data:
|
|
573
|
+
check_reference(obj.data, obj.root_key or "root")
|
|
574
|
+
|
|
575
|
+
return issues
|
|
576
|
+
|
|
577
|
+
def _validate_scene_roots(
|
|
578
|
+
self,
|
|
579
|
+
doc: UnityYAMLDocument,
|
|
580
|
+
) -> list[ValidationIssue]:
|
|
581
|
+
"""Validate SceneRoots object for scene files."""
|
|
582
|
+
issues: list[ValidationIssue] = []
|
|
583
|
+
|
|
584
|
+
# Find SceneRoots object (class_id 1660057539)
|
|
585
|
+
scene_roots_obj = None
|
|
586
|
+
for obj in doc.objects:
|
|
587
|
+
if obj.class_id == 1660057539:
|
|
588
|
+
content = obj.get_content()
|
|
589
|
+
if content and "m_Roots" in content:
|
|
590
|
+
scene_roots_obj = obj
|
|
591
|
+
break
|
|
592
|
+
|
|
593
|
+
if scene_roots_obj is None:
|
|
594
|
+
return issues # Not a scene file or no SceneRoots
|
|
595
|
+
|
|
596
|
+
# Find all root transforms (transforms with no parent)
|
|
597
|
+
root_transform_ids: set[int] = set()
|
|
598
|
+
for obj in doc.objects:
|
|
599
|
+
if obj.class_id == 4: # Transform
|
|
600
|
+
content = obj.get_content()
|
|
601
|
+
if content:
|
|
602
|
+
father = content.get("m_Father", {})
|
|
603
|
+
father_id = father.get("fileID", 0) if isinstance(father, dict) else 0
|
|
604
|
+
if father_id == 0:
|
|
605
|
+
root_transform_ids.add(obj.file_id)
|
|
606
|
+
elif obj.class_id == 224: # RectTransform
|
|
607
|
+
content = obj.get_content()
|
|
608
|
+
if content:
|
|
609
|
+
father = content.get("m_Father", {})
|
|
610
|
+
father_id = father.get("fileID", 0) if isinstance(father, dict) else 0
|
|
611
|
+
if father_id == 0:
|
|
612
|
+
root_transform_ids.add(obj.file_id)
|
|
613
|
+
|
|
614
|
+
# Check SceneRoots m_Roots
|
|
615
|
+
content = scene_roots_obj.get_content()
|
|
616
|
+
roots_list = content.get("m_Roots", [])
|
|
617
|
+
scene_root_ids: set[int] = set()
|
|
618
|
+
for root in roots_list:
|
|
619
|
+
if isinstance(root, dict):
|
|
620
|
+
file_id = root.get("fileID", 0)
|
|
621
|
+
if file_id:
|
|
622
|
+
scene_root_ids.add(file_id)
|
|
623
|
+
|
|
624
|
+
# Check for missing roots
|
|
625
|
+
missing_roots = root_transform_ids - scene_root_ids
|
|
626
|
+
if missing_roots:
|
|
627
|
+
count = len(missing_roots)
|
|
628
|
+
msg = f"SceneRoots missing {count} root transform(s): {sorted(missing_roots)}"
|
|
629
|
+
issues.append(
|
|
630
|
+
ValidationIssue(
|
|
631
|
+
severity=Severity.ERROR,
|
|
632
|
+
file_id=scene_roots_obj.file_id,
|
|
633
|
+
message=msg,
|
|
634
|
+
property_path="SceneRoots.m_Roots",
|
|
635
|
+
suggestion="Use fix_scene_roots() to automatically fix this issue",
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# Check for invalid roots (pointing to non-existent transforms)
|
|
640
|
+
invalid_roots = scene_root_ids - root_transform_ids
|
|
641
|
+
for invalid_id in invalid_roots:
|
|
642
|
+
if invalid_id != 0:
|
|
643
|
+
issues.append(
|
|
644
|
+
ValidationIssue(
|
|
645
|
+
severity=Severity.WARNING,
|
|
646
|
+
file_id=scene_roots_obj.file_id,
|
|
647
|
+
message=f"SceneRoots references non-root transform: {invalid_id}",
|
|
648
|
+
property_path="SceneRoots.m_Roots",
|
|
649
|
+
)
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
return issues
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def validate_prefab(
|
|
656
|
+
path: str | Path,
|
|
657
|
+
strict: bool = False,
|
|
658
|
+
) -> ValidationResult:
|
|
659
|
+
"""Convenience function to validate a prefab file.
|
|
660
|
+
|
|
661
|
+
Args:
|
|
662
|
+
path: Path to the prefab file
|
|
663
|
+
strict: Treat warnings as errors
|
|
664
|
+
|
|
665
|
+
Returns:
|
|
666
|
+
ValidationResult
|
|
667
|
+
"""
|
|
668
|
+
validator = PrefabValidator(strict=strict)
|
|
669
|
+
return validator.validate_file(path)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def fix_invalid_guids(doc: UnityYAMLDocument) -> int:
|
|
673
|
+
"""Fix invalid GUID values in a document.
|
|
674
|
+
|
|
675
|
+
Removes invalid GUID fields (like guid: 0.0) from references.
|
|
676
|
+
For builtin Unity resources (fileID < 100000), guid is not required.
|
|
677
|
+
|
|
678
|
+
Args:
|
|
679
|
+
doc: The UnityYAMLDocument to fix
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
Number of invalid GUIDs fixed
|
|
683
|
+
"""
|
|
684
|
+
fixed_count = 0
|
|
685
|
+
|
|
686
|
+
def fix_value(value: Any) -> Any:
|
|
687
|
+
nonlocal fixed_count
|
|
688
|
+
if isinstance(value, dict):
|
|
689
|
+
# Check if this is a file reference with invalid guid
|
|
690
|
+
if "fileID" in value and "guid" in value:
|
|
691
|
+
guid = value.get("guid")
|
|
692
|
+
if not is_valid_guid(guid):
|
|
693
|
+
# Remove invalid guid
|
|
694
|
+
del value["guid"]
|
|
695
|
+
fixed_count += 1
|
|
696
|
+
# Recurse into dict values
|
|
697
|
+
for key in list(value.keys()):
|
|
698
|
+
value[key] = fix_value(value[key])
|
|
699
|
+
return value
|
|
700
|
+
elif isinstance(value, list):
|
|
701
|
+
return [fix_value(item) for item in value]
|
|
702
|
+
else:
|
|
703
|
+
return value
|
|
704
|
+
|
|
705
|
+
for obj in doc.objects:
|
|
706
|
+
if obj.data:
|
|
707
|
+
obj.data = fix_value(obj.data)
|
|
708
|
+
|
|
709
|
+
return fixed_count
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def fix_scene_roots(doc: UnityYAMLDocument) -> bool:
|
|
713
|
+
"""Fix SceneRoots object to include all root transforms.
|
|
714
|
+
|
|
715
|
+
Finds all Transform/RectTransform objects with no parent and updates
|
|
716
|
+
the SceneRoots.m_Roots list to include them all.
|
|
717
|
+
|
|
718
|
+
Args:
|
|
719
|
+
doc: The UnityYAMLDocument to fix
|
|
720
|
+
|
|
721
|
+
Returns:
|
|
722
|
+
True if SceneRoots was fixed, False if no fix was needed
|
|
723
|
+
"""
|
|
724
|
+
# Find SceneRoots object (class_id 1660057539)
|
|
725
|
+
scene_roots_obj = None
|
|
726
|
+
for obj in doc.objects:
|
|
727
|
+
if obj.class_id == 1660057539:
|
|
728
|
+
content = obj.get_content()
|
|
729
|
+
if content and "m_Roots" in content:
|
|
730
|
+
scene_roots_obj = obj
|
|
731
|
+
break
|
|
732
|
+
|
|
733
|
+
if scene_roots_obj is None:
|
|
734
|
+
return False # Not a scene file or no SceneRoots
|
|
735
|
+
|
|
736
|
+
# Find all root transforms (transforms with no parent)
|
|
737
|
+
root_transform_ids: list[int] = []
|
|
738
|
+
for obj in doc.objects:
|
|
739
|
+
if obj.class_id in (4, 224): # Transform or RectTransform
|
|
740
|
+
content = obj.get_content()
|
|
741
|
+
if content:
|
|
742
|
+
father = content.get("m_Father", {})
|
|
743
|
+
father_id = father.get("fileID", 0) if isinstance(father, dict) else 0
|
|
744
|
+
if father_id == 0:
|
|
745
|
+
root_transform_ids.append(obj.file_id)
|
|
746
|
+
|
|
747
|
+
# Sort for consistent output
|
|
748
|
+
root_transform_ids.sort()
|
|
749
|
+
|
|
750
|
+
# Get current roots
|
|
751
|
+
content = scene_roots_obj.get_content()
|
|
752
|
+
current_roots = content.get("m_Roots", [])
|
|
753
|
+
current_root_ids: set[int] = set()
|
|
754
|
+
for root in current_roots:
|
|
755
|
+
if isinstance(root, dict):
|
|
756
|
+
file_id = root.get("fileID", 0)
|
|
757
|
+
if file_id:
|
|
758
|
+
current_root_ids.add(file_id)
|
|
759
|
+
|
|
760
|
+
# Check if fix is needed
|
|
761
|
+
if set(root_transform_ids) == current_root_ids:
|
|
762
|
+
return False # No fix needed
|
|
763
|
+
|
|
764
|
+
# Update m_Roots
|
|
765
|
+
content["m_Roots"] = [{"fileID": fid} for fid in root_transform_ids]
|
|
766
|
+
|
|
767
|
+
return True
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def fix_document(doc: UnityYAMLDocument) -> dict[str, int]:
|
|
771
|
+
"""Apply all automatic fixes to a document.
|
|
772
|
+
|
|
773
|
+
Args:
|
|
774
|
+
doc: The UnityYAMLDocument to fix
|
|
775
|
+
|
|
776
|
+
Returns:
|
|
777
|
+
Dictionary with counts of each type of fix applied
|
|
778
|
+
"""
|
|
779
|
+
results = {
|
|
780
|
+
"invalid_guids_fixed": fix_invalid_guids(doc),
|
|
781
|
+
"scene_roots_fixed": 1 if fix_scene_roots(doc) else 0,
|
|
782
|
+
}
|
|
783
|
+
return results
|