103 lines
4.0 KiB
Python
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())
|