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

143 lines
5.0 KiB
Python

#!/usr/bin/env python3
"""Extract native sound-test-adjacent label candidates and build preview aliases.
This is static analysis of /games/pokemon_pro/game. The table at
0x325d940 contains localized label-list pointers paired with a numeric field.
Follow-up WAV/ASR checks showed that numeric field is not a reliable direct
Radium local sound ID for every row, so these labels are retained for audit
but are not promoted into the best-name export.
"""
from __future__ import annotations
import argparse
import csv
import os
import re
import struct
from pathlib import Path
SOUND_TEST_LABEL_TABLE_OFFSET = 0x325D940
SOUND_TEST_LABEL_COUNT = 4
LOCALIZED_STRING_COUNT = 5
def u64(data: bytes, offset: int) -> int:
return struct.unpack_from("<Q", data, offset)[0]
def cstring(data: bytes, offset: int) -> str:
if offset <= 0 or offset >= len(data):
return ""
end = data.find(b"\x00", offset)
if end < 0:
end = len(data)
return data[offset:end].decode("utf-8", errors="replace")
def slug(value: str) -> str:
return re.sub(r"[^A-Za-z0-9_]+", "_", value).strip("_") or "sound"
def localized_labels(data: bytes, list_offset: int) -> list[tuple[int, str]]:
labels: list[tuple[int, str]] = []
for index in range(LOCALIZED_STRING_COUNT):
ptr_offset = list_offset + index * 8
if ptr_offset + 8 > len(data):
break
string_offset = u64(data, ptr_offset)
if string_offset == 0:
break
labels.append((string_offset, cstring(data, string_offset)))
return labels
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--game-binary", type=Path, default=Path("games/pokemon_pro/game"))
parser.add_argument("--sound-manifest", type=Path, default=Path("analysis/radium-image/sound_export_manifest.tsv"))
parser.add_argument("--alias-dir", type=Path, default=Path("analysis/radium-image/sounds-native-sound-test"))
parser.add_argument("--out", type=Path, default=Path("analysis/radium-image/native_sound_test_label_manifest.tsv"))
args = parser.parse_args()
data = args.game_binary.read_bytes()
manifest = list(csv.DictReader(args.sound_manifest.open(newline=""), delimiter="\t"))
by_object = {int(row["primary_object_local_id"]): row for row in manifest if row["primary_object_local_id"]}
args.alias_dir.mkdir(parents=True, exist_ok=True)
rows: list[dict[str, object]] = []
for index in range(SOUND_TEST_LABEL_COUNT):
entry_offset = SOUND_TEST_LABEL_TABLE_OFFSET + index * 16
label_list_offset = u64(data, entry_offset)
sound_id = u64(data, entry_offset + 8)
labels = localized_labels(data, label_list_offset)
if not labels:
continue
label_string_offset, label = labels[0]
source = by_object.get(sound_id)
if source is None:
continue
source_path = Path(source["wav_path"])
alias_name = (
f"{slug(label)}"
f"__obj{sound_id:04d}"
f"_s{int(source['section8_index']):04d}"
f"_{source['channels']}ch_{source['sample_rate']}hz.wav"
)
alias_path = args.alias_dir / alias_name
if not alias_path.exists():
try:
os.link(source_path, alias_path)
link_type = "hardlink"
except OSError:
alias_path.symlink_to(Path("..") / "sounds" / source_path.name)
link_type = "symlink"
else:
link_type = "existing"
rows.append(
{
"label": label,
"sound_id": sound_id,
"alias_wav_path": alias_path.as_posix(),
"source_wav_path": source_path.as_posix(),
"section8_index": source["section8_index"],
"duration_seconds": source["duration_seconds"],
"channels": source["channels"],
"sample_rate": source["sample_rate"],
"command_ref_count": source["command_ref_count"],
"table_entry_offset": f"0x{entry_offset:08x}",
"label_list_offset": f"0x{label_list_offset:08x}",
"label_string_offset": f"0x{label_string_offset:08x}",
"localized_labels": " | ".join(text for _, text in labels),
"link_type": link_type,
}
)
fields = [
"label",
"sound_id",
"alias_wav_path",
"source_wav_path",
"section8_index",
"duration_seconds",
"channels",
"sample_rate",
"command_ref_count",
"table_entry_offset",
"label_list_offset",
"label_string_offset",
"localized_labels",
"link_type",
]
with args.out.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields, delimiter="\t")
writer.writeheader()
writer.writerows(rows)
print(f"wrote {args.out} rows={len(rows)} alias_dir={args.alias_dir}")
return 0 if len(rows) == SOUND_TEST_LABEL_COUNT else 1
if __name__ == "__main__":
raise SystemExit(main())