Dialog font (proportional Latin) Confirmed
Every line an NPC speaks, every menu label and every "learned a new Art" banner is drawn one letter at a time from a single 256-cell glyph sheet. It is a proportional sans-serif - an "i" is 3 pixels wide, a "W" is 9 - and it is the reason a translation pack can only use characters the sheet already has. The sheet lives in video memory at runtime; the widths and escape codes live in the executable.
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.DATfile offset0x7F40(the unindexed head gap before entry 0), uploaded to VRAM(896, 0). Width table at SCUS0x80073F1C; escape table at SCUS0x80074050 - 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, CLIfont-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
0xCEescape; they splice in names, icons and numbers.
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.
| Byte | Char | Width | Byte | Char | Width |
|---|---|---|---|---|---|
0x20 | space | 4 | 0x4D | M | 8 |
0x21 | ! | 4 | 0x57 | W | 9 |
0x41 | A | 7 | 0x69 | i | 3 |
0x49 | I | 3 | 0x6D | m | 8 |
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
| Byte | Operand | Meaning |
|---|---|---|
0x00 | - | String terminator |
0x20 | - | Space: no glyph, advance by widths[0x20] + 1 |
0x7C | - | Newline: Y += 14 px, X back to line start |
0xCE | u8 index | Escape: index into the 38-entry table below |
0xCF | u8 palette | Colour 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).
| Index | string_id | advance | y_offset | Meaning |
|---|---|---|---|---|
0x00..=0x07 | 55..62 | 16 | -2 | Icon strings (button glyphs / currency icon) |
0x08 | 98 | 12 | +2 | String 98 |
0x09..=0x0A | 132, 133 | 12 | 0 | Strings 132 / 133 |
0x0B..=0x0E | 0 | 32 | 0..3 | Number substitution: variable slot in y_offset |
0x0F | 137 | 38 | 0 | String 137 (widest single escape) |
0x10..=0x13 | 36, 34, 35, 37 | 12 | 0 | Active actor name |
0x14..=0x1C | 139..147 | 20 | 0 | Strings 139..147 |
0x1D..=0x25 | 148..156 | 28 | 0 | Strings 148..156 |
Rendering pipeline
^X escapes into the runtime 0xCE stream.0x64 sprite per glyph, apply the advance.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:
- 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 in0x80..=0xFFare free. - 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.
- 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
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Single-line renderer | FUN_80036888 | Width table at DAT_80073F1C + c; U/V formula; escape-table layout at 0x80074050; CLUT base 0x7F86; 0xCF palette case; advance tail at 0x80036B9C | funcs/80036888.txt |
| Text-actor renderer | FUN_8003C11C | Same GP0 0x64 packet shape | funcs/8003c11c.txt |
| Preprocessor | FUN_80036514 | ^X → 0xCE (X - 0x2D) | funcs/80036514.txt |
| Word wrap / multi-line | FUN_80036044, FUN_8003CC98 | Wrap pre-pass and the field dialogue chain | funcs/8003cc98.txt |
| Number / string escapes | FUN_80034B78, FUN_8002C488 | Integer formatting; string lookup by id | per-function dumps |
| Disc carrier | PROT.DAT + 0x7F40 | PROT.DAT-wide TIM scan for framebuffer (896, 0); byte-verified against the save-state VRAM atlas | font::disc_font_matches_extracted_artifacts |
| Line metric | save screen warning line | 164 px from a disc-decoded font | engine-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:
| File | What it is |
|---|---|
dialog_font_sheet.png | The full 256×256 sheet, expanded with palette 0 |
dialog_font_atlas.png | Per-glyph atlas, 14×15 cells in 16 columns × 14 rows |
dialog_font_metadata.json | Width table + escape table + VRAM source rect |
dialog_font_widths.csv | The width table as CSV |
dialog_font_vram_4bpp.bin | Raw 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
LoadImageof 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_idindex 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.