At a glance

In the game
The dialog box, pause menu, shop and battle text - every proportional line of Latin text
Where
Glyph sheet: a plain TIM at PROT.DAT file offset 0x7F40 (the unindexed head gap before entry 0), uploaded to VRAM (896, 0). Width table at SCUS 0x80073F1C; escape table at SCUS 0x80074050
Size
256×256 4bpp pixels = 16 × 14 cells of 16×16 (224 glyphs); 256-byte width table; 38 × 4-byte escape entries
Parser
crates/font - Font::from_disc_tim_and_scus, layout API, CLI font-extract
Confidence
Confirmed - tables and packet shape traced in the single-line renderer; the disc TIM byte-matches the save-state VRAM extraction (how we know)
Used by
MES dialog, field menu, save screen, translation packs

Three pieces of data

  • The glyph sheet - 4bpp tiles in VRAM at (896, 0)..(960, 256), one 16×16 cell per character byte.
  • The width table - 256 bytes in the executable, one advance per character byte.
  • The escape table - 38 four-byte entries in the executable, selected by the byte after a 0xCE escape; they splice in names, icons and numbers.
character byte c 0x20..0xFF cell in VRAM sheetU = (c & 0xF) × 16 V = (c & 0xF0) - 0x20 sprite 14 × 15 pxGP0 0x64 packet CLUT (96 + 16i, 510) advance X bywidths[c] + pad + 1 widths at 0x80073F1C escapes: 0x7C newline (+14 px), 0xCE + index = escape table, 0xCF + n = palette n, 0x00 = end
One sprite per glyph; the texture page is set once per string by a separate draw-mode primitive, not per glyph.

Glyph sheet

The sheet is a 4bpp TIM (4 bits per pixel, 16 colours), 64 halfwords wide × 256 tall = 256×256 pixels, framebuffer destination (896, 0). Cell c for character byte c in 0x20..=0xFF:

U = (c & 0x0F) * 16
V = (c & 0xF0) - 0x20

The drawn region in each cell is 14 × 15 pixels; the remaining 2 × 1 pixels are guard space. Bytes 0x00..=0x1F are control codes and have no glyph. The TIM uses three palette indices: 0 transparent, 14 the (32,32,32) drop shadow, 15 the glyph fill.

The TIM sits in the unindexed head gap of PROT.DAT - after the table of contents, before the first numbered entry - which is why a per-entry walk never sees it. The font is therefore decodable straight from the disc, no save state required.

Palettes

Sixteen 16-colour palettes (CLUTs) sit end-to-end on VRAM row 510 from x = 96, one every 16 pixels. The colour-change escape 0xCF n sets the palette index; the CLUT word in each glyph packet is index + 0x7F86, and 0x7F86 decodes as (96, 510). Palette 0 is the white-on-transparent dialog palette; the others are the coloured variants for status text and system prompts.

Width table

256 bytes indexed by character byte. The advance for c is widths[c] + pad + 1, where pad is a per-string override (normally zero, reset after each render call) and +1 is the fixed inter-character gap. Every byte reaches the advance except the four control bytes; a space takes its advance without emitting a sprite. A line's pixel width is exactly the sum of its per-byte advances, with no per-line term - the save screen's memory-card warning line measures 164 px.

ByteCharWidthByteCharWidth
0x20space40x4DM8
0x21!40x57W9
0x41A70x69i3
0x49I30x6Dm8

Entries 0x00..=0x1F overlap three short actor-name strings and are not advances. The full table is exported as extracted/font/dialog_font_widths.csv.

Control bytes and escapes

ByteOperandMeaning
0x00-String terminator
0x20-Space: no glyph, advance by widths[0x20] + 1
0x7C-Newline: Y += 14 px, X back to line start
0xCEu8 indexEscape: index into the 38-entry table below
0xCFu8 paletteColour change: palette index 0..15
0x21..=0xFF-Glyph: one sprite

Escape table

Each entry is { i16 string_id; u8 advance_px; i8 y_offset }. A non-zero string_id draws a looked-up string at y + y_offset; a zero string_id formats one of four runtime numbers (HP / MP / gold / exp), with y_offset as the variable index. Authoring-time text writes escapes as ^X; a preprocessor turns them into 0xCE (X - 0x2D).

Indexstring_idadvancey_offsetMeaning
0x00..=0x0755..6216-2Icon strings (button glyphs / currency icon)
0x089812+2String 98
0x09..=0x0A132, 133120Strings 132 / 133
0x0B..=0x0E0320..3Number substitution: variable slot in y_offset
0x0F137380String 137 (widest single escape)
0x10..=0x1336, 34, 35, 37120Active actor name
0x14..=0x1C139..147200Strings 139..147
0x1D..=0x25148..156280Strings 148..156

Rendering pipeline

1
Preprocess
Expand authoring-time ^X escapes into the runtime 0xCE stream.
source preprocessor
2
Word wrap
Break lines to the dialog box width using the width table.
wrap pre-pass
3
Render line
Walk bytes, dispatch escapes, emit one GP0 0x64 sprite per glyph, apply the advance.
single-line renderer
4
Multi-line
Wrap + render, used by the field dialogue chain.
multi-line wrapper
Per-glyph GPU packet
[0x04 00 00 00]              // OT-list terminator
[0x64 80 80 80]              // cmd 0x64 + RGB shading
[i16 X][i16 Y]               // top-left in screen coords
[u8 U][u8 V][u16 CLUT]       // U,V within texture page; CLUT word
[u16 W=14][u16 H=15]         // sprite size in pixels

The texture page is set earlier by a separate GP0 0xE1 draw-mode primitive. A second, HUD-only text path (the text-actor tick) uses a width-bucketed layout for status numbers (column stride 8 px, height 12 px) and recognises its own 0xCC..=0xCF ops; it is outside this page's scope.

Adding accented or non-Latin glyphs

The translation pipeline writes only bytes the retail font can draw - printable ASCII 0x20..=0x7E - so the community language packs are ASCII-folded (e for é, ss for ß). Real accented glyphs are a font patch, needing three things:

  1. A free byte slot - not a two-byte opcode (0x5E, 0xC0..=0xCF, 0xFF), not a control byte, not used by any string. Roughly a hundred slots in 0x80..=0xFF are free.
  2. A glyph bitmap - a 14×15 drawing in the cell: a same-size in-place edit of the head-gap TIM through the TIM encoder.
  3. A width entry - a one-byte poke into the executable's width table.

Accented Latin (~35 code points) and Cyrillic (~66) both fit. CJK does not: thousands of glyphs exceed the 224-cell sheet and the byte index space, and would need a second glyph bank plus a multi-byte encoding in the renderer.

How we know

FunctionAddressWhat it provesDump
Single-line rendererFUN_80036888Width table at DAT_80073F1C + c; U/V formula; escape-table layout at 0x80074050; CLUT base 0x7F86; 0xCF palette case; advance tail at 0x80036B9Cfuncs/80036888.txt
Text-actor rendererFUN_8003C11CSame GP0 0x64 packet shapefuncs/8003c11c.txt
PreprocessorFUN_80036514^X0xCE (X - 0x2D)funcs/80036514.txt
Word wrap / multi-lineFUN_80036044, FUN_8003CC98Wrap pre-pass and the field dialogue chainfuncs/8003cc98.txt
Number / string escapesFUN_80034B78, FUN_8002C488Integer formatting; string lookup by idper-function dumps
Disc carrierPROT.DAT + 0x7F40PROT.DAT-wide TIM scan for framebuffer (896, 0); byte-verified against the save-state VRAM atlasfont::disc_font_matches_extracted_artifacts
Line metricsave screen warning line164 px from a disc-decoded fontengine-shell/tests/dialog_font_metrics.rs

Field dialogue has no dedicated opcode: an NPC's text is its inline interaction-script MES, shown by the per-frame actor-dialog state machine and the dialog pager, triggered by the field-interact op - see field script VM.

Deep dive

Extraction tools and outputs

extracted/font/ (gitignored - Sony pixel data) is produced by font-extract, which reads the executable for the tables and a mednafen save state's &GPURAM[0][0] section for the live VRAM bytes:

FileWhat it is
dialog_font_sheet.pngThe full 256×256 sheet, expanded with palette 0
dialog_font_atlas.pngPer-glyph atlas, 14×15 cells in 16 columns × 14 rows
dialog_font_metadata.jsonWidth table + escape table + VRAM source rect
dialog_font_widths.csvThe width table as CSV
dialog_font_vram_4bpp.binRaw 32 KB 4bpp VRAM bytes
cargo run -p legaia-font --bin font-extract -- \
    --scus extracted/SCUS_942.54 \
    --save "$HOME/.mednafen/mcs/Legend of Legaia (USA).<hash>.mcN" \
    --out extracted/font
Open questions
  • The upload routine. The boot-time routine that issues the GPU LoadImage of the head-gap TIM to (896, 0) is not named; it sits between the PROT TOC loader and the first dialog open.
  • The string pool. Escape entries with non-zero string_id index a pool whose entries 34..37 match the SCUS actor-name strings; the rest of the pool is undecoded.
History: the "carrier never found" search

A per-entry search of every PROT entry (direct slice, glyph-row signature, both again over LZS-decompressed bytes) returned zero hits - correctly, because the font lives in the gap between the TOC and the first entry. The flat sector scan behind the TIM catalog is what found it.

Source of record: docs/formats/dialog-font.md.

See also