Translation / language packs
legaia-patcher translate turns a user-supplied retail disc's user-facing text into an editable YAML language pack, and applies a filled pack back onto a disc copy as a same-size in-place patch. Built for community translations: export, edit the translation: fields with any text editor or script, import. Nothing is redistributed - the pack is generated from your own disc, and the shareable artifact is a PPF patch or (better) a filled pack.
Two pack shapes
A pack comes in two shapes that share one schema:
| Shape | source: | Holds | Lives |
|---|---|---|---|
| working | yes | the disc's own text (for the translator to read) | your machine only - never redistributed |
| distributable | no | only the new translated text, keyed by disc coordinates | shareable / committable |
translate strip turns a filled working pack into a distributable one: it drops every source: and context: field and every unfilled entry, leaving a pure key → translation lookup table plus the byte-budget hint. The key (scus:str:0x<va>, scus:party:<n>, man:<prot>:0x<off>, raw:<prot>:0x<off>) is a disc coordinate, not text, so a distributable pack carries none of the original script. That is the only shape it is safe to commit or publish - the shipped packs at site/lang/ are exactly this, and a disc-free test fails the build if any tracked pack still carries a source: field.
A distributable pack has no source to self-check against, so its budget is only a hint: import (and translate stats --input) re-measures every target on the disc being patched and rejects any entry that doesn't fit, or whose on-disc length disagrees with the hint. Same-size in place is enforced from the disc, never from the pack.
Workflow
# 1. Dump the source text into a working pack (once).
legaia-patcher translate export --input "Legend of Legaia (USA).bin" -o legaia_en.yaml
# 2. Make a skeleton for your language (fr de es it pl pt-BR ja ru zh ko ...).
# --resume seeds it from an already-published pack so you can keep editing a
# shipped translation without anyone redistributing the source.
legaia-patcher translate init --lang fr --from legaia_en.yaml \
--contributor "you" [--resume site/lang/fr.yaml] -o legaia_fr.yaml
# 3. Fill `translation:` fields (editor, script, AI pass - your choice).
# --chunk N splits the skeleton into N-entry files for a parallel bulk fill;
# recombine them with `translate merge`.
# 4. Check coverage + encodability/budget. Add --input to dry-run the pack
# against a real disc (the only way to validate a distributable pack).
legaia-patcher translate stats --pack legaia_fr.yaml [--input DISC.bin]
# 5. Publish: strip the source to make the distributable, committable pack.
legaia-patcher translate strip --pack legaia_fr.yaml -o site/lang/fr.yaml
# 6. Apply to a scratch copy (and/or emit a shareable PPF).
legaia-patcher translate import --input "Legend of Legaia (USA).bin" \
--pack legaia_fr.yaml --output legaia_fr.bin --patch legaia_fr.ppf
Entries with an empty translation: are left byte-identical on the disc, so a partially filled pack is always playable. Import is idempotent (re-running the same pack over a patched image applies nothing) and incremental (fill more entries, re-import onto a fresh copy). When a scene's dialog no longer recompresses into its MAN's on-disc footprint, import rolls back that scene's longest lines one at a time rather than dropping the whole scene. stats and import summarize skipped entries per reason; --verbose prints the full per-key list.
What may / may not be committed
- Distributable packs may be committed - they are new authored text plus a coordinate table; the shipped
site/lang/*.yamlpacks are tracked. - Working packs must not - they carry the game's script.
/translations/andlegaia_*.yamlstay gitignored. - No disc / exe / asset bytes, ever.
On the site
The in-browser ROM patcher offers the shipped packs directly (a language dropdown, default None), plus an import my own pack path and an export a starter pack from my disc button. It applies the language pack in two phases around the randomizer passes and validates a chosen pack against the user's disc before patching, then shows a per-section coverage report - applied / skipped counts plus a skip-reason breakdown (over budget, scene does not recompress, not on this disc, not encodable). Nothing is uploaded; the packs are static assets fetched from site/lang/.
Ordering: dialog before the randomizer, names after
Combined with the randomizer, a pack is applied in two phases:
- Dialog sections (
man:/raw:keys) go first. A dialog edit is same-size inside the decompressed MAN, keyed by a byte offset into it, whereas the door and starting-bag passes relocate records - moving every byte after the splice. Applied first, the translated text simply rides along with any later relocation. - SCUS name sections (
scus:keys) go last. The equipment-bonus-drop pass classifies gear by matching the disc's item names against curated English names; with the item table already translated its pool comes back empty and the pass aborts. Nothing in the randomizer relocates a SCUS string, so translating the name tables after every pass is always safe. The overlayui_menustrings ride with this phase for the same reason.
The randomizer otherwise reads structure - records, tables, item ids - never text, so translated strings never perturb it. A standalone translate import (no randomizer) applies everything in one pass.
YAML schema
Working pack (source-bearing - the shape a translator edits):
format: 'legaia-text-pack-v1'
language: 'fr'
game: 'Legend of Legaia (USA) SCUS-94254'
contributors: ['...']
notes: '...'
sections:
items: # one list per section, fixed order
- key: 'scus:str:0x80012260' # stable provenance key
context: 'item 0x79' # human context, not machine-read
source: 'Healing Berry' # US text, markup form
translation: '' # fill me
budget: 13 # max encoded bytes for the translation
Distributable pack (source-less - the shape that is committed / shipped): the same document with source: and context: removed and only filled entries kept.
Sections and their patch mechanisms:
| Section | Contents | Key shape | Mechanism |
|---|---|---|---|
items | item names (MES {c2:xx}/{c4:xx} substitutions) | scus:str:0x<va> | overwrite the NUL-terminated string in SCUS_942.54 in place, re-terminate |
item_types | shared item "type" strings | scus:str:0x<va> | same |
spells | spell/magic names ({c3:xx}) | scus:str:0x<va> | same |
arts | Tactical Arts names ({c5:xx}) | scus:str:0x<va> | same |
accessory_passives | Goods-menu passive names + descriptions | scus:str:0x<va> | same |
party_names | new-game roster names | scus:party:<n> | fixed 10-byte NUL-padded field (9-byte budget) |
scene_dialog | NPC/event dialog in the scene-bundle MANs | man:<prot>:0x<off> | edit the 0x1F-segment inside the LZS-decompressed MAN, recompress, must fit the original compressed footprint |
inline_text | dialog/narration in raw carriers | raw:<prot>:0x<off> | space-padded same-size overwrite directly in the PROT entry |
ui_menu | overlay-resident UI strings: pause-menu / options / shop / equip / status labels + in-battle system messages | ui:<prot>:0x<va> | overwrite the NUL-terminated string in the PROT overlay entry in place at file offset = va - base_va |
Strings pointer-shared by several table slots export once; interior pointers clamp the budget. The SCUS name pools are 4-byte aligned, so each string's budget also claims the 0..3 bytes of zero padding after its terminator - about 1.5 extra bytes on average, which is what lets the tightest name tables translate at all. The dialog sections are line-granular: the pager packs up to three consecutive segments into one box (MES), so consecutive entries in the pack are consecutive rows on screen - translate them as a group and keep each row inside its own budget.
Text markup + encoding
The glyph atlas is indexed by byte with 0x20..=0x7E as plain ASCII (dialog font), so markup is mostly literal text:
- printable ASCII maps to itself;
|is the in-game newline glyph (0x7C); {xx:yy}is a 2-byte token:{c1:00}= character-name substitution,{c2:79}= item-name substitution,{c3:..}magic,{c5:..}art,{cf:0n}color change,{ce:..}spacing/icon escape - keep these in the translation wherever the source has them;{xx}is a bare byte ({01}item-icon prefix, high glyph tiles); literal braces are written{7b}/{7d}.
encode (string → game bytes) is the exact inverse of the exporter's decode and reports per-character errors: anything outside printable ASCII is not in the retail glyph set. Common typographic lookalikes (smart quotes, en/em dashes, ellipsis, NBSP) are folded automatically. Accented Latin, Cyrillic and CJK are not encodable - the retail font simply has no such glyphs. A full non-Latin translation needs a font patch (new glyph tiles + width table), which this pipeline does not attempt; French/Italian/etc. must be written unaccented (Epee not Épée).
Budgets (the same-size constraint)
Every patch is same-size in place, so a translation's encoded length is capped by budget:
- SCUS strings: the original string's byte span (shorter is fine - the string is re-terminated).
- Dialog segments: same-size in place is the fast default - shorter translations are space-padded so the
0x1F ... 0x00framing never moves. When a line overflows its own span, the importer tries the generalized rewriter escape hatch: it grows every filled segment in that scene MAN to full length and relocates all crossing references, verified as the same program by re-walking both buffers. The budget then becomes the MAN's own footprint, not each string. See MAN relocation. - A whole scene's edits must additionally recompress into the MAN's original LZS footprint at the same LBA. Text compresses well, and the repack falls back to an optimal-parse LZS encoder when the fast greedy parse just misses the budget. The retail scene entries are sector-aligned with zero compressed slack, though, so a scene that still overflows falls back to same-size and rolls back its longest lines one at a time, each with a per-key diagnostic - shorten the reported lines and re-run.
translate stats checks all of this offline. On import each target is also verified against the pack's source; a mismatch (wrong disc revision, or a conflicting randomizer patch that moved the text) skips the entry with a per-key warning rather than writing blind.
The dialog-carrier gate (raw: writes)
The 0x1F <text> 0x00 dialog framing is short enough to occur by coincidence all over the disc's binary asset banks: sequenced music, VAB sample banks, the battle-character mesh/animation packs, monster archives, and every scene's first ANM slot all contain runs that read as a two- or three-letter "segment". Overwriting such a coincidental hit corrupts the asset with a same-size write that passes every framing/budget check yet freezes the game. Both export and import therefore gate every raw: write on a per-entry dialog-carrier check: a PROT entry is a real raw text carrier only if it carries enough multi-word prose segments. Across the retail disc the two populations separate with a wide margin, so the gate keeps every real carrier and refuses every binary bank. Import re-runs the check on the disc it is patching and skips a non-carrier entry with a per-key diagnostic. SCUS name-table (scus:) and scene-MAN (man:) writes are unaffected - those targets are structurally addressed, not scanned.
Coverage + limitations
Covered: the SCUS name tables (items, item types, spells, Tactical Arts, accessory passives, party names), the 0x1F-segment dialog corpus (scene bundles + raw event-script carriers) - NPC dialog, cutscene dialog and narration, picker labels, chest flavor text - and the overlay-resident UI menu strings (ui_menu): the pause-menu / options / shop / equip / status command labels and the in-battle system messages, which are NUL-terminated C strings in the menu and battle overlay data segments (see field menu). They are tight: a same-size translation of a short label can be shorter than English but rarely much longer - the in-battle Attack / Arts / Magic / Item command ring is drawn as UI-icon sprites (no text string to translate).
Not covered (out of scope for this pipeline): textures with baked-in text (title screen, save/load UI, boot logos, the command ring), and dialog lines the conservative segment scanner declines to export (they simply stay English). Junk entries the scanner does export (dev-debug strings) are harmless: leave them untranslated.
Textures with baked-in text
Some UI text is not a string at all - it is pixels in a TIM. Replacing one means authoring new glyph art at the exact same TIM footprint (identical width / height / bpp / CLUT layout) so the same-size in-place write applies. The tim crate can encode a PNG back to a TIM, so a byte-identical-footprint swap is mechanically possible; the blocker is art authoring (and, for logos, rights), not the pipeline. The text-bearing textures on the retail disc: the title wordmark bands (PRESS START BUTTON is a candidate for a band swap; copyright bands must stay), the title menu NEW GAME / CONTINUE labels (runtime-drawn from the glyph atlas, but living in the title overlay the pipeline does not address by coordinate), the save/load UI pills and panel (see save screen), config-screen chrome TIMs, the in-battle command-ring icon sprites, the boot / publisher logos (do not alter - trademark art), and the opening prologue's baked caption.
A font patch (new glyph tiles + width table in the menu glyph atlas, see boot and dialog font) is the separate, larger effort that would lift the printable-ASCII-only limitation across all text. The official PAL discs already carry such an atlas: see tooling/pal-localizations.md for the CP437-aligned accent byte→glyph map, how the official French/German/Italian text aligns id-/order-for-order to the USA disc (translate diff-disc), how to lift it onto USA coordinates (translate lift-official), and the per-string vs per-MAN fit rate (translate fit-report).
AI example packs
Machine-translated packs built with this pipeline are examples / starting points only - the tooling exists so communities can produce and iterate on real human translations. Prefer community packs; treat any AI-filled pack as a draft to correct, and credit editors in contributors.