Records the outcome of the per-glyph translation pass (Buckets 1-3): - SHIPPED: stage/level/tutorial names (source-patch, f00631e/31ab3c2); name-entry prompt -> "YOUR NAME?" (source-patch, 31ba92d); name-entry Latin input confirmed NATIVE (grid already draws the font's A-Z/0-9). - RESISTERS (not shipped, no fake): the in-game HUD (ステージ/バッテリー) and the CLEAR!! banner are composed per-glyph at runtime with NO static source address. Proven: a live 2 MB RAM scan finds ステージ/バッテリー only inside static message sentences (never a standalone HUD label, no ステージ: template); クリア/CLEAR/CLEAR exist nowhere as a standalone banner; and every disc graphic TIM was extracted+rendered — the only baked text is the already-English TSUMU light logo. So the source-patch (which needs a static address) cannot reach them; they require the scoped per-glyph sprite-draw index-remap hook (Appendix Y), a separate larger effort. Includes the full disc asset map. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
44 KiB
On-the-Fly String Translation / Localization for psxrecomp
Status: SPEC (design only — no implementation yet). Reusable framework feature; Tsumu Light (SLPS-02253) is the first consumer.
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, proposed location: runtime/src/text_xlate.c +
runtime/include/text_xlate.h (peers of fntrace.* and bios_hle.*).
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).- RmlUi 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 (F:\Projects\psxrecomp\_wt-tsumu\psxrecomp\):
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).
Resisters — composed per-glyph, no static source (NOT shipped)
- In-game HUD
ステージ:X–Y/バッテリー:N. Drawn per-glyph as sprites. Proof it is composed, not stored: a full 2 MB guest-RAM scan (live, in a gameplay stage with the HUD on-screen) finds the katakanaステージ(58 83 65 83 5b 81 57 83) andバッテリー(6f 83 62 83 65 83 8a 83 5b 81) only at the static message-text addresses (0x800709xx…), every one followed by a grammatical particle (を/が/の/は) — i.e. inside sentences, never as a standalone HUD label. There is noステージ:template (no occurrence is followed by:=46 81), and no HUD string buffer in RAM. So the label is emitted from hardcoded font-tile indices (MOJI.BIN glyphs), not from any SJIS/string the source-patch could rewrite. - Stage-clear
CLEAR!!banner. No static source of ANY form:クリア(4e 83 8a 83 41 83) appears only message-embedded ("…をクリアしていく"…); there is no standaloneクリア, no fullwidthCLEAR(63 82 6b 82 65 82 61 82 72 82), and no ASCIICLEAR/CLEAR!!anywhere in the EXE. It is also not a baked sprite: every graphic TIM on the disc was extracted and rendered (see asset map) — the only pre-rendered text is theTSUMU lighttitle logo (GRAPH1/4.BIN, already English). SoCLEAR!!is composed per-glyph at runtime, same class as the HUD.
Both resisters need the per-glyph sprite-draw interception + glyph-index
remap hook flagged in Appendix Y — a separate, larger effort: (1) crack the
MOJI.BIN font layout to map katakana → tile index, (2) pin the composition/draw
routine (Ghidra auto-analysis yields 0 functions on the raw dump; use the
generated C or a live gameplay draw-trace), (3) intercept the sprite draw and
remap indices to Latin only for the HUD/banner draws (a blanket VRAM font
swap would corrupt katakana in menus/messages, and ステージ→STAGE is a
4-vs-5 glyph mismatch). No fragile partial was shipped for these — doing so
without the scoped intercept would corrupt other katakana.
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.