diff --git a/tools/repair_uids.py b/tools/repair_uids.py new file mode 100644 index 0000000..636b854 --- /dev/null +++ b/tools/repair_uids.py @@ -0,0 +1,449 @@ +#!/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()