321 lines
11 KiB
Python
321 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Extract and convert assets from the Pokemon Pro Godot PCK.
|
|
|
|
This is a static extractor. It does not run the target AArch64 game binary.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
|
|
PCK_TABLE_OFFSET = 0x64
|
|
PCK_FILE_BASE_OFFSET = 0x18
|
|
PCK_FILE_COUNT_OFFSET = 0x60
|
|
HEX32_RE = re.compile(r"^(?P<name>.+)-(?P<hash>[0-9a-fA-F]{32})\.ctex$")
|
|
|
|
|
|
@dataclass
|
|
class PckEntry:
|
|
path: str
|
|
offset: int
|
|
size: int
|
|
md5: str
|
|
flags: int
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as f:
|
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def safe_parts(path: str) -> list[str]:
|
|
parts: list[str] = []
|
|
for part in Path(path).parts:
|
|
if part in ("", ".", ".."):
|
|
continue
|
|
clean = re.sub(r"[^A-Za-z0-9._' -]+", "_", part)
|
|
clean = clean.strip()
|
|
parts.append(clean or "_")
|
|
return parts
|
|
|
|
|
|
def safe_join(root: Path, path: str) -> Path:
|
|
out = root.joinpath(*safe_parts(path))
|
|
resolved = out.resolve()
|
|
root_resolved = root.resolve()
|
|
if root_resolved != resolved and root_resolved not in resolved.parents:
|
|
raise ValueError(f"unsafe output path: {path}")
|
|
return out
|
|
|
|
|
|
def parse_pck(data: bytes) -> tuple[int, list[PckEntry]]:
|
|
if data[:4] != b"GDPC":
|
|
raise ValueError("not a Godot PCK: missing GDPC magic")
|
|
file_base = struct.unpack_from("<Q", data, PCK_FILE_BASE_OFFSET)[0]
|
|
file_count = struct.unpack_from("<I", data, PCK_FILE_COUNT_OFFSET)[0]
|
|
pos = PCK_TABLE_OFFSET
|
|
entries: list[PckEntry] = []
|
|
for _ in range(file_count):
|
|
path_len = struct.unpack_from("<I", data, pos)[0]
|
|
pos += 4
|
|
raw_path = data[pos : pos + path_len]
|
|
pos += path_len
|
|
path = raw_path.rstrip(b"\0").decode("utf-8", "replace")
|
|
offset, size = struct.unpack_from("<QQ", data, pos)
|
|
md5 = data[pos + 16 : pos + 32].hex()
|
|
flags = struct.unpack_from("<I", data, pos + 32)[0]
|
|
pos += 36
|
|
entries.append(PckEntry(path=path, offset=offset, size=size, md5=md5, flags=flags))
|
|
if pos != file_base:
|
|
raise ValueError(f"PCK table ended at 0x{pos:x}, file_base is 0x{file_base:x}")
|
|
return file_base, entries
|
|
|
|
|
|
def decompress_rscc(blob: bytes, zstd_path: str) -> bytes:
|
|
if not blob.startswith(b"RSCC"):
|
|
return blob
|
|
if len(blob) < 16:
|
|
raise ValueError("truncated RSCC header")
|
|
_version, block_size, total_size = struct.unpack_from("<III", blob, 4)
|
|
if block_size == 0:
|
|
raise ValueError("invalid RSCC block size 0")
|
|
block_count = math.ceil(total_size / block_size)
|
|
size_table_end = 16 + (4 * block_count)
|
|
if len(blob) < size_table_end:
|
|
raise ValueError("truncated RSCC block table")
|
|
block_sizes = struct.unpack_from("<" + "I" * block_count, blob, 16)
|
|
compressed_start = size_table_end
|
|
compressed_end = compressed_start + sum(block_sizes)
|
|
compressed = blob[compressed_start:compressed_end]
|
|
result = subprocess.run(
|
|
[zstd_path, "-dc"],
|
|
input=compressed,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
err = result.stderr.decode("utf-8", "replace").strip()
|
|
raise ValueError(f"zstd failed: {err}")
|
|
if len(result.stdout) != total_size:
|
|
raise ValueError(f"RSCC decompressed to {len(result.stdout)} bytes, expected {total_size}")
|
|
return result.stdout
|
|
|
|
|
|
def extract_riff_webp(data: bytes) -> Optional[bytes]:
|
|
idx = 0
|
|
while True:
|
|
idx = data.find(b"RIFF", idx)
|
|
if idx < 0:
|
|
return None
|
|
if idx + 12 <= len(data) and data[idx + 8 : idx + 12] == b"WEBP":
|
|
riff_size = struct.unpack_from("<I", data, idx + 4)[0] + 8
|
|
end = idx + riff_size
|
|
if end <= len(data):
|
|
return data[idx:end]
|
|
idx += 4
|
|
|
|
|
|
def extract_jpeg(data: bytes) -> Optional[bytes]:
|
|
start = data.find(b"\xff\xd8\xff")
|
|
if start < 0:
|
|
return None
|
|
end = data.find(b"\xff\xd9", start + 2)
|
|
if end < 0:
|
|
return None
|
|
return data[start : end + 2]
|
|
|
|
|
|
def extract_ttf(data: bytes) -> Optional[bytes]:
|
|
candidates = [data.find(b"\x00\x01\x00\x00"), data.find(b"OTTO"), data.find(b"ttcf")]
|
|
candidates = [idx for idx in candidates if idx >= 0]
|
|
if not candidates:
|
|
return None
|
|
start = min(candidates)
|
|
if data[start : start + 4] == b"ttcf":
|
|
return data[start:]
|
|
if start + 12 > len(data):
|
|
return None
|
|
num_tables = struct.unpack_from(">H", data, start + 4)[0]
|
|
table_dir_end = start + 12 + (num_tables * 16)
|
|
if num_tables == 0 or table_dir_end > len(data):
|
|
return None
|
|
max_end = table_dir_end
|
|
for i in range(num_tables):
|
|
record = start + 12 + (i * 16)
|
|
table_offset = struct.unpack_from(">I", data, record + 8)[0]
|
|
table_length = struct.unpack_from(">I", data, record + 12)[0]
|
|
max_end = max(max_end, start + table_offset + table_length)
|
|
if max_end > len(data):
|
|
return None
|
|
return data[start:max_end]
|
|
|
|
|
|
def image_output_name(entry_path: str, extension: str) -> str:
|
|
name = Path(entry_path).name
|
|
match = HEX32_RE.match(name)
|
|
if match:
|
|
source_name = match.group("name")
|
|
short_hash = match.group("hash")[:8].lower()
|
|
stem = Path(source_name).stem
|
|
return f"{stem}--{short_hash}{extension}"
|
|
return f"{Path(name).stem}{extension}"
|
|
|
|
|
|
def text_like(data: bytes) -> bool:
|
|
if b"\0" in data[:4096]:
|
|
return False
|
|
try:
|
|
data.decode("utf-8")
|
|
return True
|
|
except UnicodeDecodeError:
|
|
return False
|
|
|
|
|
|
def write_file(path: Path, data: bytes) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--pck", type=Path, default=Path("games/pokemon_pro/assets/godot/main.pck"))
|
|
parser.add_argument("--output", type=Path, default=Path("assets"))
|
|
args = parser.parse_args()
|
|
|
|
zstd_path = shutil.which("zstd") or "/opt/homebrew/bin/zstd"
|
|
if not Path(zstd_path).exists():
|
|
print("zstd is required to decompress RSCC resources", file=sys.stderr)
|
|
return 1
|
|
if args.output.exists():
|
|
print(f"output path already exists: {args.output}", file=sys.stderr)
|
|
return 1
|
|
|
|
data = args.pck.read_bytes()
|
|
file_base, entries = parse_pck(data)
|
|
|
|
raw_root = args.output / "godot_raw"
|
|
openable_root = args.output / "openable"
|
|
stats = {
|
|
"entries": len(entries),
|
|
"raw_files": 0,
|
|
"rscc_decompressed": 0,
|
|
"images_webp": 0,
|
|
"images_jpeg": 0,
|
|
"videos_mp4": 0,
|
|
"fonts_ttf": 0,
|
|
"text_files": 0,
|
|
"conversion_failures": [],
|
|
}
|
|
manifest_entries = []
|
|
|
|
for entry in entries:
|
|
start = file_base + entry.offset
|
|
end = start + entry.size
|
|
blob = data[start:end]
|
|
try:
|
|
decoded = decompress_rscc(blob, zstd_path)
|
|
if decoded is not blob:
|
|
stats["rscc_decompressed"] += 1
|
|
except Exception as exc:
|
|
decoded = blob
|
|
stats["conversion_failures"].append({"path": entry.path, "stage": "rscc", "error": str(exc)})
|
|
|
|
raw_path = safe_join(raw_root, entry.path)
|
|
write_file(raw_path, decoded)
|
|
stats["raw_files"] += 1
|
|
|
|
lower_path = entry.path.lower()
|
|
converted_path: Optional[Path] = None
|
|
|
|
if lower_path.endswith(".ctex"):
|
|
webp = extract_riff_webp(decoded)
|
|
if webp is not None:
|
|
converted_path = openable_root / "images" / "imported" / image_output_name(entry.path, ".webp")
|
|
write_file(converted_path, webp)
|
|
stats["images_webp"] += 1
|
|
else:
|
|
jpeg = extract_jpeg(decoded)
|
|
if jpeg is not None:
|
|
converted_path = openable_root / "images" / "imported" / image_output_name(entry.path, ".jpg")
|
|
write_file(converted_path, jpeg)
|
|
stats["images_jpeg"] += 1
|
|
else:
|
|
stats["conversion_failures"].append({"path": entry.path, "stage": "image-carve", "error": "no WEBP/JPEG payload found"})
|
|
elif lower_path.endswith(".fontdata"):
|
|
ttf = extract_ttf(decoded)
|
|
if ttf is not None:
|
|
name = Path(entry.path).name.split("-", 1)[0]
|
|
converted_path = openable_root / "fonts" / name
|
|
write_file(converted_path, ttf)
|
|
stats["fonts_ttf"] += 1
|
|
else:
|
|
stats["conversion_failures"].append({"path": entry.path, "stage": "font-carve", "error": "no sfnt font payload found"})
|
|
elif lower_path.endswith(".mp4"):
|
|
converted_path = safe_join(openable_root / "videos", entry.path)
|
|
write_file(converted_path, decoded)
|
|
stats["videos_mp4"] += 1
|
|
elif lower_path.endswith((".txt", ".cfg", ".import", ".remap")) or text_like(decoded):
|
|
converted_path = safe_join(openable_root / "text", f"{entry.path}.txt")
|
|
write_file(converted_path, decoded)
|
|
stats["text_files"] += 1
|
|
|
|
manifest_entries.append(
|
|
{
|
|
"path": entry.path,
|
|
"offset": entry.offset,
|
|
"size": entry.size,
|
|
"md5": entry.md5,
|
|
"flags": entry.flags,
|
|
"decoded_size": len(decoded),
|
|
"raw_path": str(raw_path.relative_to(args.output)),
|
|
"openable_path": str(converted_path.relative_to(args.output)) if converted_path else None,
|
|
}
|
|
)
|
|
|
|
manifest = {
|
|
"source": str(args.pck),
|
|
"source_sha256": sha256_file(args.pck),
|
|
"source_size": args.pck.stat().st_size,
|
|
"pck_file_base": file_base,
|
|
"pck_entry_count": len(entries),
|
|
"stats": stats,
|
|
"entries": manifest_entries,
|
|
}
|
|
args.output.mkdir(parents=True, exist_ok=True)
|
|
(args.output / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
(args.output / "README.md").write_text(
|
|
"# Extracted Pokemon Pro Godot Assets\n\n"
|
|
"Generated from `/games/pokemon_pro/assets/godot/main.pck` by "
|
|
"`rootfs-triage-wiki/scripts/extract_godot_pck_assets.py`.\n\n"
|
|
"- `godot_raw/` preserves every PCK entry after RSCC/zstd decompression where applicable.\n"
|
|
"- `openable/images/imported/` contains WebP/JPEG payloads carved from Godot `.ctex` textures.\n"
|
|
"- `openable/videos/` contains MP4 assets from the pack.\n"
|
|
"- `openable/fonts/` contains TTF payloads carved from Godot `.fontdata` resources.\n"
|
|
"- `openable/text/` contains text metadata and config files with `.txt` suffixes for easy opening.\n"
|
|
"- `manifest.json` records offsets, sizes, hashes, decoded paths, and conversion results.\n",
|
|
encoding="utf-8",
|
|
)
|
|
print(json.dumps({"output": str(args.output), "stats": stats}, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|