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>
56 KiB
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):
- 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). - Translation table (per-title, human-editable, multilingual).
TsumuRecomp/translations/tsumu.toml— keyed by source-byte hash (+ optionalsrc_addr), one target string per language, with encoding/length metadata. Hot-reloaded on mtime change. - 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; thea0<0x1000 && a1<0x1000"looks like screen coords" test rejects binary headers (Yay0/PRESJPEG) that decode as valid EUC-JP by chance. _Printfchokepoint (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_Printfis captured too.strinv_upsert()records every distinct string (content-hash keyed) intostringdump.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 (~70distinct), 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 byFNV-1a64of those bytes (robust to RDRAM buffer reuse — the key is content, not address).target— the replacement (ASCII; may contain%s/%dspecifiers to preserve dynamic args, and\nfor line breaks).orig_w— Japanese footprint in glyph units (documentation + default fit budget). Optionalfit_woverrides the width budget (use more/less space than the original when there is room).src_jpis 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+ repointctx->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-0x800for format templates,sp-0xC00for literal args — two disjoint slots so a paired_Printfswap can't self-clobber) and repoint the arg register. The original function then runs unchanged —_Printfformats it (dynamic%d/%sstay 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_wbudget 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-r6conventions, including a subtle_Printfwriteproc 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 digits0x824F–0x8258, symbols. Lead byte0x81–0x9F/0xE0–0xFC, trail0x40–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(offsets0x60900–0x62000), reached through a pointer/index table. - Text pointer table at
0x80071474(preceded by header word0x0000FC10at0x80071470): 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
.rodataas NUL-terminated SJIS (0x80010C5C,0x80010C94, the????row at0x80010190, memcard save titlesBISLPS-02253 TSMLGT/TSMMPfor "TSUMU light" / "TSUMU MaP"). - Disc-streamed text/data:
DISC.mdlistsMOJI.BIN(文字 = "glyphs/ characters" → the font sheet) andMONDAI.BIN(問題 = "problems/puzzles" → puzzle text/data), plusGRAPH1-4.BIN,SOUND.BIN, FMV (DOUGA.BIN/END.STR),TSUMU.XA. Disc image + files are present locally underTsumuRecomp/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
0x80071474and indexes it by message ID to fetch a record pointer; that pointer flows (asa0/a1/a2) into a glyph-draw routine that walks the SJIS bytes, maps each char to a glyph cell in the font texture (uploaded fromMOJI.BINinto 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 ora0=x,a1=y,a2=str), and the glyph-draw leaf (the PSX analogue ofFUN_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 (SJIS0x8260–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 dumpingMOJI.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.BINglyph 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/fitmetadata 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 ofpsx_dispatch_implwith 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):
- For each candidate arg (
gpr[4..7], plus a few stack slotssp+0x10..), runsjis_read_record(addr, out, &len, &term):- Reject if
addroutside main RAM (0x80000000–0x80200000and mirrors). - Walk bytes until NUL or
0xFFFFterminator (capkSrcMax=512), classi- fying each: ASCII0x20–0x7E, halfwidth kana0xA1–0xDF, SJIS 2-byte lead+trail, or known control byte. Reject on any non-textish byte (filters structs/pointers). Require ≥1 real text char.
- Reject if
- Hash the record bytes:
h = fnv1a64(bytes, len). - 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 persistenttsumu_stringdump.log(flush immediately — crash-safe, per the ring-buffer rule). 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), withsrc_addras 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).
\nrequests 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_wdrive 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:
- Read the source record at the string-arg register (
sjis_read_record). h = fnv1a64(...); look up{lang}target. Miss → return without change (fallback to source).- 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–0x7Edirectly — 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
0xFFFFterminator and line-break control codes; convert\nto the game's line-break code.
- Halfwidth-ASCII path (preferred, if Phase 0 confirms halfwidth Latin
glyphs): emit ASCII
- 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. - 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_localguard prevents the hook re-triggering on any nested draw calls.
Length / layout handling:
- Prefer halfwidth ASCII (compact). Use table
max_wto 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.tomloverridesgame.toml, CLI overrides both — per the project's settings precedence)."jp"/"off"= passthrough (no translation).- Launcher: a "Language" dropdown populated from the table's
langslist; writes the choice togame_options.toml. Runtime switch = reselect → the module reloads the active language column (no restart needed; hot-reload already covers table edits). - Default =
default_langfrom the table (English for Tsumu).
3.6 Coverage workflow
The loop to reach "all strings target-language" (§0 goal):
- 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. - Decode + author.
text_xlate_decode.py→ master TSV (Japanese visible). Fill translations;text_xlate_build.py→tsumu.toml. - 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.
- Re-capture to find gaps.
text_xlate_todo.pydiffs 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. - 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
- 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. - 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). - If neither compact option: inject an ASCII glyph set — extend the
MOJI.BINupload or blit a supplementary Latin sheet into free VRAM and map ASCII to those cells. Add a smallfont_probe/font_dumptool (PSX analogue ofpkmnstadium_fontdump) that reports the font page layout and which code ranges have ink.
5. Risks / open questions
- Halfwidth ASCII glyphs present? Determines compact vs. fullwidth (width budget). Phase-0 blocker for layout quality (not for first light).
- 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
~70PCs and multiple conventions — expect the same spread). - Control-code framing semantics.
0xFFFF(terminator) and the0x81…/0x82 0xFE 0xFFsequences (line break/format) must be decoded so we preserve them across substitution and can reflow. Reverse the exact code set in Phase 0. - 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ポケモン. - Length overflow / fixed boxes. English (esp. fullwidth) overruns menu
boxes; needs
max_wcapping, reflow via line-break codes, and possibly the self-render fit path. - 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). - 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.
- 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; dumptsumu_stringdump.log. Pin the text-draw PC(s), the string-arg register, the control-code set, and the font situation (MOJI.BINdump). 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 startertsumu.toml(main menu). Verify menus render English visually. - Phase 2 — Encoding/layout polish. Halfwidth-ASCII path if available;
reflow via line-break codes;
max_wfitting; framed-message coverage. - Phase 3 — Multilingual + launcher. Language dropdown,
game.toml/settings.tomlkey, 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 fromfntrace_record()at thepsx_dispatchchokepoint. 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.
0x80010C5CTSUMU 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 (bytesbf 82= ち), framed with control codes and 0xFFFF-terminated. The transcoder must emit the matching endianness (PSX_XLATE_LE); this should become anEncodingProfileflag. - Control framing (confirmed):
FE FF= line break;FF FF= end of message;30 FC/10 FC= page/prompt breaks;$/%(8xcodes) = on-screen button icons; fullwidth space81 40pads 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 (observedPC=0). Apply is therefore gated to standalone 0xFFFF-framed messages by default (PSX_XLATE_ALLOW_NUL=1to 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 TCPxlate 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
screenshotis 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, digits0–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) — dumpingMOJI.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_probe1235,read_text_arg1152,strinv_upsert/textpc_add), apply (pkmnstadium_text_xlate2100,self_render_static1887,xlate_desc/xlate_general),load_translations1550,pkmnstadium_fontdump2227.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 everytranslations/*.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 (load0x80010000, entry0x800691F0).- Menu labels
0x80010C5C,0x80010C94,0x80010190; tutorial text block~0x80070900–0x80072000; text pointer table0x80071474(header0x0000FC10@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. (commitf00631eruntime,31ab3c2table.) - Name-entry prompt
あなたの名前を教えてね。@0x80071360— a static 0xFFFF-framed record whose renderer reads the source address directly (a pointer-repoint[[entry]]captures it withxl=trueandhitsincrements, but the pixels don't change). Source-patched instead:[[glyph_label]]width 24 (12 cells) → fullwidth "YOUR NAME?", trailingFF FF@0x80071378left intact. Renders English on-screen. (commit31ba92d.) - Name-entry Latin input is NATIVE. The kana grid already draws the font's
fullwidth Latin
A–Zand digits0–9(char table0x80074ACA, 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 textured0x2Cquads 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 a0x2Ctextured quad: screen rect (X,Y, 16–32 px cell) + U,V into texpage0x1Eselecting the font tile, CLUT0x7C10. - The font page (texpage 0x1E) holds hiragana AND Latin — proven because the
name-entry grid draws
A–Z/0–9through 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
0x8005443Cto 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.mcdare formatted (magicMC) but have 0 used blocks and noSLPS/TSMsave 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
0x2Cquads at screen y≥31, attributed to the BIOS store0xBFC38B1C). 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 ROM0xBFC38B1Cexecutes at0x80050B1C, and since segment-aware PR B that runtime PC is the stamp and thememory.ckey; 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
0x80074a9ccontains 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×256as 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!!/EMPTYstrips are already English.) - The sheet arrives as four
GP0 0xA0LoadImage 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 (ringbld[]chains:0x80041E78/0x80041EA4return 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.