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 draw controller-button and UI icons, or print a script counter.
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). 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:

InkFill (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.

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 like any glyph
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

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.

IndexSprite idSizeAdvancey_offsetDraws
0x00..=0x0755..6216×1616-2Controller buttons: X, Circle, Square, Triangle, R1, R2, L1, L2
0x089812×1212+2Gold badge
0x09 / 0x0A132 / 13312×12120One-target / all-targets badges
0x0B..=0x0E0-8 per digit0..3Script counter 0..3 as a number
0x0F13738×12380"Ra-Seru" in kana, a text sprite
0x10..=0x1336, 34, 35, 3712×12120Equip-slot icons: arms, head, body, legs
0x14..=0x1A139..14520×12200Element plates: fire, thunder, wind, water, earth, light, dark
0x1B14620×12200Monster plate
0x1C14712×12200The fire plate's left edge (a 12 px window at U 0)
0x1D..=0x23148..15428×12280Winged element icons, same order as 0x14..=0x1A
0x24 / 0x25155 / 15628×12280Wider 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.

SurfacePadRoomNotes
Field dialog box row1244 pxThree rows per page; the page-advance hand covers the last two rows from 228 px in
Picker option label1228 pxDrawn 16 px in from the box edge
Player-chosen name056 pxBounded by the name-entry screen
Item list / shop buy list / status magic0104 pxStops at the next column (count, price, level)
Item info window0124 px
Item / spell description line0144 pxDrawn 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 page0114 pxStops at the AP field
Battle message banner0288 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

  1. ExpandPreprocessor: ^X → 0xCE escapes, substitution tokens → their text.
  2. CountGlyph count for the typewriter reveal - a unit count, not a pixel width.
  3. Draw lineWalk bytes, dispatch escapes, emit one GP0 0x64 sprite per glyph, apply the advance.
  4. PagerThe field dialog pager draws each row at the box's x with pad 1, capped at the reveal counter.
Expanded string^X and names spliced first byte < 0x20draws nothing Next byteper-byte loop 0x00: end of line 0x7C: y += 14 0xCF n: set ink 0xCE n: icon / number glyph: one sprite Advance tailwidth[c] + pad + 1 Escape advanceentry +2 (8 px / digit) loop until 0x00
Every byte but the four control bytes reaches the common advance tail; a space (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, œ at 0x9C, a run one cell below CP437 at 0xA0..=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 folds oe / OE to the ligature cells at draw time. The three PAL pages differ from each other only in cell 0x24, the level mark a monster variant’s $N suffix draws: “Lv” on the USA, German and Italian pages, “Nv” on the French one.
  • Byte range limits. 0xC0..=0xCF are 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
FunctionAddressWhat it provesDump
Single-line rendererFUN_80036888Width 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 0x80036B9Cfuncs/80036888.txt
Text-actor rendererFUN_8003C11CSame GP0 0x64 packet shapefuncs/8003c11c.txt
PreprocessorFUN_80036514^X → 0xCE (X - 0x2D); substitution expansionfuncs/80036514.txt
Glyph count / draw-and-countFUN_80036044, FUN_8003CC98Typewriter unit count (not a wrap); one string drawn and countedfuncs/8003cc98.txt
Pixel measurerFUN_80035F04Widest 0x7C-separated line of the expanded stringper-function dump
Field dialog pagerFUN_801D84D0Pad 1 per row, 244 px box, three rows, reveal counter and short-row holdfield overlay dump
Icon sprites / numbersFUN_8002C488, FUN_80034B78Sprite records at 0x800732A4; integer formattingper-function dumps
Disc carrierPROT.DAT + 0x7F40PROT.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:

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 font TIM sits in the unindexed gap of PROT.DAT ahead 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_80036514 writes to 0x800740EC with no length test, so the buffer is as long as the longest expansion a drawn string produces; it can run at most 0x206 bytes 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.

See also