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

844 lines
39 KiB
Python

#!/usr/bin/env python3
"""Generate wiki media preview pages for exported sounds and openable assets."""
from __future__ import annotations
import csv
import json
from pathlib import Path
from urllib.parse import quote
WIKI_ROOT = Path(__file__).resolve().parents[1]
WORKSPACE_ROOT = WIKI_ROOT.parent
SOUND_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/sound_export_manifest.tsv"
NATIVE_NAMED_SOUND_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/native_named_sound_manifest.tsv"
NATIVE_SOUND_TEST_LABEL_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/native_sound_test_label_manifest.tsv"
ASR_DRAFT_SOUND_LABEL_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/asr_draft_sound_labels.tsv"
ASR_REVIEW_SOUND_LABEL_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/asr_review_sound_label_candidates.tsv"
ASR_CATALOG_SOUND_LABEL_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/asr_catalog_sound_label_candidates.tsv"
CATALOG_OFFSET_CANDIDATE_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/catalog_offset_candidate_labels.tsv"
POKEDEX_VOICE_LABEL_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/pokedex_voice_label_manifest.tsv"
SPIKE_MENU_SOUND_MANIFEST = WORKSPACE_ROOT / "analysis/radium-spike-menu-image/sound_export_manifest.tsv"
BEST_NAMED_SOUND_MANIFEST = WORKSPACE_ROOT / "analysis/all-sound-best-name-manifest.tsv"
RADIUM_SECTION3_BITMAP_MANIFEST = WORKSPACE_ROOT / "analysis/radium-image/section3_bitmap_manifest.tsv"
ASSET_ROOT = WORKSPACE_ROOT / "assets/openable"
OUT_MANIFEST = WORKSPACE_ROOT / "analysis/media-preview-pages.json"
SOUND_PAGE_SIZE = 200
IMAGE_PAGE_SIZE = 200
VIDEO_PAGE_SIZE = 100
TEXT_PAGE_SIZE = 200
RADIUM_BITMAP_PAGE_SIZE = 240
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
VIDEO_EXTS = {".mp4", ".mov", ".webm", ".m4v"}
TEXT_EXTS = {".txt", ".json", ".csv", ".tsv", ".md", ".import"}
FONT_EXTS = {".ttf", ".otf", ".woff", ".woff2"}
def clean_attr(value: object) -> str:
return str(value).replace('"', "'").replace("\n", " ").replace("\r", " ")
def wiki_src(path: Path) -> str:
rel = path.relative_to(WIKI_ROOT).as_posix() if path.is_relative_to(WIKI_ROOT) else ("../" + path.relative_to(WORKSPACE_ROOT).as_posix())
return quote(rel, safe="/._-~:;@!$&'()*+,=")
def write_page(name: str, text: str) -> str:
path = WIKI_ROOT / name
path.write_text(text.rstrip() + "\n", encoding="utf-8")
return name
def chunks(items: list[dict[str, object]], size: int) -> list[list[dict[str, object]]]:
return [items[i : i + size] for i in range(0, len(items), size)]
def sound_rows() -> list[dict[str, str]]:
with SOUND_MANIFEST.open(newline="") as f:
rows = list(csv.DictReader(f, delimiter="\t"))
native_by_source: dict[str, dict[str, str]] = {}
if NATIVE_NAMED_SOUND_MANIFEST.exists():
with NATIVE_NAMED_SOUND_MANIFEST.open(newline="") as f:
for native in csv.DictReader(f, delimiter="\t"):
native_by_source[native["source_wav_path"]] = native
asr_by_source: dict[str, dict[str, str]] = {}
if ASR_DRAFT_SOUND_LABEL_MANIFEST.exists():
with ASR_DRAFT_SOUND_LABEL_MANIFEST.open(newline="") as f:
for draft in csv.DictReader(f, delimiter="\t"):
if draft["accepted"] == "yes":
asr_by_source[draft["wav_path"]] = draft
for row in rows:
native = native_by_source.get(row["wav_path"])
row["confirmed_name"] = native["name"] if native else ""
row["confirmed_alias_wav_path"] = native["alias_wav_path"] if native else ""
row["sound_test_label"] = ""
row["sound_test_alias_wav_path"] = ""
asr = asr_by_source.get(row["wav_path"])
row["asr_draft_label"] = asr["transcript"] if asr else ""
row["asr_draft_score"] = asr["avg_logprob"] if asr else ""
return sorted(rows, key=lambda row: row["sound_name"])
def native_named_sound_rows() -> list[dict[str, str]]:
if not NATIVE_NAMED_SOUND_MANIFEST.exists():
return []
with NATIVE_NAMED_SOUND_MANIFEST.open(newline="") as f:
return sorted(csv.DictReader(f, delimiter="\t"), key=lambda row: int(row["native_sound_id"]))
def native_sound_test_label_rows() -> list[dict[str, str]]:
if not NATIVE_SOUND_TEST_LABEL_MANIFEST.exists():
return []
with NATIVE_SOUND_TEST_LABEL_MANIFEST.open(newline="") as f:
return sorted(csv.DictReader(f, delimiter="\t"), key=lambda row: int(row["sound_id"]))
def asr_draft_sound_label_rows() -> list[dict[str, str]]:
if not ASR_DRAFT_SOUND_LABEL_MANIFEST.exists():
return []
with ASR_DRAFT_SOUND_LABEL_MANIFEST.open(newline="") as f:
rows = [row for row in csv.DictReader(f, delimiter="\t") if row["accepted"] == "yes"]
return sorted(rows, key=lambda row: int(row["section8_index"]))
def asr_review_sound_label_rows() -> list[dict[str, str]]:
if not ASR_REVIEW_SOUND_LABEL_MANIFEST.exists():
return []
with ASR_REVIEW_SOUND_LABEL_MANIFEST.open(newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f, delimiter="\t"))
return sorted(rows, key=lambda row: int(row["section8_index"]))
def asr_catalog_sound_label_rows() -> list[dict[str, str]]:
if not ASR_CATALOG_SOUND_LABEL_MANIFEST.exists():
return []
with ASR_CATALOG_SOUND_LABEL_MANIFEST.open(newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f, delimiter="\t"))
return sorted(rows, key=lambda row: int(row["section8_index"]))
def catalog_offset_candidate_rows() -> list[dict[str, str]]:
if not CATALOG_OFFSET_CANDIDATE_MANIFEST.exists():
return []
with CATALOG_OFFSET_CANDIDATE_MANIFEST.open(newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f, delimiter="\t"))
return sorted(rows, key=lambda row: (row["block_name"], int(row["candidate_object_id"])))
def pokedex_voice_label_rows() -> list[dict[str, str]]:
if not POKEDEX_VOICE_LABEL_MANIFEST.exists():
return []
with POKEDEX_VOICE_LABEL_MANIFEST.open(newline="", encoding="utf-8") as f:
return sorted(csv.DictReader(f, delimiter="\t"), key=lambda row: int(row["object_id"]))
def spike_menu_sound_rows() -> list[dict[str, str]]:
if not SPIKE_MENU_SOUND_MANIFEST.exists():
return []
with SPIKE_MENU_SOUND_MANIFEST.open(newline="") as f:
return sorted(csv.DictReader(f, delimiter="\t"), key=lambda row: row["sound_name"])
def best_named_sound_rows() -> list[dict[str, str]]:
if not BEST_NAMED_SOUND_MANIFEST.exists():
return []
with BEST_NAMED_SOUND_MANIFEST.open(newline="", encoding="utf-8") as f:
return sorted(
csv.DictReader(f, delimiter="\t"),
key=lambda row: (row["bank"], row["name_source"], int(row["section8_index"])),
)
def unresolved_best_named_rows(best_named_rows: list[dict[str, str]]) -> list[dict[str, str]]:
return sorted(
[
row
for row in best_named_rows
if row["name_source"] in {"object_ref", "spike_menu_object_ref"}
],
key=lambda row: (row["bank"], int(row["object_id"]), int(row["section8_index"])),
)
def asset_rows() -> dict[str, list[dict[str, object]]]:
rows = {"images": [], "videos": [], "text": [], "fonts": []}
for path in sorted(ASSET_ROOT.rglob("*")):
if not path.is_file() or path.name == ".DS_Store":
continue
ext = path.suffix.lower()
rel = path.relative_to(WORKSPACE_ROOT).as_posix()
row = {"path": path, "rel": rel, "name": path.name, "size": path.stat().st_size}
if ext in IMAGE_EXTS:
rows["images"].append(row)
elif ext in VIDEO_EXTS:
rows["videos"].append(row)
elif ext in TEXT_EXTS:
rows["text"].append(row)
elif ext in FONT_EXTS:
rows["fonts"].append(row)
return rows
def radium_section3_bitmap_rows() -> list[dict[str, str]]:
if not RADIUM_SECTION3_BITMAP_MANIFEST.exists():
return []
with RADIUM_SECTION3_BITMAP_MANIFEST.open(newline="", encoding="utf-8") as f:
rows = [
row
for row in csv.DictReader(f, delimiter="\t")
if row["decoder_status"] == "raw_indexed_preview" and row["png_path"]
]
return sorted(rows, key=lambda row: int(row["logical_index"]))
def page_nav(page_names: list[str]) -> str:
return "\n".join(f"- [{name[:-3].replace('-', ' ').title()}]({name})" for name in page_names)
def sound_directive(row: dict[str, str]) -> str:
meta = (
f"section8={row['section8_index']} object={row['primary_object_local_id'] or 'unreferenced'} "
f"duration={row['duration_seconds']}s channels={row['channels']} refs={row['command_ref_count']}"
)
title = row["sound_name"]
if row.get("confirmed_name"):
title = f"{row['confirmed_name']} ({row['sound_name']})"
elif row.get("asr_draft_label"):
title = f"ASR draft: {row['asr_draft_label']} ({row['sound_name']})"
return (
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["wav_path"])}" '
f'title="{clean_attr(title)}" meta="{clean_attr(meta)}"}}'
)
def native_named_sound_directive(row: dict[str, str]) -> str:
meta = (
f"native_id={row['native_sound_id']} section8={row['section8_index']} "
f"duration={row['duration_seconds']}s channels={row['channels']} "
f"register={row['register_call_ghidra']} string={row['string_ghidra']}"
)
return (
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["alias_wav_path"])}" '
f'title="{clean_attr(row["name"])}" meta="{clean_attr(meta)}"}}'
)
def native_sound_test_label_directive(row: dict[str, str]) -> str:
meta = (
f"sound_id={row['sound_id']} section8={row['section8_index']} "
f"duration={row['duration_seconds']}s channels={row['channels']} "
f"table={row['table_entry_offset']} string={row['label_string_offset']}"
)
return (
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["alias_wav_path"])}" '
f'title="{clean_attr(row["label"])}" meta="{clean_attr(meta)}"}}'
)
def asr_draft_sound_label_directive(row: dict[str, str]) -> str:
meta = (
f"object={row['object_id']} section8={row['section8_index']} "
f"duration={row['duration_seconds']}s channels={row['channels']} "
f"model={row['model']} logprob={row['avg_logprob']} no_speech={row['no_speech_prob']}"
)
return (
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["wav_path"])}" '
f'title="ASR draft: {clean_attr(row["transcript"])}" meta="{clean_attr(meta)}"}}'
)
def asr_review_sound_label_directive(row: dict[str, str]) -> str:
meta = (
f"object={row['object_id']} section8={row['section8_index']} "
f"duration={row['duration_seconds']}s channels={row['channels']} "
f"rule={row['review_rule']} model={row['model']} "
f"logprob={row['avg_logprob']} no_speech={row['no_speech_prob']}"
)
return (
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["wav_path"])}" '
f'title="ASR review: {clean_attr(row["transcript"])}" meta="{clean_attr(meta)}"}}'
)
def asr_catalog_sound_label_directive(row: dict[str, str]) -> str:
meta = (
f"object={row['object_id']} section8={row['section8_index']} "
f"duration={row['duration_seconds']}s channels={row['channels']} "
f"match={row['match_kind']} phrase={row['match_phrase']} "
f"model={row['model']} logprob={row['avg_logprob']}"
)
title = f"{row['catalog_constant']} (ASR: {row['transcript']})"
return (
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["wav_path"])}" '
f'title="{clean_attr(title)}" meta="{clean_attr(meta)}"}}'
)
def catalog_offset_candidate_directive(row: dict[str, str]) -> str:
meta = (
f"status={row['candidate_status']} block={row['block_name']} "
f"range={row['range_kind']} catalog_index={row['catalog_index']} "
f"object={row['candidate_object_id']} section8={row['section8_index']} "
f"offset={row['offset']} current={row['current_best_source']}"
)
if row.get("sound_test_label"):
meta += f" sound_test={row['sound_test_label']}"
title = f"{row['catalog_constant']} [{row['candidate_status']}]"
return (
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["source_wav_path"])}" '
f'title="{clean_attr(title)}" meta="{clean_attr(meta)}"}}'
)
def pokedex_voice_label_directive(row: dict[str, str]) -> str:
role = row["role"]
if role == "description" and row["role_variant"] != "1":
role = f"description alt {row['role_variant']}"
meta = (
f"pokemon={int(row['pokemon_number']):04d} role={role} object={row['object_id']} "
f"section8={row['section8_index']} duration={row['duration_seconds']}s "
f"score={row['alignment_score']} action={row['alignment_action']}"
)
title = f"Pokedex {int(row['pokemon_number']):04d} {row['pokemon_name']} {role}"
return (
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["alias_wav_path"])}" '
f'title="{clean_attr(title)}" meta="{clean_attr(meta)}"}}'
)
def spike_menu_sound_directive(row: dict[str, str]) -> str:
meta = (
f"section8={row['section8_index']} object={row['primary_object_local_id']} "
f"duration={row['duration_seconds']}s channels={row['channels']} refs={row['command_ref_count']}"
)
return (
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["wav_path"])}" '
f'title="SPIKE menu: {clean_attr(row["sound_name"])}" meta="{clean_attr(meta)}"}}'
)
def best_named_sound_directive(row: dict[str, str]) -> str:
meta = (
f"bank={row['bank']} source={row['name_source']} confidence={row['confidence']} "
f"section8={row['section8_index']} object={row['object_id']} "
f"duration={row['duration_seconds']}s channels={row['channels']} refs={row['command_ref_count']}"
)
title = f"{row['canonical_name']} [{row['name_source']}]"
return (
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["alias_wav_path"])}" '
f'title="{clean_attr(title)}" meta="{clean_attr(meta)}"}}'
)
def unresolved_sound_review_kind(row: dict[str, str]) -> str:
duration = float(row["duration_seconds"])
channels = int(row["channels"])
if channels == 2 and duration >= 30:
return "long_stereo_music_candidate"
if channels == 2 and duration >= 10:
return "mid_stereo_music_or_ambience_candidate"
if duration < 0.25:
return "very_short_fx_candidate"
if channels == 1:
return "mono_fx_or_voice_reject"
return "stereo_fx_candidate"
def unresolved_sound_directive(row: dict[str, str]) -> str:
review_kind = unresolved_sound_review_kind(row)
meta = (
f"bank={row['bank']} review={review_kind} section8={row['section8_index']} "
f"object={row['object_id']} duration={row['duration_seconds']}s "
f"channels={row['channels']} refs={row['command_ref_count']}"
)
title = f"{row['canonical_name']} [{review_kind}]"
return (
f'::audio{{src="{wiki_src(WORKSPACE_ROOT / row["alias_wav_path"])}" '
f'title="{clean_attr(title)}" meta="{clean_attr(meta)}"}}'
)
def asset_directive(kind: str, row: dict[str, object], index: int) -> str:
path = row["path"]
title = row["rel"]
meta = f"{row['size']} bytes"
if kind == "images":
directive = "image"
elif kind == "videos":
directive = "video"
elif kind == "fonts":
directive = "font"
else:
directive = "textasset"
extra = f' family="asset_font_{index:03d}"' if directive == "font" else ""
return (
f'::{directive}{{src="{wiki_src(path)}" title="{clean_attr(title)}" '
f'meta="{clean_attr(meta)}"{extra}}}'
)
def radium_section3_bitmap_directive(row: dict[str, str]) -> str:
path = WORKSPACE_ROOT / row["png_path"]
title = f"Section 3 {int(row['logical_index']):04d} record {row['record_id']}"
meta = (
f"offset={row['record_offset']} flags={row['flags']} "
f"size={row['width']}x{row['height']} payload={row['pixel_payload_length']} "
f"status={row['decoder_status']}"
)
return (
f'::image{{src="{wiki_src(path)}" title="{clean_attr(title)}" '
f'meta="{clean_attr(meta)}"}}'
)
def build_sound_pages(
rows: list[dict[str, str]],
native_rows: list[dict[str, str]],
sound_test_rows: list[dict[str, str]],
asr_rows: list[dict[str, str]],
asr_review_rows: list[dict[str, str]],
asr_catalog_rows: list[dict[str, str]],
catalog_offset_rows: list[dict[str, str]],
pokedex_rows: list[dict[str, str]],
spike_menu_rows: list[dict[str, str]],
best_named_rows: list[dict[str, str]],
) -> list[str]:
page_names: list[str] = []
for page_number, page_rows in enumerate(chunks(rows, SOUND_PAGE_SIZE), start=1):
name = f"sound-gallery-{page_number:02d}.md"
first = (page_number - 1) * SOUND_PAGE_SIZE + 1
last = first + len(page_rows) - 1
lines = [
f"# Sound Gallery {page_number:02d}",
"",
f"Exported Radium PCM sounds {first}-{last} of {len(rows)}.",
"",
"::gallery-start",
*[sound_directive(row) for row in page_rows],
"::gallery-end",
]
page_names.append(write_page(name, "\n".join(lines)))
best_named_pages: list[str] = []
if best_named_rows:
for page_number, page_rows in enumerate(chunks(best_named_rows, SOUND_PAGE_SIZE), start=1):
name = f"sound-best-named-gallery-{page_number:02d}.md"
first = (page_number - 1) * SOUND_PAGE_SIZE + 1
last = first + len(page_rows) - 1
lines = [
f"# Best-Named Sound Export {page_number:02d}",
"",
f"Best-available named aliases {first}-{last} of {len(best_named_rows)}.",
"Name provenance is preserved in the `source` metadata: confirmed native, Pokedex voice alignment, catalog-matched ASR, draft ASR, catalog-offset candidate, low-confidence ASR review candidate, main-image hash match, or structural object reference.",
"",
"::gallery-start",
*[best_named_sound_directive(row) for row in page_rows],
"::gallery-end",
]
best_named_pages.append(write_page(name, "\n".join(lines)))
source_counts: dict[str, int] = {}
for row in best_named_rows:
source_counts[row["name_source"]] = source_counts.get(row["name_source"], 0) + 1
source_lines = [f"- `{source}`: `{count}`" for source, count in sorted(source_counts.items())]
best_named_index = write_page(
"sound-best-named-export.md",
"\n".join(
[
"# Best-Named Sound Export",
"",
f"Generated best-available named alias previews for `{len(best_named_rows)}` total sounds across the main game and SPIKE menu images.",
"",
"The canonical WAV exports remain unchanged. These aliases are generated under `analysis/radium-image/sounds-best-named/` and `analysis/radium-spike-menu-image/sounds-best-named/` so every sound has one stable, traceable, previewable filename using the best current evidence.",
"",
"## Name Sources",
"",
*source_lines,
"",
"Confirmed native names are preferred first. Pokedex voice alignment, catalog-matched ASR names, accepted raw ASR labels, and catalog-offset candidates are used before low-confidence ASR review candidates. Catalog ASR names are inferred from exact transcript-to-catalog phrase matches; raw ASR names are marked draft. Catalog-offset names are marked candidate_unpromoted and remain below direct content evidence. Low-confidence ASR review candidates are only used when no stronger semantic label exists. Object-reference names are structural fallbacks for sounds that still lack semantic labels.",
"",
"## Gallery Pages",
"",
page_nav(best_named_pages),
"",
"## Manifest",
"",
"- `analysis/all-sound-best-name-manifest.tsv`",
]
),
)
else:
best_named_index = ""
unresolved_page = ""
unresolved_rows = unresolved_best_named_rows(best_named_rows)
if unresolved_rows:
review_counts: dict[str, int] = {}
bank_counts: dict[str, int] = {}
for row in unresolved_rows:
review_kind = unresolved_sound_review_kind(row)
review_counts[review_kind] = review_counts.get(review_kind, 0) + 1
bank_counts[row["bank"]] = bank_counts.get(row["bank"], 0) + 1
review_lines = [f"- `{kind}`: `{count}`" for kind, count in sorted(review_counts.items())]
bank_lines = [f"- `{bank}`: `{count}`" for bank, count in sorted(bank_counts.items())]
unresolved_lines = [
"# Unresolved Structural Sound Review",
"",
"These are the remaining best-name rows whose current labels are structural object references rather than semantic names.",
"The `review` metadata is a duration/channel triage hint only; it is not a recovered native or Godot symbol.",
"",
"## Bank Counts",
"",
*bank_lines,
"",
"## Review Buckets",
"",
*review_lines,
"",
"::gallery-start",
*[unresolved_sound_directive(row) for row in unresolved_rows],
"::gallery-end",
]
unresolved_page = write_page("sound-unresolved-review.md", "\n".join(unresolved_lines))
named_page = ""
if native_rows:
named_lines = [
"# Confirmed Native Sound Names",
"",
"These names are confirmed from native `GodotSpikeBridge` registration calls in `/games/pokemon_pro/game`.",
"The alias WAVs are hardlinks or symlinks under `analysis/radium-image/sounds-native-named/` that point back to the full export.",
"",
"::gallery-start",
*[native_named_sound_directive(row) for row in native_rows],
"::gallery-end",
]
named_page = write_page("sound-native-names.md", "\n".join(named_lines))
sound_test_page = ""
if sound_test_rows:
sound_test_lines = [
"# Native Sound Test Labels",
"",
"These labels are raw candidate rows from a native sound-test-adjacent table in `/games/pokemon_pro/game`.",
"Follow-up checks showed the table's numeric field is not a reliable direct Radium local sound ID for every row, so these labels are not promoted into the best-name export.",
"The alias WAVs are retained under `analysis/radium-image/sounds-native-sound-test/` for audit only.",
"",
"::gallery-start",
*[native_sound_test_label_directive(row) for row in sound_test_rows],
"::gallery-end",
]
sound_test_page = write_page("sound-native-sound-test.md", "\n".join(sound_test_lines))
asr_page = ""
if asr_rows:
asr_lines = [
"# Draft ASR Sound Labels",
"",
"These labels are draft content transcripts generated locally with Whisper from exported WAVs.",
"They are useful for triage and preview search, but they are not native symbols and may contain recognition errors.",
"",
"::gallery-start",
*[asr_draft_sound_label_directive(row) for row in asr_rows],
"::gallery-end",
]
asr_page = write_page("sound-asr-draft-labels.md", "\n".join(asr_lines))
asr_review_page = ""
if asr_review_rows:
asr_review_lines = [
"# Low-Confidence ASR Review Candidates",
"",
"These labels are lower-confidence ASR transcripts for sounds that would otherwise be structural object references.",
"They are weaker than accepted draft ASR labels and are not native symbols; the `rule`, `logprob`, and `no_speech` metadata are included so candidates can be audited while listening.",
"",
"::gallery-start",
*[asr_review_sound_label_directive(row) for row in asr_review_rows],
"::gallery-end",
]
asr_review_page = write_page("sound-asr-review-candidates.md", "\n".join(asr_review_lines))
asr_catalog_page = ""
if asr_catalog_rows:
asr_catalog_lines = [
"# Catalog-Matched ASR Sound Labels",
"",
"These labels are accepted ASR transcripts that exactly match known `SE_PK_VO_*` Godot catalog constants or narrow documented ASR error variants.",
"They are stronger than raw transcript slugs for browsing, but remain inferred because no general native/Radium symbol-to-object map has been recovered.",
"",
"::gallery-start",
*[asr_catalog_sound_label_directive(row) for row in asr_catalog_rows],
"::gallery-end",
]
asr_catalog_page = write_page("sound-asr-catalog-labels.md", "\n".join(asr_catalog_lines))
catalog_offset_page = ""
if catalog_offset_rows:
status_counts: dict[str, int] = {}
for row in catalog_offset_rows:
status_counts[row["candidate_status"]] = status_counts.get(row["candidate_status"], 0) + 1
status_lines = [f"- `{status}`: `{count}`" for status, count in sorted(status_counts.items())]
catalog_offset_lines = [
"# Catalog-Offset Candidate Sound Labels",
"",
"These previews are audit-only candidate labels from comparing Godot sound-catalog order against the two Ghidra-confirmed native registration anchor blocks.",
"Non-conflicting rows that previously had only structural object names are used in the best-name export as `catalog_offset_candidate` aliases with `candidate_unpromoted` confidence; the sound-test disagreement row and rows with stronger content evidence are not promoted from this table.",
"",
"## Candidate Status Counts",
"",
*status_lines,
"",
"::gallery-start",
*[catalog_offset_candidate_directive(row) for row in catalog_offset_rows],
"::gallery-end",
]
catalog_offset_page = write_page("sound-catalog-offset-candidates.md", "\n".join(catalog_offset_lines))
pokedex_page = ""
if pokedex_rows:
duplicate_count = sum(1 for row in pokedex_rows if row["alignment_action"] == "duplicate_description")
pokedex_lines = [
"# Pokedex Voice Alignment",
"",
"These labels are aligned from the Radium object stream, `assets/godot_raw/data/pokemon_data.txt`, and local ASR transcripts.",
f"The stream covers `{len(pokedex_rows)}` clips from Radium objects `409` through `1545`, including `{duplicate_count}` duplicate description variants.",
"The alias WAVs are hardlinks or symlinks under `analysis/radium-image/sounds-pokedex-voice/` that point back to the full export.",
"",
"::gallery-start",
*[pokedex_voice_label_directive(row) for row in pokedex_rows],
"::gallery-end",
]
pokedex_page = write_page("sound-pokedex-voice.md", "\n".join(pokedex_lines))
spike_menu_page = ""
if spike_menu_rows:
spike_menu_lines = [
"# SPIKE Menu Sound Export",
"",
"These WAVs are exported from `/games/pokemon_pro/spike3/spike_menu/image.bin`.",
"The parser uses the same section 8 PCM table format and a SPIKE-menu-specific opcode-size table at `0x410ce8` to resolve section 5 command references.",
"",
"::gallery-start",
*[spike_menu_sound_directive(row) for row in spike_menu_rows],
"::gallery-end",
]
spike_menu_page = write_page("sound-spike-menu.md", "\n".join(spike_menu_lines))
confirmed_count = sum(1 for row in rows if row.get("confirmed_name"))
asr_count = sum(1 for row in rows if row.get("asr_draft_label"))
index_lines = [
"# Full Sound Export",
"",
f"Generated preview pages for `{len(rows)}` exported WAV files under `analysis/radium-image/sounds/`.",
f"`{confirmed_count}` exported sounds currently have Ghidra-confirmed native names and aliases under "
"`analysis/radium-image/sounds-native-named/`.",
f"`{len(sound_test_rows)}` raw sound-test-adjacent table rows are documented under "
"`analysis/radium-image/native_sound_test_label_manifest.tsv`; they are not treated as confirmed best-name labels.",
f"`{asr_count}` exported sounds currently have accepted draft ASR transcript labels in "
"`analysis/radium-image/asr_draft_sound_labels.tsv`.",
f"`{len(asr_review_rows)}` rejected ASR rows pass conservative review-candidate filters in "
"`analysis/radium-image/asr_review_sound_label_candidates.tsv`; only rows without stronger names are used as low-confidence best-name aliases.",
f"`{len(asr_catalog_rows)}` ASR-labeled sounds also match known Godot voice catalog constants in "
"`analysis/radium-image/asr_catalog_sound_label_candidates.tsv`.",
f"`{len(catalog_offset_rows)}` catalog-order offset candidate rows are previewable in "
"`analysis/radium-image/catalog_offset_candidate_labels.tsv`; these are audit-only and are not promoted.",
f"`{len(pokedex_rows)}` Pokedex voice clips have aligned semantic labels in "
"`analysis/radium-image/pokedex_voice_label_manifest.tsv`.",
f"`{len(spike_menu_rows)}` SPIKE menu sounds are exported under `analysis/radium-spike-menu-image/sounds/`.",
f"`{len(best_named_rows)}` sounds have complete best-available named aliases under "
"`analysis/radium-image/sounds-best-named/` and `analysis/radium-spike-menu-image/sounds-best-named/`.",
f"`{len(unresolved_rows)}` best-name rows still have structural object-reference labels and are grouped for review in `sound-unresolved-review.md`.",
"",
"Names are deterministic static names based on the best resolved Radium command reference: "
"`objNNNN_vV_cmdC_sSSSS_channels_rate.wav`.",
"",
"Confirmed native names come from `GodotSpikeBridge` registration calls recovered from `/games/pokemon_pro/game`. "
"Raw sound-test-adjacent labels are preserved for audit but are not promoted as confirmed names. "
"ASR draft and review labels are content-derived transcripts and are not treated as confirmed symbols. "
"The remaining sounds keep object/chunk names until a broader native or bytecode symbol-to-object map is recovered.",
"",
"## Gallery Pages",
"",
f"- [Confirmed Native Sound Names]({named_page})" if named_page else "",
f"- [Native Sound Test Labels]({sound_test_page})" if sound_test_page else "",
f"- [Draft ASR Sound Labels]({asr_page})" if asr_page else "",
f"- [Low-Confidence ASR Review Candidates]({asr_review_page})" if asr_review_page else "",
f"- [Catalog-Matched ASR Sound Labels]({asr_catalog_page})" if asr_catalog_page else "",
f"- [Catalog-Offset Candidate Sound Labels]({catalog_offset_page})" if catalog_offset_page else "",
f"- [Pokedex Voice Alignment]({pokedex_page})" if pokedex_page else "",
f"- [SPIKE Menu Sound Export]({spike_menu_page})" if spike_menu_page else "",
f"- [Best-Named Sound Export]({best_named_index})" if best_named_index else "",
f"- [Unresolved Structural Sound Review]({unresolved_page})" if unresolved_page else "",
page_nav(page_names),
"",
"## Manifests",
"",
"- `analysis/radium-image/sound_export_manifest.tsv`",
"- `analysis/radium-image/sound_export_manifest.json`",
"- `analysis/radium-image/command_sound_refs.tsv`",
"- `analysis/radium-image/native_sound_id_map.tsv`",
"- `analysis/radium-image/native_named_sound_manifest.tsv`",
"- `analysis/radium-image/native_sound_test_label_manifest.tsv`",
"- `analysis/radium-image/asr_draft_sound_labels.tsv`",
"- `analysis/radium-image/asr_review_sound_label_candidates.tsv`",
"- `analysis/radium-image/asr_catalog_sound_label_candidates.tsv`",
"- `analysis/radium-image/catalog_offset_candidate_labels.tsv`",
"- `analysis/radium-image/pokedex_voice_label_manifest.tsv`",
"- `analysis/radium-spike-menu-image/sound_export_manifest.tsv`",
"- `analysis/all-sound-best-name-manifest.tsv`",
]
write_page("sound-export.md", "\n".join(index_lines))
return [
"sound-export.md",
*([named_page] if named_page else []),
*([sound_test_page] if sound_test_page else []),
*([asr_page] if asr_page else []),
*([asr_review_page] if asr_review_page else []),
*([asr_catalog_page] if asr_catalog_page else []),
*([catalog_offset_page] if catalog_offset_page else []),
*([pokedex_page] if pokedex_page else []),
*([spike_menu_page] if spike_menu_page else []),
*([best_named_index] if best_named_index else []),
*([unresolved_page] if unresolved_page else []),
*best_named_pages,
*page_names,
]
def build_asset_pages(rows_by_kind: dict[str, list[dict[str, object]]]) -> list[str]:
settings = {
"images": ("Asset Image Gallery", IMAGE_PAGE_SIZE),
"videos": ("Asset Video Gallery", VIDEO_PAGE_SIZE),
"text": ("Asset Text Gallery", TEXT_PAGE_SIZE),
"fonts": ("Asset Font Gallery", TEXT_PAGE_SIZE),
}
page_names: list[str] = []
grouped_pages: dict[str, list[str]] = {}
for kind, rows in rows_by_kind.items():
title, page_size = settings[kind]
grouped_pages[kind] = []
for page_number, page_rows in enumerate(chunks(rows, page_size), start=1):
name = f"asset-{kind}-gallery-{page_number:02d}.md"
first = (page_number - 1) * page_size + 1
last = first + len(page_rows) - 1
lines = [
f"# {title} {page_number:02d}",
"",
f"Openable {kind} assets {first}-{last} of {len(rows)}.",
"",
"::gallery-start",
*[asset_directive(kind, row, first + idx) for idx, row in enumerate(page_rows)],
"::gallery-end",
]
grouped_pages[kind].append(write_page(name, "\n".join(lines)))
page_names.extend(grouped_pages[kind])
index_lines = [
"# Asset Previews",
"",
"Generated preview pages for openable reconstructed assets under `assets/openable/`.",
"",
"| Type | Count | Pages |",
"| --- | ---: | --- |",
]
for kind in ["images", "videos", "text", "fonts"]:
pages = grouped_pages[kind]
links = ", ".join(f"[{i + 1}]({name})" for i, name in enumerate(pages))
index_lines.append(f"| {kind} | `{len(rows_by_kind[kind])}` | {links} |")
write_page("asset-previews.md", "\n".join(index_lines))
return ["asset-previews.md", *page_names]
def build_radium_section3_bitmap_pages(rows: list[dict[str, str]]) -> list[str]:
if not RADIUM_SECTION3_BITMAP_MANIFEST.exists():
return []
with RADIUM_SECTION3_BITMAP_MANIFEST.open(newline="", encoding="utf-8") as f:
all_rows = list(csv.DictReader(f, delimiter="\t"))
status_counts: dict[str, int] = {}
for row in all_rows:
status_counts[row["decoder_status"]] = status_counts.get(row["decoder_status"], 0) + 1
status_lines = [f"- `{status}`: `{count}`" for status, count in sorted(status_counts.items())]
gallery_pages: list[str] = []
for page_number, page_rows in enumerate(chunks(rows, RADIUM_BITMAP_PAGE_SIZE), start=1):
name = f"radium-section3-bitmap-gallery-{page_number:02d}.md"
first = (page_number - 1) * RADIUM_BITMAP_PAGE_SIZE + 1
last = first + len(page_rows) - 1
lines = [
f"# Radium Section 3 Bitmap Gallery {page_number:02d}",
"",
f"Raw-indexed section 3 bitmap previews {first}-{last} of {len(rows)}.",
"",
"::gallery-start",
*[radium_section3_bitmap_directive(row) for row in page_rows],
"::gallery-end",
]
gallery_pages.append(write_page(name, "\n".join(lines)))
index_lines = [
"# Radium Section 3 Bitmap Previews",
"",
"These previews are decoded from `/games/pokemon_pro/image.bin` section 3 pointer-table records.",
"The current static decoder treats records as `uint32 id`, `uint32 flags`, `uint16 width`, `uint16 height`, followed by raw indexed pixel bytes when the payload is large enough for `width * height` pixels.",
"Pixel values are rendered as grayscale; `0xff` is rendered transparent. Compact payloads are retained in the manifest as undecoded rather than guessed.",
"",
"## Decoder Status Counts",
"",
*status_lines,
"",
"## Gallery Pages",
"",
page_nav(gallery_pages),
"",
"## Manifest",
"",
"- `analysis/radium-image/section3_bitmap_manifest.tsv`",
]
return [write_page("radium-section3-bitmaps.md", "\n".join(index_lines)), *gallery_pages]
def main() -> int:
sounds = sound_rows()
native_sounds = native_named_sound_rows()
sound_test_labels = native_sound_test_label_rows()
asr_draft_labels = asr_draft_sound_label_rows()
asr_review_labels = asr_review_sound_label_rows()
asr_catalog_labels = asr_catalog_sound_label_rows()
catalog_offset_candidates = catalog_offset_candidate_rows()
pokedex_voice_labels = pokedex_voice_label_rows()
spike_menu_sounds = spike_menu_sound_rows()
best_named_sounds = best_named_sound_rows()
assets = asset_rows()
radium_bitmaps = radium_section3_bitmap_rows()
pages = build_sound_pages(
sounds,
native_sounds,
sound_test_labels,
asr_draft_labels,
asr_review_labels,
asr_catalog_labels,
catalog_offset_candidates,
pokedex_voice_labels,
spike_menu_sounds,
best_named_sounds,
) + build_asset_pages(assets) + build_radium_section3_bitmap_pages(radium_bitmaps)
best_named_source_counts: dict[str, int] = {}
for row in best_named_sounds:
best_named_source_counts[row["name_source"]] = best_named_source_counts.get(row["name_source"], 0) + 1
summary = {
"sound_count": len(sounds),
"native_named_sound_count": len(native_sounds),
"native_sound_test_label_count": len(sound_test_labels),
"asr_draft_sound_label_count": len(asr_draft_labels),
"asr_review_sound_label_count": len(asr_review_labels),
"asr_catalog_sound_label_count": len(asr_catalog_labels),
"catalog_offset_candidate_count": len(catalog_offset_candidates),
"pokedex_voice_label_count": len(pokedex_voice_labels),
"spike_menu_sound_count": len(spike_menu_sounds),
"best_named_sound_count": len(best_named_sounds),
"unresolved_structural_sound_count": len(unresolved_best_named_rows(best_named_sounds)),
"best_named_source_counts": best_named_source_counts,
"asset_counts": {kind: len(rows) for kind, rows in assets.items()},
"radium_section3_bitmap_preview_count": len(radium_bitmaps),
"pages": pages,
}
OUT_MANIFEST.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
print(f"wrote {len(pages)} media preview pages")
print(json.dumps(summary, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())