Files
pokemon_pinball_wiki/scripts/extract_godot_gdc_strings.py
2026-07-07 00:25:38 -05:00

103 lines
4.0 KiB
Python

#!/usr/bin/env python3
"""Extract strings and sound-ID references from Godot 4 compiled GDScript.
The exported `.gdc` files in this project contain a 12-byte `GDSC` header
followed by a Zstandard payload. Some strings are plain length-prefixed ASCII;
many identifiers are XOR-obfuscated with byte `0xb6` and stored in a
UTF-32LE-like layout. This script recovers both forms statically.
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import subprocess
from pathlib import Path
PLAIN_TOKEN_RE = re.compile(rb"[A-Za-z0-9_./:-]{3,}")
SOUND_RE = re.compile(r"SE_[A-Z0-9_]+")
def decompress_gdc(path: Path) -> bytes:
data = path.read_bytes()
if not data.startswith(b"GDSC"):
return b""
proc = subprocess.run(["zstd", "-dc"], input=data[12:], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
raise RuntimeError(f"zstd failed for {path}: {proc.stderr.decode(errors='replace')}")
return proc.stdout
def utf32_xor_tokens(payload: bytes) -> set[str]:
xored = bytes(byte ^ 0xB6 for byte in payload)
chars: list[str] = []
index = 0
while index + 3 < len(xored):
if xored[index + 1 : index + 4] == b"\0\0\0" and 32 <= xored[index] <= 126:
chars.append(chr(xored[index]))
index += 4
else:
chars.append("\n")
index += 1
text = "".join(chars)
return set(re.findall(r"[A-Za-z0-9_./:-]{3,}", text))
def extract_tokens(path: Path) -> tuple[set[str], int]:
payload = decompress_gdc(path)
plain = {match.group(0).decode("ascii", errors="replace") for match in PLAIN_TOKEN_RE.finditer(payload)}
return plain | utf32_xor_tokens(payload), len(payload)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path("assets/godot_raw"))
parser.add_argument("--out-dir", type=Path, default=Path("analysis/godot-introspection"))
parser.add_argument("--constant-map", type=Path, default=Path("analysis/godot-introspection/spike_sound_constant_map.json"))
args = parser.parse_args()
args.out_dir.mkdir(parents=True, exist_ok=True)
known_sound_names: set[str] = set()
if args.constant_map.exists():
known_sound_names = set(json.loads(args.constant_map.read_text()).keys())
string_rows: list[dict[str, object]] = []
sound_rows: list[dict[str, object]] = []
script_rows: list[dict[str, object]] = []
for path in sorted(args.root.rglob("*.gdc")):
tokens, payload_size = extract_tokens(path)
rel = path.as_posix()
sound_names = sorted({token for token in tokens if SOUND_RE.fullmatch(token) and (not known_sound_names or token in known_sound_names)})
script_rows.append(
{
"path": rel,
"compressed_bytes": path.stat().st_size,
"decompressed_bytes": payload_size,
"token_count": len(tokens),
"sound_ref_count": len(sound_names),
"has_spike_play_sound": "Spike" in tokens and "play_sound" in tokens,
}
)
for token in sorted(tokens):
string_rows.append({"path": rel, "token": token})
for sound_name in sound_names:
sound_rows.append({"path": rel, "sound_name": sound_name})
for name, rows, fields in [
("gdc_script_summary.tsv", script_rows, ["path", "compressed_bytes", "decompressed_bytes", "token_count", "sound_ref_count", "has_spike_play_sound"]),
("gdc_strings.tsv", string_rows, ["path", "token"]),
("gdc_sound_refs.tsv", sound_rows, ["path", "sound_name"]),
]:
with (args.out_dir / name).open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields, delimiter="\t")
writer.writeheader()
writer.writerows(rows)
print(f"scripts={len(script_rows)} strings={len(string_rows)} sound_refs={len(sound_rows)} out={args.out_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())