Two pack shapes

A pack comes in two shapes that share one schema:

Shapesource:HoldsLives
workingyesthe disc's own text (for the translator to read)your machine only - never redistributed
distributablenoonly the new translated text, keyed by disc coordinatesshareable / 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/*.yaml packs are tracked.
  • Working packs must not - they carry the game's script. /translations/ and legaia_*.yaml stay 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 overlay ui_menu strings 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:

SectionContentsKey shapeMechanism
itemsitem 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_typesshared item "type" stringsscus:str:0x<va>same
spellsspell/magic names ({c3:xx})scus:str:0x<va>same
artsTactical Arts names ({c5:xx})scus:str:0x<va>same
accessory_passivesGoods-menu passive names + descriptionsscus:str:0x<va>same
party_namesnew-game roster namesscus:party:<n>fixed 10-byte NUL-padded field (9-byte budget)
scene_dialogNPC/event dialog in the scene-bundle MANsman:<prot>:0x<off>edit the 0x1F-segment inside the LZS-decompressed MAN, recompress, must fit the original compressed footprint
inline_textdialog/narration in raw carriersraw:<prot>:0x<off>space-padded same-size overwrite directly in the PROT entry
ui_menuoverlay-resident UI strings: pause-menu / options / shop / equip / status labels + in-battle system messagesui:<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 ... 0x00 framing 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.

See also