Stealth Cymbal #3
449
tools/repair_uids.py
Normal file
449
tools/repair_uids.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user