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 its pixel widths - not byte counts - decide whether a translated line fits its box. 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 draw controller-button and UI icons, or print a script counter.
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). A text ink is therefore a CLUT pick (DAT_8007B454), and each ink’s CLUT carries its own shadow in entry 14 beside the fill in entry 15, so the drop shadow changes with the ink. Read off a retail frame’s VRAM:
| Ink | Fill (entry 15, 5-bit RGB) | Shadow (entry 14) | Where it is used |
|---|---|---|---|
| 0 | (16, 16, 16) | (4, 4, 4) | a list’s unfocused rows |
| 1 | (13, 13, 28) | (3, 3, 5) | |
| 2 | (28, 4, 0) | (8, 4, 4) | the downed / 0-HP tier |
| 3 | (28, 13, 28) | (8, 2, 8) | the status-lock tier |
| 4 | (13, 27, 13) | (3, 8, 3) | skill passives, MP Used |
| 5 | (8, 27, 27) | (4, 8, 8) | teal labels |
| 6 | (28, 21, 0) | (8, 8, 4) | gold values, the caution tier |
| 7 | (25, 25, 25) | (4, 4, 4) | dialogue, menu text, every battle caption |
| 8 | (25, 25, 25) | (14, 14, 14) | |
| 9 | (27, 11, 0) | (8, 6, 3) | the danger tier |
The engine’s stencil atlas whitewashes the fill and marks each shadow texel; the overlay tint (legaia_render_kernels::ui_tint) draws a marked texel under a known ink as that ink’s own entry 14, and under any other tint as an opaque grey.
Width table
256 bytes indexed by character byte. The advance for c is widths[c] + pad + 1: +1 is the fixed inter-character gap, and pad is a per-call override that resets to zero after each render call. Menus and battle draw with pad = 0; the field dialog pager sets it to 1 before every row, so dialogue runs one pixel wider per glyph than the same text in a menu.
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.
A string whose first byte is below 0x20 draws nothing: the renderer tests it before the expander runs and returns 0. That is what a 0x1F dialogue lead hits, and why the segment drawer steps past the lead before drawing.
| 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 like any glyph |
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 |
Before drawing, a preprocessor expands the string into a line buffer: authoring-time ^X becomes 0xCE (X - 0x2D), and the substitution tokens 0xC1..=0xC5 / 0xC7 are replaced by the text they name (a party member's name, an item, spell or arts name). The renderer only ever sees the expanded string.
Escape table
38 four-byte entries at SCUS 0x80074050, indices 0x00..=0x25; an index past 0x25 reads whatever follows the table.
A non-zero first field is not a string: it is a UI-icon sprite id into the 12-byte sprite records at 0x800732A4, drawn untinted from the system-UI sheet at VRAM (896, 256) at y + y_offset. A zero first field prints a number instead: the signed halfword at script-counter slot y_offset (the field VM's counter table, written by 4C CA/CB/CC), at 8 px per digit - but only for y_offset < 4; a larger operand draws nothing and advances nothing. Leading zeros are suppressed and the units digit always draws, so any value <= 0 prints a single 0.
| Index | Sprite id | Size | Advance | y_offset | Draws |
|---|---|---|---|---|---|
0x00..=0x07 | 55..62 | 16×16 | 16 | -2 | Controller buttons: X, Circle, Square, Triangle, R1, R2, L1, L2 |
0x08 | 98 | 12×12 | 12 | +2 | Gold badge |
0x09 / 0x0A | 132 / 133 | 12×12 | 12 | 0 | One-target / all-targets badges |
0x0B..=0x0E | 0 | - | 8 per digit | 0..3 | Script counter 0..3 as a number |
0x0F | 137 | 38×12 | 38 | 0 | "Ra-Seru" in kana, a text sprite |
0x10..=0x13 | 36, 34, 35, 37 | 12×12 | 12 | 0 | Equip-slot icons: arms, head, body, legs |
0x14..=0x1A | 139..145 | 20×12 | 20 | 0 | Element plates: fire, thunder, wind, water, earth, light, dark |
0x1B | 146 | 20×12 | 20 | 0 | Monster plate |
0x1C | 147 | 12×12 | 20 | 0 | The fire plate's left edge (a 12 px window at U 0) |
0x1D..=0x23 | 148..154 | 28×12 | 28 | 0 | Winged element icons, same order as 0x14..=0x1A |
0x24 / 0x25 | 155 / 156 | 28×12 | 28 | 0 | Wider reads of the monster and fire plates |
On the disc only 0x0B and 0x0E of the number escapes occur, in the koin1 and koin3 scene dialogue. The first list of these symbols was contributed by Henrique Stanke Scandelari (Stann0x) for a Brazilian Portuguese translation; translation packs can write them as aliases such as {btn:x} or {icon:fire}.
Line width: nothing wraps
No retail text surface wraps. Every line break is an authored 0x7C or a new dialog line; an over-long line is drawn in full, over the box frame and off it. The renderer has no clip and no width test, and none of its callers measure a line first. Its only bound is an optional cap on the number of units drawn (glyphs, numeric escapes and line breaks), which is the typewriter: the pager passes its reveal counter for the row being typed and no cap anywhere else. That is why a translation has to fit the box by pixels, not only its byte slot.
| Surface | Pad | Room | Notes |
|---|---|---|---|
| Field dialog box row | 1 | 244 px | Three rows per page; the page-advance hand covers the last two rows from 228 px in |
| Picker option label | 1 | 228 px | Drawn 16 px in from the box edge |
| Player-chosen name | 0 | 56 px | Bounded by the name-entry screen |
| Item list / shop buy list / status magic | 0 | 104 px | Stops at the next column (count, price, level) |
| Item info window | 0 | 124 px | |
| Item / spell description line | 0 | 144 px | Drawn in full from the info window's left edge; FUN_8003CD00 keeps the widest line but never wraps, and no retail description starts below 0x20, so every description is one draw. A spell description has two rows above "MP Used" |
| Status moves page | 0 | 114 px | Stops at the AP field |
| Battle message banner | 0 | 288 px |
Typewriter pacing
Dialogue types in. The pager keeps a reveal counter per row, and the draw shows only that many units. A unit is a glyph, a 0x7C or a whole 0xCE escape; a colour change costs nothing, and a substitution costs the length of what it splices in. The counter gains one unit per vsync. A row shorter than 0x22 units then holds for a moment before the next starts, so short lines do not flash past; the last row of a page never holds. Confirm while a row types completes the page. Rows scroll inside the box rather than clearing per page - see MES dialog.
Rendering pipeline
- ExpandPreprocessor:
^X→0xCEescapes, substitution tokens → their text. - CountGlyph count for the typewriter reveal - a unit count, not a pixel width.
- Draw lineWalk bytes, dispatch escapes, emit one GP0
0x64sprite per glyph, apply the advance. - PagerThe field dialog pager draws each row at the box's x with pad 1, capped at the reveal counter.
0x20) takes the advance without emitting a sprite. The optional unit cap - the typewriter’s reveal counter - counts glyphs, line breaks and whole escapes; an ink change costs nothing.A separate pixel measurer (the same expansion, then the widest 0x7C-separated line) exists for the few surfaces that size themselves to their text, such as the battle name plaque.
Per-glyph GPU packet
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.
Accented Latin cells
The sheet indexes every byte 0x20..=0xFF, so the cells above 0x7E are ordinary glyph cells. Whether one draws needs two things at once: ink in the cell and a non-zero width.
- USA page. 32 of the 128 high cells carry accented Latin letters, mostly at their IBM CP437 positions (
0x80..=0x90,0x95..=0x9A,œat0x9C, a run one cell below CP437 at0xA0..=0xAC). The width table gives 26 of them a zero advance, so they draw and the next letter overprints them. Retail English text barely touches them. - PAL pages (French / German / Italian). A different, smaller drawing of every glyph, with CP437 accents plus accented capitals and
ßmostly at CP850 positions. The French preprocessor alone foldsoe/OEto the ligature cells at draw time. The three PAL pages differ from each other only in cell0x24, the level mark a monster variant’s$Nsuffix draws: “Lv” on the USA, German and Italian pages, “Nv” on the French one. - Byte range limits.
0xC0..=0xCFare two-byte dialog opcodes, never glyphs, so those cells are unreachable from text.
The translation tools share one byte-to-character layout, legaia_font::latin::LATIN_CELLS (CP437 for the lowercase block, CP850 for the rest, so text lifted from a PAL disc and text typed by hand land on the same cells). Three cells are the tools’ own choice, because CP850’s cell for ã / à sits in the opcode window and CP850 has no Œ:
ã is 0x9B, à is 0xD0, Œ is 0x9E. Their accent font rebuilds every layout cell on the user's own disc at patch time: it copies the base letter from the same page, paints a small diacritic mask in the page's two ink indices, and sets the width to the base letter's advance. It is two same-size in-place edits (the font TIM rows in PROT.DAT and the SCUS width table), and no glyph pixels are committed - the recipes run against the disc being patched.
A capital shifts down as far as its mark needs, the way the retail accented capitals do; ligatures join two base letters with a one-column overlap; ¿ and ¡ are ? and ! turned half a turn onto the descender line. Every mark pixel uses the page’s fill index with its shadow as the fill dilated one pixel right, down and diagonally - the rule almost every retail glyph follows. Because the result is a function of the input page alone, an import can tell a disc already carries the font by rebuilding it and comparing (legaia_font::accent_font::accent_font_state).
What does not fit: Latin letters with no cell in the layout (Polish ł, Czech č) fold to ASCII, and Cyrillic, Greek and CJK need more cells than the 224-cell sheet has - a second glyph bank plus a multi-byte encoding in the renderer.
How we know
The tables, packet shape and escape handling are read off the single-line renderer's disassembly; the disc TIM byte-matches the font page in live save-state VRAM, and line widths decoded from it match the drawn display lists.
Provenance: functions and dumps
| 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; sprite call and counter read; 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); substitution expansion | funcs/80036514.txt |
| Glyph count / draw-and-count | FUN_80036044, FUN_8003CC98 | Typewriter unit count (not a wrap); one string drawn and counted | funcs/8003cc98.txt |
| Pixel measurer | FUN_80035F04 | Widest 0x7C-separated line of the expanded string | per-function dump |
| Field dialog pager | FUN_801D84D0 | Pad 1 per row, 244 px box, three rows, reveal counter and short-row hold | field overlay dump |
| Icon sprites / numbers | FUN_8002C488, FUN_80034B78 | Sprite records at 0x800732A4; integer formatting | per-function dumps |
| Disc carrier | PROT.DAT + 0x7F40 | PROT.DAT-wide TIM scan for framebuffer (896, 0); byte-verified against the save-state VRAM page | - |
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 touch / button-press interaction rather than an opcode - 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 font TIM sits in the unindexed gap of
PROT.DATahead of the first entry, with the other boot-resident UI pages; the routine that uploads that gap to VRAM is not named. - The expansion buffer's end. The expander
FUN_80036514writes to0x800740ECwith no length test, so the buffer is as long as the longest expansion a drawn string produces; it can run at most0x206bytes to the next initialised data, an upper bound by inference.
Both are tracked on open RE threads. The text-actor walker FUN_80031D00 has no escape ops of its own - it walks the drawable list and reads no glyph stream.
Source of record: docs/formats/dialog-font.md.