#!/usr/bin/env python3 """Scans and repairs UID mismatches across all KQ4 room scenes. Fixes two types of drift: 1. .tscn.headers have a different UID than the .uid companion file 2. Source rooms reference stale target UIDs from .uid companions instead of the current .tscn header UID After repair, check_transitions.py and build_room_graph.py report consistent results. Usage: # Detect (show what's wrong) python tools/repair_uids.py --detect # Dry run (show what would change) python tools/repair_uids.py --fix --dry-run # Apply fixes python tools/repair_uids.py --fix # Fix a specific room only python tools/repair_uids.py --fix --room kq4_018_cemetery # Verbose output python tools/repair_uids.py --detect -v """ import re import sys import hashlib import json from pathlib import Path from dataclasses import dataclass, field from typing import Optional def generate_godot_uid() -> str: """Generate a fresh Godot-compatible UID (base36 encoded 64-bit random).""" ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz" def to_base36(num: int) -> str: if num == 0: return "0" chars = [] while num: num, rem = divmod(num, 36) chars.append(ALPHABET[rem]) return "".join(reversed(chars)) import secrets value = secrets.randbits(64) return f"uid://{to_base36(value)}" @dataclass class RoomData: """Parsed data for a single room scene.""" name: str path: Path header_uid: Optional[str] # uid from .tscn [gd_scene ...] line companion_uid: Optional[str] # uid from .uid file (may be stale or missing) target_uids: list[dict] # [{"exit_node": str, "target_uid": str}, ...] @dataclass class Mismatch: """A single detected UID problem.""" category: str # "companion_mismatch" | "missing_companion" | "stale_target" room_name: str detail: str fixable: bool = True fix_uid: Optional[str] = None source_file: Optional[Path] = None def parse_all_rooms(scenes_dir: Path) -> dict[str, RoomData]: """Parse every kq4_* .tscn scene and return data keyed by room name.""" rooms: dict[str, RoomData] = {} for tscn in sorted(scenes_dir.glob("kq4_*/kq4_*.tscn")): if "placeholder" in str(tscn): continue content = tscn.read_text() # Extract header UID header_match = re.search(r'\[gd_scene[^\]]*uid="([^"]+)"', content) header_uid = header_match.group(1) if header_match else None # Extract companion .uid file uid_file = Path(str(tscn) + ".uid") companion_uid = uid_file.read_text().strip() if uid_file.exists() else None # Extract target UIDs from TransitionPiece nodes target_uids = [] transition_pattern = re.compile( r'\[node name="([^"]+)"[^\]]*instance=ExtResource\([^\)]+\)\]\s*\n' r"(([^\[]+(?:\n|$))*)(?=\[|$)", re.MULTILINE, ) for match in transition_pattern.finditer(content): node_name = match.group(1) body = match.group(2) target_match = re.search(r'^target = "([^"]+)"', body, re.MULTILINE) if target_match: target_uids.append({ "exit_node": node_name, "target_uid": target_match.group(1), }) rooms[tscn.stem] = RoomData( name=tscn.stem, path=tscn, header_uid=header_uid, companion_uid=companion_uid, target_uids=target_uids, ) return rooms def detect_mismatches(rooms: dict[str, RoomData]) -> list[Mismatch]: """Find all UID problems and return actionable mismatch records.""" mismatches: list[Mismatch] = [] # Build lookup: companion_uid -> room_name (for stale-target detection) companion_to_room: dict[str, str] = {} for rdata in rooms.values(): if rdata.companion_uid: companion_to_room[rdata.companion_uid] = rdata.name for rname, rd in rooms.items(): # 1. Companion doesn't match header (both exist but differ) if rd.header_uid and rd.companion_uid and rd.header_uid != rd.companion_uid: mismatches.append(Mismatch( category="companion_mismatch", room_name=rname, detail=f".uid={rd.companion_uid} .tscn header={rd.header_uid}", fix_uid=rd.header_uid, source_file=Path(str(rd.path) + ".uid"), )) # 2. Missing companion file if rd.header_uid and not rd.companion_uid: mismatches.append(Mismatch( category="missing_companion", room_name=rname, detail=f"No .uid file for header UID {rd.header_uid}", fix_uid=rd.header_uid, source_file=Path(str(rd.path) + ".uid"), )) # 3. Stale target references for tgt in rd.target_uids: uid = tgt["target_uid"] if not uid.startswith("uid://"): continue # Target exists in some header -> check companion matches target_room = None for other_name, other_rd in rooms.items(): if other_rd.header_uid == uid: target_room = other_name break if target_room is None: # Check if the stale UID matches a companion that diverged from header mapped_companion = companion_to_room.get(uid) if mapped_companion and mapped_companion != rooms[mapped_companion].name or \ (mapped_companion and rooms[mapped_companion].companion_uid == uid): # Stale companion lookup — the target room exists but source # references its OLD companion UID instead of current header correct_uid = rooms[mapped_companion].header_uid if uid != correct_uid: mismatches.append(Mismatch( category="stale_target", room_name=rname, detail=f"{tgt['exit_node']} -> {uid} (correct: {correct_uid} for {mapped_companion})", fix_uid=correct_uid, source_file=rd.path, )) elif target_room is None and uid not in companion_to_room: # Totally orphaned UID — try to auto-resolve via exit node name suggested = suggest_target_from_node(tgt["exit_node"], rooms) if suggested: sugg_uid = rooms[suggested].header_uid mismatches.append(Mismatch( category="stale_target", room_name=rname, detail=f"{tgt['exit_node']} -> {uid} (not found, likely → {suggested}: {sugg_uid})", fix_uid=sugg_uid, source_file=rd.path, )) return mismatches def suggest_target_from_node(exit_node_name: str, rooms: dict[str, RoomData]) -> Optional[str]: """Try to guess target room from exit node naming convention. Exit nodes are typically named after their destination (e.g., 'kq4_018_cemetery'). """ # Direct match if exit_node_name in rooms: return exit_node_name # kq4_XXX_room_node -> kq4_XXX_room (strip trailing node-like suffix) for rname in rooms: if exit_node_name.startswith(rname): return rname if rname in exit_node_name: return rname return None def fix_companion_mismatch(room: RoomData, new_uid: str, dry_run: bool) -> str: """Write the header UID into the .uid companion file.""" uid_path = Path(str(room.path) + ".uid") content = f"{new_uid}\n" if not dry_run: uid_path.write_text(content) return f"Synchronizing {room.name}: .uid → {new_uid}" def fix_missing_companion(room: RoomData, uid: str, dry_run: bool) -> str: """Create a new .uid companion file with the header UID.""" uid_path = Path(str(room.path) + ".uid") content = f"{uid}\n" if not dry_run: uid_path.write_text(content) return f"Creating {room.name}: .uid ← {uid} (from .tscn header)" def fix_stale_target(source_room: RoomData, exit_node: str, old_uid: str, new_uid: str, dry_run: bool) -> str: """Replace a stale target UID in the source .tscn file.""" content = source_room.path.read_text() if not dry_run: content = content.replace( f'target = "{old_uid}"', f'target = "{new_uid}"', ) source_room.path.write_text(content) return f"Updating {source_room.name}/{exit_node}: target {old_uid} → {new_uid}" def run_detect(rooms: dict[str, RoomData], verbose: bool) -> list[Mismatch]: """Detect and display all mismatches. Returns list for further processing.""" mismatches = detect_mismatches(rooms) if not mismatches: print("No UID mismatches found. All rooms are consistent.") return mismatches # Group by category by_category: dict[str, list[Mismatch]] = {} for m in mismatches: by_category.setdefault(m.category, []).append(m) total = len(mismatches) print(f"Found {total} UID issues across {len(rooms)} rooms:\n") if "companion_mismatch" in by_category: msgs = by_category["companion_mismatch"] print(f" [{len(msgs)}] Companion files out of sync with .tscn headers:") for m in msgs[:10]: print(f" {m.room_name}: {m.detail}") if len(msgs) > 10: print(f" ... and {len(msgs) - 10} more") if "missing_companion" in by_category: msgs = by_category["missing_companion"] print(f" [{len(msgs)}] Missing .uid companion files:") for m in msgs: print(f" {m.room_name}: {m.detail}") if "stale_target" in by_category: msgs = by_category["stale_target"] print(f" [{len(msgs)}] Stale target UIDs in source rooms:") for m in (msgs[:15] if verbose else msgs[:5]): print(f" {m.room_name}: {m.detail}") if len(msgs) > 5 and not verbose: print(f" ... and {len(msgs) - 5} more (use -v for full list)") return mismatches def run_fix(rooms: dict[str, RoomData], dry_run: bool, room_filter: Optional[str]) -> int: """Detect and fix all mismatches. Returns count of fixes applied.""" mismatches = detect_mismatches(rooms) if not mismatches: print("No UID mismatches to fix.") return 0 if room_filter: mismatches = [m for m in mismatches if m.room_name == room_filter] if not mismatches: print(f"No issues found for {room_filter}.") return 0 mode = "(DRY RUN) " if dry_run else "" applied = 0 for m in mismatches: if not m.fixable or not m.fix_uid: continue rd = rooms.get(m.room_name) if not rd: continue if m.category == "companion_mismatch" and m.source_file: msg = fix_companion_mismatch(rd, m.fix_uid, dry_run) print(f"{mode}✓ {msg}") applied += 1 elif m.category == "missing_companion": # Check if room needs a new UID generated uid_to_use = m.fix_uid or rd.header_uid if not uid_to_use: uid_to_use = generate_godot_uid() if not dry_run: # Also update the .tscn header with the new UID content = rd.path.read_text() if rd.header_uid: content = content.replace(f'uid="{rd.header_uid}"', f'uid="{uid_to_use}"') else: content = content.replace('[gd_scene format=3', f'[gd_scene format=3 uid="{uid_to_use}"') rd.path.write_text(content) msg = fix_missing_companion(rd, uid_to_use, dry_run) print(f"{mode}✓ {msg}") applied += 1 elif m.category == "stale_target": # Extract old UID from detail message detail_parts = m.detail.split(" -> ") if len(detail_parts) >= 2: exit_node = detail_parts[0].split()[-1] old_uid = detail_parts[1] new_uid = m.fix_uid msg = fix_stale_target(rd, exit_node, old_uid or "", new_uid, dry_run) # Handle case where target UID doesn't exist in any companion file if "not found" in m.detail.lower(): # Extract via regex for more robust parsing import re uid_match = re.search(r"-> (uid://\S+)", m.detail) if uid_match: old_uid = uid_match.group(1) else: continue exit_node_match = re.search(r"(kq4_\S+) -> ", m.detail) exit_node = exit_node_match.group(1) if exit_node_match else "unknown" msg = fix_stale_target(rd, exit_node, old_uid, new_uid, dry_run) print(f"{mode}✓ {msg}") applied += 1 return applied def run_verify() -> bool: """Run check_transitions.py and build_room_graph.py to confirm fixes.""" root = Path(__file__).resolve().parent.parent check_script = root / "scripts" / "check_transitions.py" graph_script = root / "scripts" / "build_room_graph.py" print("\n--- Post-fix verification ---\n") all_clean = True if check_script.exists(): import subprocess print("$ python scripts/check_transitions.py") result = subprocess.run(["python", str(check_script)], cwd=str(root), capture_output=True, text=True) lines = [l for l in result.stdout.strip().split("\n") if "ERROR:" in l] if lines: all_clean = False print(f" Still has {len(lines)} error(s):") for l in lines[:10]: print(f" {l.strip()}") if len(lines) > 10: print(f" ... and {len(lines) - 10} more") else: OK_count = sum(1 for l in result.stdout.split("\n") if "OK:" in l) print(f" All {OK_count} transitions valid! ✓\n") else: print(f"[skip] check_transitions.py not found at {check_script}\n") if graph_script.exists(): import subprocess print("$ python scripts/build_room_graph.py") result = subprocess.run(["python", str(graph_script)], cwd=str(root), capture_output=True, text=True) output = result.stdout.strip().split("\n") for line in output[:8]: print(f" {line}") # Check if all rooms are connected or note component issues for l in output: if "component" in l.lower(): if "all rooms connected" not in l.lower(): comp_count = re.search(r"(\d+)", l) if comp_count and int(comp_count.group(1)) > 1: print(f" Note: {l.strip()} (some rooms disconnected by missing transitions)") else: print(f"[skip] build_room_graph.py not found at {graph_script}\n") return all_clean def main() -> None: import argparse parser = argparse.ArgumentParser( description="Detect and repair UID mismatches in KQ4 room scenes", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) parser.add_argument("--detect", action="store_true", help="Only detect mismatches") parser.add_argument("--fix", action="store_true", help="Apply fixes for found mismatches") parser.add_argument("--dry-run", action="store_true", help="Show what would change without writing") parser.add_argument("--room", help="Limit to a specific room name (e.g., kq4_018_cemetery)") parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") args = parser.parse_args() root = Path(__file__).resolve().parent.parent scenes_dir = root / "scenes" if not scenes_dir.exists(): print(f"ERROR: Scenes directory not found at {scenes_dir}", file=sys.stderr) sys.exit(1) rooms = parse_all_rooms(scenes_dir) if args.detect and not args.fix: mismatches = run_detect(rooms, args.verbose) if mismatches: sys.exit(0) if args.fix: applied = run_fix(rooms, args.dry_run, args.room) print(f"\nApplied {applied} fix(es)" + (" (dry run — no changes made)" if args.dry_run else "")) if not args.dry_run and applied > 0: try: clean = run_verify() if not clean: sys.exit(1) except Exception as e: print(f"[verification warning] {e}") if __name__ == "__main__": main()