Files
psxrecomp/docs/STRING_TRANSLATION.md
Shokunin b3b0960044 docs: record rollout PR B (runtime_pc) in the segment-aware design
docs/SEGMENT_AWARE_CODE.md:
- §3.1 names emitter functions instead of line numbers, adds the
  delay-slot jalr link and the VSync-query hook, and moves cosim_* out of
  the identity keys.
- §5.2 records what PR B routes through runtime_pc(), what keeps the
  compile address, the code segment's default (the image's own), and
  what the tests cover (the dispatch rows wait for PR C).
- §5.4 notes that D's variants must tail-transfer jump-table cases in
  another segment.
- §5.6 records that B routes emit_pre_icache's uncached test.
- §7.1 lists the two new regression guards; KNOWN_GAPS stays at 10.
- §7.2 adds the probe's interpreter and compiled columns next to
  Beetle's.
- §8 records B's validation.
- §9 records the re-keyed store-PC keys and their SCPH-1001 gate, the
  filters' Beetle divergence, and the confirmed game-syscall EPC gap
  (its own change).

FAITHFUL_TIMING_PLAN and ACCURACY_BURNDOWN get the dated entries; the
burndown also records the RAM 0x0-0xF boot-scratch divergence from
Beetle. STRING_TRANSLATION's note that attributed GP0 quads to ROM
0xBFC38B1C now names the runtime key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:12:27 -07:00

56 KiB
Raw Permalink Blame History

On-the-Fly String Translation / Localization for psxrecomp

Status: IMPLEMENTED and wired into the runtime. Reusable framework feature; Tsumu Light (SLPS-02253) is the first consumer.

This document was left marked "SPEC (design only — no implementation yet)" long after the feature shipped. It now reads as the design rationale behind working code, not a proposal. Where the prose below says "will" or "should", read it as describing what was built.

The implementation lives in runtime/src/text_xlate.cpp (~970 lines) behind runtime/include/text_xlate.h:

Entry point Role
text_xlate_init(project_root, language) Loads translations/*.toml under the project root; called from runtime/src/main.cpp at startup
text_xlate_set_language(language) Re-applies the resolved language (launcher/settings selection)
text_xlate_on_dispatch(cpu, target) Per-dispatch capture/substitution hook
text_xlate_vram_upload(x, y, w, h) VRAM-upload interception path
text_xlate_debug_json(subcmd, out, cap) TCP debug-server introspection

Configuration is [localization].language (falling back to [runtime]), with [localization].languages driving the launcher's language dropdown; see recompiler/src/config_loader.h. Setting the language to jp, off, or empty disables substitution while leaving capture running.

Goal. Let a Japanese-only PSX title be played in English (or any target language) by capturing the game's source strings from memory and substituting translated bytes on the fly, so a non-Japanese speaker can navigate menus, read tutorials/dialogue, and understand the game. Ultimate success = every string we can find renders in the target language. Multilingual is a first-class requirement (English is just the first target); the mechanism is a shared psxrecomp module any title can adopt.

This design is a direct PSX adaptation of the author's proven N64 prior art in the n64recomp fork for Pocket Monsters Stadium (Japan) (F:\Projects\n64recomp\PocketMonstersStadiumRecomp\). Section 1 documents that mechanism; Section 2 documents Tsumu's concrete text reality; Sections 3+ define the PSX framework.


0. TL;DR architecture

Three always-on subsystems hung off the psx_dispatch call chokepoint (the one place every guest function call funnels through — see §3.1):

  1. Capture ring (always-on). On every dispatch, scan the argument registers (and a few stack slots) for pointers to Shift-JIS text records. Hash each distinct record (FNV-1a64 over its raw bytes), record {hash, bytes, addr, target-PC, call-site}, persist immediately. This enumerates every string to translate and discovers which PCs draw text — no arm-then-capture (per the project's ring-buffer rule).
  2. Translation table (per-title, human-editable, multilingual). TsumuRecomp/translations/tsumu.toml — keyed by source-byte hash (+ optional src_addr), one target string per language, with encoding/length metadata. Hot-reloaded on mtime change.
  3. Apply hook (always-on). A dispatch hook (sibling to the existing g_psx_bios_hle_hook) that, when the target is a registered text-draw PC and an arg points at a translated source record, writes the target-language bytes into transient guest scratch (below $sp), repoints the arg register, and lets the original draw routine render it. No regen, no engine change, unbounded replacement length, dynamic format strings preserved.

The only title-specific pieces are (a) the translation table and (b) the font situation (§4). Everything else — capture ring, apply hook, config, coverage tooling — is shared framework code.


1. Prior art: the n64recomp PMS-J mechanism

Files: PocketMonstersStadiumRecomp/src/main/diagnostics.cpp (the engine), include/trace.h (the hook wiring), tools/pms_build_translations.py (authoring), translations.json (the table). Verified by reading the source.

1.1 Capture — always-on string inventory + text-PC census

The recompiler emits a bare TRACE_ENTRY() at the top of every recompiled function when game.toml has trace_mode = true. trace.h expands it to call, with rdram/ctx in scope:

pkmnstadium_textdraw_probe(__func__, ra, a0, a1, a2, a3);  // census + inventory
pkmnstadium_text_xlate(__func__, rdram, ctx);              // apply

pkmnstadium_textdraw_probe (diagnostics.cpp:1235) is the always-on capture (explicitly no env gate — "we never 'arm' capture and hope to catch the window, we record every unique on-screen string as it is first drawn and persist it immediately"):

  • read_text_arg() (1152) validates that an arg register points at a NUL-terminated EUC-JP/ASCII run ≥2 bytes: every byte must be newline, printable ASCII, or a EUC-JP lead/kana byte (0x8E/0x8F/0xA1–0xFE). The RDRAM-range check is the real discriminator — coords/ints/non-pointers are rejected before the byte scan.
  • Class-1 convention gate: most text draws use x=a0, y=a1, str=a2; the a0<0x1000 && a1<0x1000 "looks like screen coords" test rejects binary headers (Yay0/PRESJPEG) that decode as valid EUC-JP by chance.
  • _Printf chokepoint (kPrintfPC): the universal vararg formatter is special-cased — its format string (a2) is inventoried regardless of the coord gate, so battle/overlay text built via _Printf is captured too.
  • strinv_upsert() records every distinct string (content-hash keyed) into stringdump.log, flushed on every newly-seen string so a crash never loses captures.
  • textpc_add() maintains a lock-free open-addressing set of PCs proven to draw text (~70 distinct), so the apply hook only pays the arg-scan cost on real text PCs.

There is also a capture-only discovery probe (xlate_discovery, gated by PMS_TEXTPROBE) that finds which register/stack slot a known-untranslated watch-string arrives in — used to learn non-standard conventions (battle labels, Pokédex descriptions in r7/r4, the %sポケモン category classifier in r5).

1.2 Translation table format

translations.json next to the exe, hot-reloaded on mtime (1550):

{ "src_hex": "a5d0a5c8a5eb", "src_jp": "バトル", "target": "Battle", "orig_w": 3 }
  • src_hex — the raw source bytes (EUC-JP) in hex. The load path converts hex→bytes and keys the in-memory map by FNV-1a64 of those bytes (robust to RDRAM buffer reuse — the key is content, not address).
  • target — the replacement (ASCII; may contain %s/%d specifiers to preserve dynamic args, and \n for line breaks).
  • orig_w — Japanese footprint in glyph units (documentation + default fit budget). Optional fit_w overrides the width budget (use more/less space than the original when there is room). src_jp is documentation only.

Authoring workflow (pms_build_translations.py): stringdump.log → decode EUC-JP → strings_decoded.tsv (persistent master across sessions) → author key_en.tsv (<fnv-key>\t<english>) → build joins them into translations.json, merge-preserving hand-authored entries. A todo mode lists still-untranslated strings sorted by draw count.

1.3 Apply — on-the-fly substitution

pkmnstadium_text_xlate (2100) runs at every function entry; cheap no-ops (g_xlate_active reentrancy guard, lock-free g_xlate_armed reject, reload every 1024 calls). On a text PC it takes one of three paths:

  • Pointer-swap (write_guest_str + repoint ctx->r6). Content-hash the source at the fmt arg; on a KV hit, write the replacement (ASCII, NUL-term) into transient scratch on the calling thread's guest stack (sp-0x800 for format templates, sp-0xC00 for literal args — two disjoint slots so a paired _Printf swap can't self-clobber) and repoint the arg register. The original function then runs unchanged — _Printf formats it (dynamic %d/%s stay intact) and renders glyph-by-glyph. No engine change, no regen, unbounded replacement length (never writes back into game RAM).
  • Proportional self-render (self_render_static, 1887). For plain strings at coordinate drawers, it draws the English itself by calling the game's glyph routine (FUN_8001a3e4) per character with per-glyph proportional advances (calibrated by measuring real ink width of each glyph tile in VRAM, calibrate_slot/measure_glyph) and auto-fit condensing so English never spills past the Japanese footprint (fit_w budget or per-line JP width). Then it suppresses the caller's own draw with an empty fmt.
  • Register-specific paths (xlate_desc, xlate_general) for the discovered non-r6 conventions, including a subtle _Printf writeproc byte-count re-sync when a shorter replacement would otherwise embed a NUL and truncate a composed string.

1.4 Multilingual & font

  • Multilingual is table-driven: the KV value is per-language. (PMS shipped English only, but the schema and hook are language-agnostic — selecting a language means loading a different column/file.)
  • Font/glyph: PMS-J's font sheet already contained Latin glyphs, so English renders directly through the existing glyphs (the whole "no glyph injection needed" premise). pkmnstadium_fontdump (2227) dumps the font-state struct + glyph sheets (.i8) to answer exactly this gating question; the fallback contingency (documented in comments/tools) is to inject an ASCII glyph set if a game lacks Latin glyphs. The renderer measures glyph tiles directly from VRAM to get proportional widths.

1.5 What carries to PSX vs. what changes

Aspect N64 (PMS-J) PSX (Tsumu)
Hook point TRACE_ENTRY() emitted per function (needs trace_mode=true regen) psx_dispatch chokepoint — one install site, no regen, works in Release
Encoding EUC-JP (JIS X 0208 via A1/A3/A4/A5 leads) Shift-JIS (2-byte fullwidth + 1-byte halfwidth kana A1–DF) + custom control framing
String delimiter NUL NUL (menu labels) AND 0xFFFF (framed message records)
Memory swizzle big-endian ^3 byte swap little-endian, no swizzle (simpler)
Arg access ctx->r4..r7 cpu->gpr[4..7]
Glyph routine FUN_8001a3e4 (known) text-draw PC pinned at runtime by the census (Phase 0)
Latin glyphs present in font fullwidth Latin present (proven); halfwidth ASCII TBD (§4)

2. Tsumu's concrete text reality

Evidence gathered from the headerless boot EXE (TsumuRecomp/ghidra/SLPS_022.53_no_header.bin, 512 KB, loads at 0x80010000, entry 0x800691F0) via direct byte analysis + Ghidra, plus DISC.md.

2.1 Encoding — Shift-JIS (confirmed)

The text is Shift-JIS, not ASCII and not EUC-JP (EUC was the N64 game). Proven by decoding real records:

Address Decoded Notes
0x80010C5C TSUMU light プレイデータ fullwidth Latin + fullwidth space + katakana ("TSUMU light / Play Data")
0x80010C94 TSUMU マップデータ "TSUMU / Map Data"
0x80010190 ???????? fullwidth question marks (placeholder score row)
0x80070AA8 それじゃ、がんばってね!… tutorial dialogue (hiragana)

The character mix is:

  • 2-byte fullwidth (JIS X 0208): kanji, hiragana, katakana, fullwidth Latin 0x8260–0x8279 (A–Z), 0x8281–0x829A (a–z), fullwidth digits 0x824F–0x8258, symbols. Lead byte 0x81–0x9F / 0xE0–0xFC, trail 0x40–0x7E / 0x80–0xFC.
  • 1-byte halfwidth katakana 0xA1–0xDF (e.g. 0xBB=サ) — mixed inline with 2-byte chars. This means the font has a halfwidth grid.
  • Custom control framing interleaved with text (raw bytes, not SJIS):
    • 0xFF 0xFF — message terminator (record end).
    • 0x82 0xFE 0xFF / 0x81 … sequences — line break / formatting markers.
    • Menu labels (e.g. 0x80010C5C) are simpler: plain 2-byte SJIS, NUL-terminated.

Consequence for the framework: the PSX text validator must be Shift-JIS aware (2-byte lead+trail, 1-byte halfwidth kana A1–DF, ASCII 20–7E), must pass through control bytes, and must treat a "string" as a record delimited by either NUL (menu labels) or 0xFFFF (framed messages). The hash key is the raw record bytes up to (not including) the terminator.

2.2 String storage

  • In-EXE Shift-JIS text block: a large body of tutorial/hint/system messages lives in the EXE at roughly 0x80070900–0x80072000 (offsets 0x60900–0x62000), reached through a pointer/index table.
  • Text pointer table at 0x80071474 (preceded by header word 0x0000FC10 at 0x80071470): a contiguous array of 32-bit little-endian pointers into the text records, e.g. 0x80070918, 0x80070958, 0x800709A4, 0x800709F0, … 0x80070AA8, 0x80070AC4, …. This is the message-ID → record-address map; the text system indexes it by ID.
  • Menu/UI labels live earlier in .rodata as NUL-terminated SJIS (0x80010C5C, 0x80010C94, the ???? row at 0x80010190, memcard save titles BISLPS-02253 TSMLGT / TSMMP for "TSUMU light" / "TSUMU MaP").
  • Disc-streamed text/data: DISC.md lists MOJI.BIN (文字 = "glyphs/ characters" → the font sheet) and MONDAI.BIN (問題 = "problems/puzzles" → puzzle text/data), plus GRAPH1-4.BIN, SOUND.BIN, FMV (DOUGA.BIN/ END.STR), TSUMU.XA. Disc image + files are present locally under TsumuRecomp/tsumu/. So some text is static (patchable in the EXE) and some is loaded/composed from disc at runtime (must be caught at draw time).

Static vs. dynamic: the in-EXE block and menu labels are static and could in principle be patched at load, but the framework deliberately intercepts at draw time (like PMS) so it uniformly covers disc-loaded and dynamically- composed strings too, without touching game RAM.

Recompiler note (not blocking): the recompiler's flow walker currently mis-decodes the text/pointer-table region as code (it emitted func_80070300 over 0x80070300…, decoding the pointer words as load-byte instructions — classic data-walk over-reach). This does not affect the runtime translation hook (which works on live memory), but a data/code separation pass would clean the generated C. Track separately.

2.3 Text render path

psx_dispatch funnels every call, so the exact draw routine is pinned at runtime by the capture census (Phase 0), exactly as PMS discovered its ~70 text PCs. Static anchors already found to seed/confirm it:

  • The text-lookup routine loads base 0x80071474 and indexes it by message ID to fetch a record pointer; that pointer flows (as a0/a1/a2) into a glyph-draw routine that walks the SJIS bytes, maps each char to a glyph cell in the font texture (uploaded from MOJI.BIN into VRAM), and issues textured sprite primitives via the GPU. Menu labels reach the same drawer directly.
  • Phase 0 confirms the exact draw PC(s), the arg register carrying the string pointer (PSX convention is commonly a0=str or a0=x,a1=y,a2=str), and the glyph-draw leaf (the PSX analogue of FUN_8001a3e4) if a self-render path is wanted for fit control.

(Full static disassembly of the drawer was not completed here because Ghidra auto-analysis produced 0 functions on the raw dump and the region is data-poisoned; the runtime census is the reliable, spec-sanctioned way to pin it and is a Phase-0 deliverable.)

2.4 Font / glyph availability

  • The game renders fullwidth Latin and fullwidth digits today (TSUMU light, score digits), so its font sheet already contains a fullwidth Latin A–Z / a–z / 0–9 block (SJIS 0x8260–0x8279, 0x8281–0x829A, 0x824F–0x8258). English can therefore render with zero glyph injection by emitting fullwidth-Latin SJIS codes — the same "existing glyphs" premise that made PMS work.
  • The game also uses halfwidth katakana (0xA1–0xDF), so a halfwidth grid exists. Whether a halfwidth ASCII block (0x20–0x7E) is also present — which would let English render compactly (half width) instead of fullwidth — is the open question, resolved in Phase 0 by dumping MOJI.BIN / the VRAM font page. PSX SJIS fonts frequently include halfwidth ASCII, so this is likely but must be confirmed.
  • Contingency (if no compact Latin): inject an ASCII glyph set into the font page — append a Latin row to the MOJI.BIN glyph upload or upload a supplementary sheet into free VRAM and map ASCII codes to it (mirrors PMS's documented injection fallback).

2.5 Length / layout constraints

  • Menu labels sit in fixed-width boxes; framed messages are laid out with the game's own 0xFFFF/line-break control codes into a text window.
  • English is usually longer than Japanese, and fullwidth Latin doubles the width, so overflow is the primary layout risk. Mitigations (§3.4): prefer halfwidth ASCII when available; re-insert the game's own line-break control codes to reflow multi-line messages; optionally a self-render fit path (PMS-style advance condensing) for coordinate drawers; and per-entry max_w/fit metadata in the table to cap width.

3. Framework design (PSX)

Shared module, shipped at runtime/src/text_xlate.cpp + runtime/include/text_xlate.h (peers of fntrace.* and bios_hle.*). The design below was written as a proposal and is described in the future tense in places; it is built, so read "will" as "does".

3.1 The hook point — psx_dispatch

psx_dispatch / psx_dispatch_impl (declared cpu_state.h:164) is the single chokepoint every guest call routes through. Two precedents already hang off it:

  • fntrace_record(cpu, target) (fntrace.c:58) — an always-on ring of every dispatch ({frame, target, ra, a0..a3, s3, sp}), explicitly designed as "record from boot, query the window after the fact" (the project ring-buffer rule). This is the capture analogue.
  • g_psx_bios_hle_hook (bios_hle.h:54) — a hook slot consulted at the top of psx_dispatch_impl with the pre-normalize physical address; it can intercept/replace the target (HLE call + boot shell-skip). This is the apply analogue.

The translation module adds the same two shapes, so it needs no per-function codegen and works in Release.

3.2 Capture subsystem (always-on ring)

Add text_capture_record(cpu, target) called from psx_dispatch right beside fntrace_record (or fold into it):

  1. For each candidate arg (gpr[4..7], plus a few stack slots sp+0x10..), run sjis_read_record(addr, out, &len, &term):
    • Reject if addr outside main RAM (0x80000000–0x80200000 and mirrors).
    • Walk bytes until NUL or 0xFFFF terminator (cap kSrcMax=512), classi- fying each: ASCII 0x20–0x7E, halfwidth kana 0xA1–0xDF, SJIS 2-byte lead+trail, or known control byte. Reject on any non-textish byte (filters structs/pointers). Require ≥1 real text char.
  2. Hash the record bytes: h = fnv1a64(bytes, len).
  3. Upsert into an always-on inventory ring {h, bytes[≤64 sample], full-len, src_addr, target_pc, ra, first_frame, count}; on first sight, append to a persistent tsumu_stringdump.log (flush immediately — crash-safe, per the ring-buffer rule).
  4. textpc_add(target) into a lock-free set so the apply hook only scans args on proven text PCs.

This runs from boot for every player (no arm gate). "Enumerate every string" = drive the game (§6) and read the ever-growing inventory; "find untranslated" = query the ring for records with no table entry.

Coord/_Printf disambiguation: reuse PMS's precision filters adapted to PSX — a coordinate gate (a0,a1 < 0x1000) for the class-1 drawer, plus explicit handling for any universal formatter PC once the census reveals it.

3.3 Translation table (multilingual, human-editable)

Location: TsumuRecomp/translations/tsumu.toml (single multilingual file; TOML fits the project's config conventions — game_options.toml, SCPH1001.toml). Loaded by the shared module from a path the runtime injects (peer of the overlay-cache dir injection). Hot-reloaded on mtime change.

Schema:

# tsumu.toml — Tsumu Light string translations (multilingual)
schema = 1
default_lang = "en"
langs = ["en", "es", "de", "fr"]   # first-class multilingual; add columns freely

[[entry]]
src_hex   = "82bb82ea82b6..."   # raw Shift-JIS record bytes (hex), the hash key
src_jp    = "それじゃ、がんばってね!"  # documentation (decoded), not used for matching
src_addr  = 0x80070AA8          # optional disambiguator when two records collide on hash
term      = "ffff"              # terminator that delimited this record: "nul" | "ffff"
orig_w    = 12                  # JP footprint in glyph cells (fit budget / docs)
max_w     = 12                  # optional hard width cap (glyph cells) for overflow control
note      = "tutorial: end-of-lesson encouragement"
en = "Well then, do your best!"
es = "¡Pues venga, esfuérzate!"
de = "Also dann, gib dein Bestes!"

[[entry]]
src_hex = "8273827282758..."    # TSUMU light プレイデータ
term    = "nul"
en = "TSUMU light  Play Data"
  • Key = fnv1a64(src bytes) (content hash — robust to buffer reuse), with src_addr as a tiebreak if two distinct records ever collide.
  • Per-language target strings; untranslated / missing-language falls back to the source (render the original Japanese) so the game is never broken by a gap.
  • Target strings are authored in UTF-8; the apply layer transcodes UTF-8 → game glyph codes (§3.4). \n requests a line break (emitted as the game's own line-break control code). %d/%s-style specifiers are preserved for dynamic args if the census shows a formatter path.
  • orig_w/max_w drive width fitting.

Authoring tools (shared, psxrecomp/tools/, adapted from PMS): text_xlate_decode.py (stringdump → decoded TSV master, Shift-JIS via Python cp932), text_xlate_build.py (join <key>\tUTF-8 → tsumu.toml, merge-preserving), text_xlate_todo.py (list untranslated by draw count → coverage report).

3.4 Apply subsystem (dispatch hook)

Install g_psx_text_xlate_hook (sibling to g_psx_bios_hle_hook), consulted at the top of psx_dispatch_impl after the BIOS-HLE hook, gated by a lock-free armed flag (table non-empty) and the textpc_contains(target) set:

  1. Read the source record at the string-arg register (sjis_read_record).
  2. h = fnv1a64(...); look up {lang} target. Miss → return without change (fallback to source).
  3. Transcode the UTF-8 target → a byte sequence the game's drawer will render:
    • Halfwidth-ASCII path (preferred, if Phase 0 confirms halfwidth Latin glyphs): emit ASCII 0x20–0x7E directly — compact, closest to JP width.
    • Fullwidth-Latin path (guaranteed): map each ASCII char to its fullwidth SJIS code (A→0x8260…, a→0x8281…, 0→0x824F…, space→0x8140). Always works because those glyphs are proven present. Double width → rely on width fitting.
    • Injected-glyph path (contingency): map to injected ASCII glyph cells.
    • Re-emit the record's framing: preserve/insert the game's 0xFFFF terminator and line-break control codes; convert \n to the game's line-break code.
  4. Write to guest scratch below the caller's $sp (gpr[29]) using the runtime store accessors (little-endian, no swizzle): pick a scratch slot with stack headroom (e.g. sp - 0x800, & ~7), bounds-checked against RAM; NUL/ 0xFFFF-terminate.
  5. Repoint the string-arg register at the scratch address. Return "not handled" so the original draw routine runs and renders the replacement (the whole no-regen premise). A reentrancy thread_local guard prevents the hook re-triggering on any nested draw calls.

Length / layout handling:

  • Prefer halfwidth ASCII (compact). Use table max_w to cap width.
  • For framed multi-line messages, reflow by inserting the game's own line-break control codes at word boundaries to fit the text window.
  • Optional self-render fit path (Phase 1+, mirrors PMS self_render_static): once the glyph-draw leaf PC is known, draw the target ourselves per-glyph with condensed advances and suppress the caller's draw — gives pixel-accurate fit-to-JP-footprint. Not required for first light; the pointer-swap path is.

3.5 Language selection

  • game.toml / settings.toml: add [localization] language = "en" (peer of the existing renderer/aspect/FMV/turbo keys; settings.toml overrides game.toml, CLI overrides both — per the project's settings precedence). "jp"/"off" = passthrough (no translation).
  • Launcher: a "Language" dropdown populated from the table's langs list; writes the choice to game_options.toml. Runtime switch = reselect → the module reloads the active language column (no restart needed; hot-reload already covers table edits).
  • Default = default_lang from the table (English for Tsumu).

3.6 Coverage workflow

The loop to reach "all strings target-language" (§0 goal):

  1. Capture pass. Fresh run; drive every screen/menu/tutorial/dialogue/ending (self-driven via the debug port + input injection, or a scripted playthrough). The always-on ring logs every distinct record to tsumu_stringdump.log.
  2. Decode + author. text_xlate_decode.py → master TSV (Japanese visible). Fill translations; text_xlate_build.py → tsumu.toml.
  3. Apply + verify. Re-run with the table; the hook substitutes. Verify visually (screenshot both windows — never infer from a counter), per the project's verification rules.
  4. Re-capture to find gaps. text_xlate_todo.py diffs the live inventory against the table → coverage report (N/total translated, untranslated sorted by draw count). Repeat until the untranslated set is empty across a full playthrough.
  5. Coverage report artifact: total distinct records seen, translated %, per-screen breakdown, list of dynamic/composed strings needing special handling. "We've found them all" = a full playthrough adds zero new records to the inventory.

3.7 Framework generality (what's shared vs. title-specific)

Shared (framework) Title-specific
Capture ring + sjis_read_record + inventory persistence The translation table (<title>.toml)
fnv1a64 keying, text-PC census set The font situation (Latin present? inject?)
Apply dispatch hook + guest-scratch write + arg repoint Encoding profile (SJIS here; another game might be custom-tiled)
UTF-8 → glyph transcoders (ASCII, fullwidth-Latin, injected) The string-arg register convention (pinned by census)
Config keys + launcher wiring + hot-reload Control-framing profile (terminators, line-break codes)
Authoring tools (decode/build/todo/coverage) —

Encoding is abstracted behind an EncodingProfile (validator + terminator rules + transcoders) selected per title in game.toml ([localization] encoding = "shift_jis"), so a game using a custom tile-index encoding plugs in its own profile without touching the capture/apply core.


4. Font/glyph plan — concrete Tsumu situation

  1. Guaranteed today: fullwidth Latin + fullwidth digits are in the font (proven by TSUMU light). Ship English on the fullwidth-Latin path for first light — zero asset work, guaranteed to render.
  2. Phase-0 check: dump MOJI.BIN / the live VRAM font page and inspect for a halfwidth ASCII block (0x20–0x7E). The presence of halfwidth katakana (0xA1–0xDF) means a halfwidth grid exists; if ASCII is in it, switch to the compact halfwidth-ASCII path (better width fit).
  3. If neither compact option: inject an ASCII glyph set — extend the MOJI.BIN upload or blit a supplementary Latin sheet into free VRAM and map ASCII to those cells. Add a small font_probe/font_dump tool (PSX analogue of pkmnstadium_fontdump) that reports the font page layout and which code ranges have ink.

5. Risks / open questions

  1. Halfwidth ASCII glyphs present? Determines compact vs. fullwidth (width budget). Phase-0 blocker for layout quality (not for first light).
  2. Exact text-draw PC(s) + string-arg register. Pinned by the Phase-0 census; menu labels vs. framed messages may use different drawers/registers (PMS had ~70 PCs and multiple conventions — expect the same spread).
  3. Control-code framing semantics. 0xFFFF (terminator) and the 0x81…/ 0x82 0xFE 0xFF sequences (line break/format) must be decoded so we preserve them across substitution and can reflow. Reverse the exact code set in Phase 0.
  4. Dynamically composed strings (scores, counts, names concatenated at runtime, e.g. the ???? placeholder row): the source bytes vary, so a pure content-hash misses them. Handle via formatter-template entries ("%d"-style) once the census reveals a composing routine, or per-fragment translation — same class of problem PMS solved for %sポケモン.
  5. Length overflow / fixed boxes. English (esp. fullwidth) overruns menu boxes; needs max_w capping, reflow via line-break codes, and possibly the self-render fit path.
  6. Disc-loaded text (MONDAI.BIN/MOJI.BIN). Confirm puzzle/problem text is caught at draw time (it should be — the capture is draw-time, not load-time); if a puzzle is Japanese-character gameplay, translation may be gameplay-affecting and should be scoped carefully (menus/UI/tutorials first).
  7. DTE / dictionary compression. Not observed (text is plain SJIS in the EXE), but confirm no compressed text table exists on disc before declaring full coverage.
  8. Hot path cost. The apply hook runs at every dispatch; keep the fast reject (lock-free armed flag + text-PC set membership) ahead of any RAM scan, exactly as PMS does, so non-text dispatch pays ~nothing.

6. Phased implementation plan

  • Phase 0 — Discovery (runtime census). Add the capture ring + sjis_read_record + inventory persistence + text-PC census. Drive Tsumu; dump tsumu_stringdump.log. Pin the text-draw PC(s), the string-arg register, the control-code set, and the font situation (MOJI.BIN dump). Deliver the encoding profile. (No visible change yet — pure observability.)
  • Phase 1 — Apply (pointer-swap). Install g_psx_text_xlate_hook; implement the fullwidth-Latin transcoder + guest-scratch write + arg repoint. Author a starter tsumu.toml (main menu). Verify menus render English visually.
  • Phase 2 — Encoding/layout polish. Halfwidth-ASCII path if available; reflow via line-break codes; max_w fitting; framed-message coverage.
  • Phase 3 — Multilingual + launcher. Language dropdown, game.toml/ settings.toml key, runtime switch, second language column to prove the pipeline.
  • Phase 4 — Coverage to 100%. Iterate capture→author→verify with the coverage report until a full playthrough surfaces no untranslated records. Handle dynamic/composed strings and any injected-glyph work.
  • Phase 5 — Generalize. Lift the module to a clean framework API (EncodingProfile, config-driven table path), document a "new title" recipe, and (optionally) fix the data/code separation for the mis-decoded text region.

Appendix Z — Implementation status & Phase-0 empirical findings (2026-07-05)

The mechanism is IMPLEMENTED and verified working headless on Tsumu; the translation table is an ongoing coverage effort. Hard-won findings from driving the real game (these correct/extend §2, which was written pre-implementation):

  • Hook point (as specced): text_xlate_on_dispatch() is called from fntrace_record() at the psx_dispatch chokepoint. No generated-code edits, no BIOS regen, works in the Release build. Capture + apply confirmed live.
  • Two encodings, two endiannesses. The EXE menu labels (e.g. 0x80010C5C TSUMU light) are big-endian Shift-JIS, NUL-terminated. The message-table text (tutorial/hint/dialogue, drawn via a string pointer) is little-endian 16-bit Shift-JIS (bytes bf 82 = ち), framed with control codes and 0xFFFF-terminated. The transcoder must emit the matching endianness (PSX_XLATE_LE); this should become an EncodingProfile flag.
  • Control framing (confirmed): FE FF = line break; FF FF = end of message; 30 FC / 10 FC = page/prompt breaks; $/% (8x codes) = on-screen button icons; fullwidth space 81 40 pads fixed-width fields.
  • Two draw paths — only one is interceptable. Message/dialogue text is drawn by passing a string pointer to a formatter → captured & translatable. The title prompt, HUD ("ステージ"/"バッテリー"), and menu labels are drawn per-glyph as sprites (glyph index, no string pointer) → NOT reachable by arg-scanning. Full HUD/menu localization needs a second hook that intercepts the per-glyph sprite draw and remaps the glyph index — a larger, separate effort (open item).
  • Struct-embedded strings crash if naively replaced. Some NUL records are fixed-width struct fields with binary params packed after the text (e.g. the ちゅーとりあるの5 "Tutorial N" label carries trailing coordinate bytes). Replacing the whole record corrupts the struct and derails the game (observed PC=0). Apply is therefore gated to standalone 0xFFFF-framed messages by default (PSX_XLATE_ALLOW_NUL=1 to override for vetted labels). This is the concrete form of the §5.4 "dynamically-composed / struct" risk.
  • Capture quality gate. The relaxed reader admits vertex/coordinate binary that passes by chance (~20k records). A gate requiring ≥2 hiragana/katakana/ fullwidth chars (SJIS lead 0x82/0x83) keeps the always-on inventory a clean enumeration (~52 real records). Authoring reads it via TCP xlate dump/todo.
  • Verification. Framed tutorial hints apply on real draws (hits climb, game stays alive, English written little-endian). These particular hints are composed during a load transition and displayed only in interactive tutorial mode; a full on-screen visual pass needs menu navigation to that mode (and, for the HUD/menu, the per-glyph hook above). Debug-server screenshot is flaky after extended headless runs — a tooling bug to fix (CLAUDE.md rule 15).

Coverage status (138 entries shipped): the full in-EXE message table was statically enumerated — pointer table at 0x80071474 plus the block 0x80070900–0x80073700 — decoded (little-endian Shift-JIS, control framing honored) and translated to English: mode-select descriptions, the entire interactive tutorial, ~90 per-stage hints, all memory-card save/load/format messages, the Yes/No confirmation dialogs, and the contributor credits. Every entry's src_hex is byte-exact to what the game draws — cross-checked by hashing against the always-on runtime capture ring (confirmed matches), so the apply hook fires on the real draws (verified: hits climb, no crash, English written little-endian).

Reader limitation (small gap): the byte-based reader rejects a record whose first byte isn't "textish" — which for LE storage means a first glyph whose low byte is 0xA0 (i.e. records starting with あ, e.g. the name-entry prompt "あなたの名前を教えてね"). Those few stay Japanese until the reader is made LE-word-aware (would rehash the table).

Still out of scope / open: (1) per-glyph sprite-draw text — HUD (ステージ/バッテリー), title prompt, menu labels, the "CLEAR!!" banner, the name-entry glyph grid — needs a separate glyph-index-remap hook (deferred feature, by design); (2) MONDAI.BIN disc puzzle data (if it carries string-pointer text, the always-on capture will surface it during play); (3) fold endianness + the LE-first-byte fix into EncodingProfile; (4) screenshot writes fail to some temp paths — use a project-dir path.

Appendix Y — Per-glyph text: investigation & why it resists (2026-07-05)

The HUD / title / menu labels are drawn per-glyph as sprites (glyph index → font tile → sprite), NOT via the string-pointer formatter, so the message-path hook cannot reach them. Investigation results (concrete, for the eventual hook):

Font glyph-index map (table at EXE 0x80074ACA, one LE-SJIS code per index):

  • Latin A–Z = indices 73–98, digits 0–9 = 105–114 (primary bank); a second Latin/digit bank at 213–252. Hiragana fills 0–64 and 121–207. So for any English char the glyph index is known. The font already contains fullwidth Latin/digit glyphs (confirmed) — no glyph injection needed.
  • Note: this table has no katakana (it is the name-entry character set); the katakana glyphs the HUD uses live at other indices in the VRAM font (MOJI.BIN) — dumping MOJI.BIN / the VRAM font page is still needed to map katakana → index if a katakana label is to be recognized.

Label storage — per-glyph labels are static little-endian Shift-JIS in the EXE, in fixed-width, space-padded slots (patchable):

  • Level names ~0x80070755–0x800708E4: はこにわ(0x8007075E) たわー(0x800707AC) くらげ(0x800707C6) せんしゃ(0x800707E0) せぱれーと(0x800707FA) おてほん(0x80070814) ろじうら(0x800708E4) … each padded with fullwidth spaces (81 40) to a fixed slot.
  • Tutorial labels ~0x80070300+ (ちゅーとりある ×6); mode name のーまるげーむ; はい/いいえ at several sites.

Cleanest hook (designed, not shipped): since fullwidth Latin glyphs exist, a load-time source patch — after game-start, overwrite a label's katakana/ hiragana SJIS in RAM with fullwidth-Latin English SJIS (ステージ→STAGE), padded to the slot with 81 40 — makes the existing per-glyph routine draw English, with no new draw routine and no glyph injection. English must fit the slot (the space padding gives the budget; e.g. たわー→TOWER, くらげ→JELLYFISH). For labels drawn instead via the string-pointer path, they simply go in tsumu.toml like any message entry.

Why it is not shipped/verified here: the bar is a screenshot of English on these screens, and the screens are unreachable in the headless build: the attract never auto-plays the gameplay demo (150 s observed: title/Sony-logo only), and reaching the mode/level-select or in-game HUD requires completing the name-entry glyph grid (enter a char, navigate to the "End" cell, confirm) — which is intractable via blind debug-input injection (input reaches the pad, verified, but precise grid navigation to "End" did not land). Committing a RAM-patch feature that overwrites game memory without a rendered-English screenshot would be unverified coverage, so it is documented here instead.

Remaining resisters within per-glyph: the HUD counter ステージ:X–Y (label storage not found statically — composed at runtime / in an overlay; needs gameplay capture to pin); the title prompt and CLEAR!! (likely baked sprite graphics, not glyph-drawn → would need image replacement, out of the glyph model). Next step for whoever has a reachable build: dump MOJI.BIN, confirm the katakana index range, ship the load-time source patch for the padded label slots, and screenshot the level-select / HUD.

Appendix A — Key source references

n64recomp prior art (F:\Projects\n64recomp\PocketMonstersStadiumRecomp\):

  • src/main/diagnostics.cpp — capture (pkmnstadium_textdraw_probe 1235, read_text_arg 1152, strinv_upsert/textpc_add), apply (pkmnstadium_text_xlate 2100, self_render_static 1887, xlate_desc/ xlate_general), load_translations 1550, pkmnstadium_fontdump 2227.
  • include/trace.h — TRACE_ENTRY() hook wiring.
  • tools/pms_build_translations.py — decode/build/todo authoring.
  • translations.json — table format.

psxrecomp hook points (paths relative to this repository; they were captured from the since-removed _wt-tsumu worktree, but the files live on master):

  • runtime/src/text_xlate.cpp + runtime/include/text_xlate.h — the shipped module: text_xlate_init / _set_language / _on_dispatch / _vram_upload / _debug_json. Loads every translations/*.toml.
  • runtime/include/fntrace.h + runtime/src/fntrace.c:58 — always-on dispatch ring (capture analogue).
  • runtime/include/bios_hle.h:54 (g_psx_bios_hle_hook) + runtime/src/bios_hle.c:297 — dispatch hook slot precedent (apply analogue).
  • runtime/include/cpu_state.h:164 — psx_dispatch / psx_dispatch_call.

Tsumu evidence (F:\Projects\psxrecomp\TsumuRecomp\):

  • ghidra/SLPS_022.53_no_header.bin — boot EXE (load 0x80010000, entry 0x800691F0).
  • Menu labels 0x80010C5C, 0x80010C94, 0x80010190; tutorial text block ~0x80070900–0x80072000; text pointer table 0x80071474 (header 0x0000FC10 @ 0x80071470).
  • DISC.md — MOJI.BIN (font), MONDAI.BIN (puzzle text), disc file list.
  • game.toml — load_address, entry_pc, debug_port = 4510.

Appendix X — Per-glyph coverage pass: shipped, and the composed-HUD resisters (2026-07-05)

Follow-up to Appendix Y. Concrete outcomes of the per-glyph work, with the static evidence that decides what is source-patchable vs. what genuinely resists.

Shipped (RAM source-patch layer — [[glyph_label]])

  • Stage/level/tutorial names — the full uniform 26-byte-stride table at 0x80070300–0x800708FE (59 slots, one per puzzle). Confirmed clean: every slot start is +26 from the last (text + fullwidth-space pad), so slot bounds are certain. Patched in RAM once resident, verified byte-for-byte against the source before writing exactly 26 bytes. (commit f00631e runtime, 31ab3c2 table.)
  • Name-entry prompt あなたの名前を教えてね。 @ 0x80071360 — a static 0xFFFF-framed record whose renderer reads the source address directly (a pointer-repoint [[entry]] captures it with xl=true and hits increments, but the pixels don't change). Source-patched instead: [[glyph_label]] width 24 (12 cells) → fullwidth "YOUR NAME?", trailing FF FF @ 0x80071378 left intact. Renders English on-screen. (commit 31ba92d.)
  • Name-entry Latin input is NATIVE. The kana grid already draws the font's fullwidth Latin A–Z and digits 0–9 (char table 0x80074ACA, indices 73–98 / 213–252). A non-JP player can type an English name today — no change needed; the only JP on that screen was the prompt above.

Key discriminator: static source address ⇒ source-patchable

The RAM source-patch works for any label the game draws from a fixed EXE address, regardless of draw path (per-glyph sprite loop OR string formatter) — both read the same bytes. It CANNOT touch text that is composed at runtime (no static source to overwrite).

CLEAR!! banner — ALREADY ENGLISH (no work needed)

Reaching a gameplay stage (attract demo, HUD on-screen) and screenshotting the stage-clear moment shows the banner already renders "CLEAR!!" in stylized Latin letters — it is not Japanese. Consistent with the static evidence: クリア (4e 83 8a 83 41 83) appears only message-embedded ("…をクリアしていく"…), there is no standalone クリア / fullwidth CLEAR / ASCII CLEAR in the EXE, and the disc's only pre-rendered text graphic is the TSUMU light logo. The clear banner is drawn from Latin glyphs the game already owns. No translation required (the earlier "resister" assumption was wrong — corrected here).

In-game HUD — RESOLVED 2026-07-06, see Appendix Z (the analysis below was superseded)

The characterization below assumed the HUD labels were composed per-glyph at stage load. They are not: the labels exist as pre-rendered pixel strips inside the glyph-sheet asset itself (the 64hw-wide block uploaded to VRAM 832,256 — MOJI.BIN carries the font AND the baked label strips). The stage-load compose just MoveImages the whole strip (src=(834,352) 18×16 ステージすう:, src=(852,352) 18×16 のこりバッテリー:) into the HUD cache row at y=32. The shipped fix is the [[vram_patch]] upload-time pixel patch (Appendix Z) — no glyph remap hook was needed. Historical analysis kept for the record:

In-game HUD ステージ:X–Y / バッテリー:N — RESISTER (characterized, NOT shipped)

The HUD label is the one genuinely-unshipped item. Rendering model, pinned via the always-on GP0 ring (gpu_frame_dump, records every primitive with its submitting PC + DMA src_addr):

  • The katakana ステージ (58 83 65 83 5b 81 57 83) / バッテリー (6f 83 62 83 65 83 8a 83 5b 81) exist in a full 2 MB live RAM scan only at the static message-text addresses (0x800709xx…), each followed by a particle (を/が/の/は) — never as a standalone HUD label, and there is no ステージ: template. So there is no static source the RAM source-patch could rewrite.
  • The game composites heavily: it averages ~6 draw primitives/frame (gp0_draw ÷ frames) and rebuilds only the animated foreground each frame (character + a few blocks, ~8 textured 0x2C quads at screen y ≥ 31, issued via the BIOS DMA library from an OT at RAM ~0x138xxx). The HUD sits at screen y ≈ 8 and is NOT among the per-frame draws — it is composed once at stage-load into the display/background and thereafter just shown.
  • The stage-load compose is therefore the only place to intercept it, and it happens before the GP0 ring's steady-state window; catching it needs a fresh stage load while observing (force real gameplay past name-entry, or clear a stage so the next one composes), then decoding whether the label is blitted as CPU→VRAM glyph bitmaps (0xA0) or drawn as font-tile quads, and remapping that specific compose — a scoped per-glyph/blit intercept, not a source-patch.

This remains the per-glyph interception effort flagged in Appendix Y. It was NOT shipped: a clean, pixel-verified remap of the composited HUD wasn't achieved in this pass, and a blanket VRAM font swap (the only "quick" alternative) would corrupt katakana in menus/messages and can't handle the ステージ→STAGE 4-vs-5-glyph layout change. Per project Rule 5 (pixels or it's not done) and the no-fragile-hacks rule, no partial was shipped. Next step for a reachable build: force a fresh stage-load, dump the GP0 ring across the compose, and remap there.

Disc asset map (all files extracted from Tsumu Light (Japan).bin)

ISO9660 root: DOUGA.BIN (FMV), END.STR (ending FMV), FB_TEX.BIN (4bpp block textures @VRAM 768,0), GRAPH1/4.BIN (16bpp TSUMU light title @320,0), GRAPH2/3.BIN (8bpp character sprite sheets @320,256 / 576,256), HECTLOGO.TIM (4bpp Hect logo), TSU_KOK.TIM (16bpp), MOJI.BIN (22528 B raw font, lba 7119 — the HUD/per-glyph font sheet), MONDAI.BIN (puzzle data), SOUND.BIN, TSUMU.XA. Rendered PNGs confirm no CLEAR!! banner among the baked graphics.

HUD remap — the drawer is now pinned (mechanism cracked, for the next pass)

Live GP0-ring capture on the name-entry screen (which draws per-glyph text at the screen top, same class as the HUD) pinned the per-glyph text drawer concretely:

  • Drawer PC 0x8005443C — issues one textured quad per glyph via direct GP0 (not DMA), building the primitives in game RAM at ~0x80092xxx. Each glyph is a 0x2C textured quad: screen rect (X,Y, 16–32 px cell) + U,V into texpage 0x1E selecting the font tile, CLUT 0x7C10.
  • The font page (texpage 0x1E) holds hiragana AND Latin — proven because the name-entry grid draws A–Z/0–9 through this exact drawer. So the Latin tile U,Vs the HUD remap needs are directly readable off the grid's letter quads (correlate each grid cell's on-screen glyph ↔ its quad U,V).
  • Remaining blocker for a clean HUD remap: catch the HUD label's own compose (it draws its katakana quads via this drawer at stage-load) to get its source prims + katakana U,Vs, then either rewrite those prims' U,V to the Latin tiles or hook 0x8005443C to remap the HUD-position glyph quads. Reaching a fresh stage-load is gated on confirming a name in the grid (blind debug-input grid navigation to the "decide" cell did not land) or a memcard LOAD path — a reachable interactive build (the USER's) clears this immediately. With the drawer + font-tile scheme now known, the remap itself is a scoped U,V rewrite.

HUD remap — reachability blockers (why it's not finishable headless)

Attempted the LOAD-GAME unblock (skip the name grid) to reach a dynamic HUD:

  • Both memcards are empty. saves/card1.mcd + card2.mcd are formatted (magic MC) but have 0 used blocks and no SLPS/TSM save markers — so LOAD GAME has nothing to load and cannot reach a stage.
  • NEW GAME still routes through the name-entry glyph grid; blind debug-input navigation to its "decide" cell did not land (confirmed again this pass).
  • The attract demo's HUD is not per-frame-drawn. With the GP0 ring recording from the game's first GP0 write (frame 554), a full 554→3754 capture shows the HUD (screen y≈8) is never drawn as glyph quads or any top-region primitive; steady gameplay frames contain only the animated 3D scene (~8 textured 0x2C quads at screen y≥31, attributed to the BIOS store 0xBFC38B1C). The HUD is composed once at stage-load and thereafter only displayed. In the demo its values are static (0-5 / ∞), so there is no dynamic HUD draw to intercept and no discriminator to capture. (Later note: that attribution came from the store-PC stamp before #420, when overlay stores kept a private copy of it, so it may name an older store. The shell's GP0 store at ROM 0xBFC38B1C executes at 0x80050B1C, and since segment-aware PR B that runtime PC is the stamp and the memory.c key; SEGMENT_AWARE_CODE.md §9.)

Net: the drawer + font-tile scheme are cracked (see above), but capturing the HUD label's own glyph quads (kana U,Vs + a call-context/screen-region discriminator) requires real gameplay where the HUD is dynamically drawn (battery counting down / stage advancing) — reachable only via a real save (create one first) or driving NEW GAME past the name grid on an interactive build. No hook was shipped: without the captured discriminator, any remap would risk rewriting non-HUD quads (blocks/menus) — a fragile hack (Rule 5, forbidden). Precise remaining step for the USER's reachable build: enter a stage so the HUD draws, dump the GP0 ring at drawer 0x8005443C, read the HUD glyph quads' U,Vs + screen row, then rewrite those to the font's Latin tiles for STAGE / BATTERY.

Appendix Z — VRAM-strip patch layer ([[vram_patch]]) — HUD labels SHIPPED (2026-07-06)

The missing model: pre-rendered strips, not composed glyphs

The HUD labels (and, it turns out, the entire menu system) are pre-rendered pixel strips baked into the glyph-sheet asset — not glyph-code strings, not per-glyph composes. Evidence chain:

  • The 144-entry font order table at 0x80074a9c contains no katakana (only hiragana + fullwidth Latin/digits + a little punctuation), yet the HUD shows katakana ステージすう:/のこりバッテリー: — so that text cannot come from the glyph-code path at all.
  • No SJIS code string for either label exists anywhere in the EXE or in a full 2 MB RAM scan (searched as both hiragana and katakana, both byte orders).
  • Rendering VRAM (832,256) 64×256 as 4bpp shows the sheet rows 0–4 are the font, and rows past the font are baked strips: the HUD labels at y=352, ○ボタンで すすむ !! at y=368, results-screen labels (ステージすう/ のこりバッテリー/ランク/ほじゅうバッテリー/ノーマル/スペシャル) at y≈448–511; block (896,256) holds the main-menu strips (ちゅーとりある, のーまる げーむ, ふりー ぷれい, すとろーく げーむ, でーた せーぶ, にゅーげーむ, こんてぃにゅー, ハイスコア) and (896,384) the high-score table (1位/2位/3位, なまえ, のこりバッテリー, グループ); block (768,256) the pause menu (このままつづける, さいしょからはじめる, あきらめる, そうさタイプのへんこう, 目標残バッテリー:) and misc (〜を選択, Tブロック…不正です, ○ボタンでせーぶへ!!); (960,~440) データ1〜4. (CRASH/CLEAR!!/EMPTY strips are already English.)
  • The sheet arrives as four GP0 0xA0 LoadImage uploads of 64×256 at (768,256) (832,256) (896,256) (960,256) (~boot frame 1500; ring-attributed to the libgpu LoadImage path, guest buffer transient — a CD-read bounce buffer, which is why the RAM scan finds nothing).
  • At stage load the game MoveImages whole label strips into a HUD cache row: src=(834,352) 18×16 + src=(852,352) 18×16 → dst=(642..,32), digits blitted separately from the big-digit row y=336 (ring bld[] chains: 0x80041E78/0x80041EA4 return sites). Patching the SHEET therefore fixes every downstream consumer.

Mechanism (general, runtime): patch rides the game's own upload

text_xlate.cpp gains a third layer, [[vram_patch]]: on completion of every CPU→VRAM transfer (gpu.c GP0 0xA0 state machine, both completion paths call text_xlate_vram_upload(x,y,w,h)), any configured rect fully contained in the upload is verified halfword-for-halfword against src_hex (expected JP pixels) and only on an exact match rewritten with <lang>_hex through gr_vram_read/gr_vram_write — the renderer facade the upload itself uses, so software / GL / supersampling mirrors all stay coherent. Re-applies on every matching re-upload (source-path patch, NOT a one-shot VRAM poke — survives scene reloads; a different asset at the same coords fails verify and is left alone). Config lives with the other translation surfaces in translations/tsumu.toml; language-gated like [[glyph_label]]. Debug: xlate stats → vram_patches/vram_applied; xlate vpatch force-applies against current VRAM (verify still gates) for config iteration without waiting for a re-upload.

Shipped entries + authoring recipe

Shipped: hud_stage (834,352,18,16) → STAGE, hud_battery (852,352,18,16) → BATTERY — rects exactly the game's own compose-blit strip units; the replacement preserves the bullet-dot and colon pixels verbatim and swaps only the JP text texels for text composed from the sheet's own fullwidth Latin glyphs (row y=304, 8 texels/glyph, palette nibbles {1=stroke, 2=shadow} — proven HUD-visible because the y=320 row, same palette, is blitted onto the HUD). Authoring loop (scratchpad composestrips2.py): peek the strip rect → src_hex; overwrite the measured text-texel span with Latin glyph texels → en_hex; preview-render both; append to toml; xlate reload + vpatch to test live. Same-day follow-up shipped the rest of the inventory — 37 entries total: title menu (TUTORIAL/NEW GAME/CONTINUE ×2 variants), pause menu (CONTINUE/RESTART/GIVE UP/CHANGE CONTROLS + TARGET BATT), results (STAGES/BATTERY/RANK/REFILL/NORMAL/SPECIAL), mode menu (TUTORIAL/NORMAL GAME/FREE PLAY/STROKE GAME/DATA SAVE), DATA 1-4, STAGE SELECT, SELECT MOVIE PIECE, BAD T BLOCK COUNT/LAYOUT, HIGH SCORE + FREE pills, TO CONTINUE / TO SAVE prompts, high-score headers NAME/BATTERY. Per-strip palette families matter: pause/mode boxes bg=1 with {c,d,e,f} text (Latin glyphs must be remapped, e.g. {1→d,2→c}, or the stroke nibble collides with the box bg and vanishes); gray prompt strips bg=5 keep the Latin {1,2} palette; the high-score pill is light-on-dark (map {1→f,2→d}). Remaining: グループ box + 1/2/3位 badges, and the level-select レベル1/2/3 labels which are NOT in this sheet (likely the 8bpp character sheets GRAPH2/3) — separate hunt.