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/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