Stealth Cymbal #3

Merged
notid merged 10 commits from stealth-cymbal into master 2026-04-29 16:56:44 -07:00
Showing only changes of commit 8929c4fc09 - Show all commits

449
tools/repair_uids.py Normal file
View 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()