Translation / language packs
Legend of Legaia shipped in English, French, German, Italian and Japanese. A language pack lets a community translate the game's text - item and spell names, menus, dialogue - into any Latin-script language and play it on the retail disc. The pack is a plain YAML file you edit in any text editor; applying it never changes the size of anything on the disc.
At a glance
- What you get
- A translated game on your own disc, applied in the browser patcher or with
legaia-patcher translate. Composes with every randomizer option. - Covered
- Name tables (items, spells, arts, accessory passives, party names), the whole NPC / cutscene / narration dialogue corpus, pause-menu / shop / battle system labels.
- Not covered
- Text baked into textures; accented and non-Latin scripts (the retail font has no glyphs for them).
- The one rule
- Every edit is same-size in place. Each line has a byte budget; a line over budget is reported and left English.
- Sharing
- A pack is safe to publish once its
source:text is stripped - what remains is your text keyed by disc coordinates. - Code
crates/patcher/src/translation/; writes go throughDiscPatcher(EDC/ECC re-encoded, no LBA moves).- Own a PAL disc?
- Its official French / German / Italian text can be lifted onto the USA disc from your own two discs.
Workflow
| Step | Command | Does |
|---|---|---|
| 1 | translate export --input DISC.bin -o legaia_en.yaml | Dumps the disc's own text into a working pack (once). |
| 2 | translate init --lang fr --from legaia_en.yaml --contributor you -o legaia_fr.yaml | Skeleton for your language; --resume site/lang/fr.yaml seeds it from a published pack. |
| 3 | translate merge --base legaia_fr.yaml --pack part1.yaml --pack part2.yaml -o legaia_fr.yaml | Fill the translation: fields with any editor; init --chunk N splits the work, merge recombines it. |
| 4 | translate stats --pack legaia_fr.yaml [--input DISC.bin] | Coverage, encodability and budgets; --input dry-runs against a real disc. |
| 5 | translate strip --pack legaia_fr.yaml -o site/lang/fr.yaml | Drops the source text - the result is the shareable pack. |
| 6 | translate import --input DISC.bin --pack legaia_fr.yaml --output out.bin --patch out.ppf | Applies to a scratch copy and/or emits a PPF patch. |
Every disc argument takes a .bin or a .cue. A partially filled pack is always playable: empty entries stay byte-identical. Import is idempotent and incremental. stats and import summarise skipped entries per reason; --verbose lists every key.
- Commit distributable packs (
site/lang/*.yaml) - new authored text plus a coordinate table. A disc-free test fails the build if a tracked pack still carriessource:. - Never commit working packs - they carry the game's script.
/translations/andlegaia_*.yamlare gitignored.
The in-browser ROM patcher does the same from a language dropdown (default None): shipped packs, your own pack, a starter export, or a PAL lift - validated against your disc, with a per-section coverage report. Nothing is uploaded.
Two pack shapes
| Shape | source: | Holds | Lives |
|---|---|---|---|
| working | yes | the disc's own text, for the translator to read | your machine only |
| distributable | no | only the new text, keyed by disc coordinates | shareable / committable |
strip drops every source: and context: field and every unfilled entry. The key is a disc coordinate, not text, so a distributable pack carries none of the original script. Its budget is only a hint: import re-measures every target on the disc it patches and rejects anything that does not fit. Same-size is enforced from the disc, never from the pack.
What a pack contains
format: 'legaia-text-pack-v1'
language: 'fr'
game: 'Legend of Legaia (USA) SCUS-94254'
contributors: ['...']
sections:
items: # one list per section, fixed order
- key: 'scus:str:0x80012260' # disc coordinate
context: 'item 0x79' # human hint, not machine-read
source: 'Healing Berry' # US text (working pack only)
translation: '' # fill me
budget: 13 # max encoded bytes
| Section | Contents | Key | Patch mechanism |
|---|---|---|---|
items | item names | scus:str:0x<va> | overwrite the string in SCUS_942.54 in place, re-terminate |
item_types | shared item "type" strings | scus:str:… | same |
spells | spell / magic names | scus:str:… | same |
arts | Tactical Arts names | scus:str:… | same |
accessory_passives | Goods-menu passive names + descriptions | scus:str:… | same |
party_names | new-game roster names | scus:party:<n> | fixed 10-byte field, 9-byte budget |
scene_dialog | NPC / event dialogue in scene MANs | man:<prot>:0x<off> | edit inside the decompressed MAN, recompress into the original footprint |
inline_text | dialogue / narration in raw carriers | raw:<prot>:0x<off> | space-padded same-size overwrite in the PROT entry |
ui_menu | pause-menu / shop / status labels, battle messages | ui:<prot>:0x<va> | overwrite in the overlay entry at va - base_va |
Dialogue sections are line-granular: the pager packs up to three consecutive segments into one box (MES), so consecutive pack entries are consecutive rows on screen. Translate them as a group, each row inside its own budget.
Details: shared strings, alignment padding, duplicates
- Strings pointer-shared by several table slots export once; the
contextlists the referencing ids, and interior pointers clamp the budget. - The SCUS name pools are 4-byte aligned, so each budget also claims the 0..3 zero bytes after the terminator (verified zero, never past another string) - about 1.5 extra bytes on average, which is what lets the tightest tables translate at all.
- Duplicate PROT TOC entries over the same disc bytes are deduplicated by LBA.
ui_menupools are pinned by VA windows in the menu (PROT 0899) and battle (PROT 0898) overlays; see field menu. Short writes zero-fill the old span.
Text markup + encoding
Game text is plain ASCII plus a few control tokens (dialog font). Keep every token where the source has it.
| Markup | Meaning |
|---|---|
A-Z a-z 0-9 … | printable ASCII maps to itself |
| | in-game newline |
{c1:00} | character-name substitution |
{c2:79} {c3:..} {c5:..} | item / magic / art name substitution |
{cf:0n} {ce:..} | colour change; spacing / icon escape |
{xx} | bare byte ({01} item-icon prefix); literal braces are {7b} / {7d} |
Encoding reports per-character errors. Smart quotes, dashes, ellipsis and NBSP fold automatically. Accented Latin, Cyrillic and CJK are not encodable - the retail font has no such glyphs, so French or Italian must be written unaccented (Epee, not Épée). Lifting that limit needs a font patch, which is a separate effort.
Budgets: the same-size rule
| Target | Budget |
|---|---|
| SCUS / overlay strings | the original string's span; shorter is fine |
| Dialogue line | its own span, space-padded when shorter; an overflowing line triggers the MAN rewriter, which grows the line and relocates everything after it (MAN relocation) |
| Whole scene | the rewritten MAN must recompress into its original LZS footprint - retail scenes have zero compressed slack |
When a scene still overflows, import rolls back its longest lines one at a time with a per-key diagnostic - shorten those and re-run. The CLI's import --allow-relayout instead grows the scene by whole sectors (an --output image only, never a PPF). Each target is also checked against the pack's source; a mismatch skips the entry rather than writing blind.
Details: the dialogue-carrier gate on raw: writes
The 0x1F <text> 0x00 dialogue framing is short enough to occur by coincidence in binary banks - music, sample banks, mesh packs, monster archives, animation slots. A same-size write into one passes every budget check and freezes the game. Both export and import therefore accept a raw: target only when its PROT entry carries at least MIN_CARRIER_PROSE multi-word segments; real carriers and binary banks separate by a wide margin. scus:, man: and ui: targets are structurally addressed and unaffected.
Details: ordering next to the randomizer
Combined with a randomizer run, a pack is applied in two phases. Dialogue (man: / raw:) goes first, because the door, location-rename and starting-bag passes relocate MAN records, and text applied earlier rides along. Names (scus: / ui:) go last, because the equipment-bonus-drop pass classifies gear by matching English item names; nothing in the randomizer relocates a SCUS or overlay string. The randomizer otherwise reads ids, never text.
Coverage + limits
- Covered: every SCUS name table; NPC, cutscene and narration dialogue, picker labels, chest flavour text; overlay UI labels and battle system messages.
- Tight: the overlay label pools - a short label can be shorter than English but rarely longer.
- No string exists: the in-battle
Attack / Arts / Magic / Itemring is icon sprites. - Stays English: lines the conservative segment scanner declines to export. Junk entries it does export (dev-debug strings) are harmless - leave them blank.
Official PAL translations
Sony shipped French, German and Italian discs (SCES_019.44 / .45 / .46). They are structurally 1:1 with the USA disc, so their text lifts onto USA coordinates id-for-id and line-for-line - from your own two discs, in the browser or on the CLI. The official text is never bundled with this site.
| Command | Does |
|---|---|
translate lift-official --from PAL.bin --target USA.bin -o pack.yaml | Detects the language, re-keys every table and line into a filled working pack. --fold-accents (browser default) maps é→e, ß→ss. |
translate diff-disc | Alignment counts between two discs, text-free. |
translate fit-report | Per-string vs per-MAN fit, text-free. |
Not all of it fits, because of sector alignment: USA scenes are packed with zero slack, so a longer line overflows. Roughly a third of the dialogue lands in place; the rest is reported, not dropped quietly. import --allow-relayout closes the residual on the CLI.
Details: fit numbers and the recommended path
The PAL discs gave most scene entries one extra sector at mastering. In place, name tables land 36-60% and dialogue about half per string. Overlay ui_menu labels are not lifted - the PAL overlays place their pools elsewhere. Recommended path: lift → fit-report → import with relayout → font patch → strip to site/lang/<lang>.yaml. Full layout, accent map and fit numbers: pal-localizations.md.
Text baked into textures
Some UI text is pixels in a texture, not a string. Replacing one means new art at the exact same TIM footprint so the same-size write applies; the ROM patcher's texture replacement makes that possible. The blocker is art, not the pipeline.
Details: the text-bearing textures and their footprints
| Texture | Baked text | Notes |
|---|---|---|
| Title wordmark (PROT 0888) | logo, PRESS START BUTTON, copyright bands | bands are sub-rects of one 256×256 TIM; copyright bands must stay |
Title menu NEW GAME / CONTINUE | drawn from the glyph atlas | real text in a region the pipeline does not address by coordinate |
| Save / load UI (PROT 0899) | SLOT n pill, Load panel | small 4bpp TIMs; a same-footprint swap is feasible (save screen) |
| In-battle command ring | Attack / Arts / Magic / Item | icon sprites; a worded localisation needs new icon art |
| Boot / publisher logos (PROT 0895) | brand marks | do not alter - trademark art |
| Opening prologue caption | a baked caption under the crawl | the crawl narration itself is dialogue and is covered |
AI example packs
Machine-translated packs are starting points only. Prefer community packs, treat any AI-filled pack as a draft to correct, and credit editors in contributors.
How we know
| Component | Where | What it proves |
|---|---|---|
| Exporter / importer | crates/patcher/src/translation/{export,import}.rs | Same-size writes, source check, per-scene rollback, two-phase ordering. |
| Markup codec | translation/markup.rs | Byte-exact decode↔encode; per-character encodability errors. |
| Carrier gate | translation/segments.rs (is_dialog_carrier), test translation_carrier_gate_real.rs | Binary banks and real carriers separate on the retail disc. |
| Overlay UI pools | translation/ui.rs, test translation_ui_menu_real.rs | Menu / battle label windows at their disc coordinates. |
| MAN rewriter | legaia_asset::man_edit (apply_text_edits, text_edits_preserve_scripts) | A grown line leaves the scene script identical when re-walked. |
| PAL lift + fit | translation/{lift,diff,fit}.rs, tests translate_lift_official_real.rs, translate_relayout_import_real.rs | 1:1 alignment; the fit numbers above. |
| Shipped-pack hygiene | test translation_shipped_packs.rs | No tracked pack carries source:. |