At a glance

Where
Inline in each scene's MAN (the scene's script-and-data bundle) as the actors' interaction scripts; in data\battle\efect.dat-style bundles; as type 4 in the asset-type dispatcher
Magic
Compact: first u32 0x00000404. Records: recurring 0x44 0x78 marker pairs, no fixed header
Size / stride
Glyph bytes are 1 byte; escapes 0xC0..0xCF are 2 bytes; a line is 0x1F <glyphs> 0x00
Parser
crates/mes (CLI mes); box packing legaia_mes::dialog_box; pickers legaia_mes::picker
Engine
engine_core::dialog + inline_dialogue; pager SM in engine-vm
Confidence
Confirmed - four interpreter functions statically linked in SCUS_942.54; pager and pickers traced in the dialog overlay and pinned on disc bytes
Used by
field/event VM, translation packs, NPC browser

Anatomy of a line

lead 0x1F glyphs 1 byte each = font tile substitution 0xC1 XX = hero's name more glyphs 0xCE XX = spacing end 0x00 control 0x24 / 0x25
A line is glyph indices with 2-byte escapes. Up to three lines pack into one box; the control byte after the box says what the button press does - next page, close, or open a choice menu.

Container variants

VariantDiscriminatorWhere
CompactFirst u16 = 0x0404Small blobs embedded inline in efect.dat and similar
RecordsFirst two bytes 0x44 0x78Larger dialog blobs; the form found in RAM in town overlays

Compact header

OffsetSizeFieldMeaning
+0x004magic0x00000404
+0x040x24-Unused padding
+0x284back_ptrRuntime pointer, patched on load
+0x2C4forward_ptrRuntime pointer
+0x304expanded_sizeByte count of the expanded blob
+0x344countNumber of messages
+0x3816metricsi16[8] per-line metrics
+0x4826-Further header fields (unknown)
+0x623 × Noffset tableu24 offsets from 0xC8; up to 86 entries
+0xC8-bytecodeMessage bodies

The Records variant has no fixed header: the parser finds record boundaries by scanning for 0x44 0x78 markers (at least four). Its per-record structure is Unknown.

Bytecode encoding

Most bytes are glyphs. Prefix bytes 0xC0..0xCF (plus two aliases) take a data byte and either name a wide glyph, insert spacing, or substitute a string from one of the game's name tables. All four interpreter functions share this classification; only the action per byte differs.

ByteStrideMeaning
0x00..0x1E1Line terminator; walkers stop here
0x1F..0x5D, 0x5F..0xBF, 0xD0..0xFE1Glyph (font tile index); 0x1F is also the line lead
0x5E XX2Alias, rewritten to 0xCE (XX - 0x2D)
0xC0 XX, 0xC6 XX, 0xC8..0xCD XX2Wide glyph, no substitution
0xC1 XX2Character name from the save record XX; 99 = current party leader
0xC2 XX, 0xC4 XX2Item name (item table); two consumer sites, one table
0xC3 XX2Magic name (spell table)
0xC5 XX2Tactical Art name (arts-name table), key (character = XX >> 6, art = XX & 0x3F)
0xC7 XX2Terrain / quest name (8-byte stride table)
0xCE XX2Spacing: XX is a horizontal offset
0xCF XX2Passthrough: XX renders alone
0xFF1Alias, rewritten to 0xCF

The substitution gate is the integer test (byte + 0x40) < 8, catching 0xC0..0xC7; 0xC0 and 0xC6 fall through to "no substitution" at 2-byte stride.

The four interpreters

  • Stride walker - 16 instructions returning the glyph count to the next terminator; the pager uses it for line lengths.
  • Width measurer - the same walk plus substitution dispatch, recursing into the substituted string.
  • Substitution expander - normalises the two aliases, then inlines each substituted name byte by byte into a working buffer.
  • Renderer - expands first, then emits glyphs into the text-actor buffer and forwards spacing ops to the cursor.

The dialog window pager

Above the byte-level interpreter sits the per-frame state machine that opens the speech box, scrolls lines in, waits for a button press, and turns the page. It lives in the dialog overlay: 26 states covering load / scroll / drain / wait / done, with per-line bytecode pointers in a 16-line buffer. Rust port: engine-core's dialog_window (rows, scroll, confirm) and dialog_pacing (the typewriter), driven by dialog::OwnedDialogPanel on the path both play hosts share.

Box packing

  • The window shows 3 rows; three box-open states each pin the limit to 3. It is a window height, not a page length.
  • Consecutive 0x1F lines are one page: a line right after a line's 0x00 means "same page, next row". A fourth line scrolls the window up a row with no button press; the page ends only at a control byte.
  • A 0xC? escape whose argument falls in 0x00..0x1E (e.g. 0xC1 0x00) does not end the line; the advance loop consumes the argument as part of the token.
  • Multi-page speech is several pages chained by 0x24. dialog_box::pack_page decodes a page; pack_box / pack_boxes cut the same lines into window-sized chunks. The Rim Elm sparring partner's narration packs into three full pages then a 2-row box opening a 4-option menu.

Row window and scrolling

The row table is a scrolling window. State 0x0B types the last row; a line after a full window scrolls it (0x0C, 15 px per row at 0x24 * dt sixteenths a call); at a page end, rows the previous page left above scroll away (0x0F) before the page waits (0x19). A 0x24 page turn keeps the rows and types the next page beneath them. A confirm press while a row types or holds latches 0x25 and completes the page (0x0D, then 0x0E for overflow). The text is clipped to the box's rows band; the frame does not scroll. A town01 PCSX-Redux trace pins every arm, and the engine reproduces it vsync for vsync.

Post-page control byte

When the page is full and the player confirms, the pager reads the next control byte (masked & 0x7F) and picks the follow-on state:

Control bytePager statesEffect
0x243 → 4Next line, same box (rows preserved)
0x489 → 0xABox reset, open animation - a fresh box
0x250 → 1Box reset, row buffer cleared (teardown)
0x4C 0xFF6 → 7Box reset, row buffer cleared (teardown)
0x2A0x11 → 0x122-option menu in a small top-right box that slides in from the right edge
0x270x13 → 0x142-option Yes / No menu; the box slides up from the bottom edge
0x280x15 → 0x163-option menu, same slide
0x290x17 → 0x184-option menu, same slide

The pager decides the box, not the conversation: teardown clears rows and stops. Whether the dialogue session ends is decided by the actor dialog SM and the field VM.

No menu takes input on the press that opens it. The box slides from off screen to its place over a span of 24 frame-step units - twelve pager calls at the field's 30 fps, 10 px a call for the inn's top-right box - and the option hand appears on the call where it lands, with input read from the call after. A capture of an inn's stay offer measures 26 vsyncs from the press to a usable menu. Tetsu's 4-option list in Rim Elm rises the same way from below the screen, y 240 to 148 at 8, 8, 7 px a call, with the same 26 vsyncs to input.

Choice menus (pickers)

Four open bytes - 0x27, 0x28, 0x29 and 0x2A - open a menu. Relative to the open byte at index O:

[ .. 0x1F prompt segment .. 0x00 ]   the box text shown above the menu
O                                     open byte (0x27 / 0x28 / 0x29 / 0x2A)
O+1 .. O+N*2                          N jump entries, 2 bytes each (i16 LE)
O+N*2+1                               continuation byte (0x24/0x25/0x48/0x4C 0xFF)
                                      OR the first label's 0x1F (immediate labels)
N * [ 0x1F label segment 0x00 ]       the on-screen option labels
  • Each jump entry is a signed 16-bit relative jump applied on confirm: new_pc = (O + 1 + index*2) + entry[index], relative to that option's own entry.
  • Labels are ordinary 0x1F segments after the continuation byte, drawn under the prompt at a 15-px row pitch.
  • Two continuation forms exist: a dispatch byte before the labels (the izumi book menu) or the first label's 0x1F directly (Rim Elm's spar menu). The parser accepts both.
  • 0x2A is a real 2-option picker whose cursor clamps instead of wrapping. Every inn's Yes / No offer is one (inn); a decoder that stops at 0x29 finds no inn menus.

Engine: OwnedDialogPanel::from_inline_dialog attaches the picker, confirm_menu applies the jump. The faithful path, inline_dialogue, drives the whole interaction script through the real field VM (flag tests, scene changes) and pauses only at each 0x1F segment - default in play-window, --simple-dialogue opts out.

Box geometry and render

The three box-open init arms (states 0, 6, 9) are near-copies differing in one word: the successor state each writes (1, 7, 0xA). States 1 and 7 clear the 16-entry row buffer; state 4 (from 0x24) returns early and preserves it; state 0xA ramps _DAT_801F274C to 0x1000, which the draw reads as the box collapsing to its centre line, then hides the box for 0x18 steps and drops into state 1.

Picker box rect: x = 0x26, y = 0x94 + ((4-N)*0xF)/2, w = 0xF4, h = 0x38 - (4-N)*0xF - 244 wide, shrinking 15 px per absent option. The main reading box is emitted as (ctx+0x12, ctx+0x14 + d, 0xF4, lines*0xF - 3) with skin 0x61, where d moves only with the 0x48 collapse animation - the rows scroll, the frame does not.

The box emitter composes a 9-slice gold frame from the system-UI sheet plus an interior fill of two identical semi-transparent gouraud quads (mode B/2 + F/2, top RGB (0x18,0x18,0x28), bottom (0x40,0x40,0xA0)), which composes to 0.25 back + 0.75 gradient. The engine bakes the gradient at alpha 191/255 and draws it once. The page-advance hand draws at (0x10A, box_y + lines*0xF - 0x13); the picker hand at (box_x - 6, box_y + cursor*0xF); labels render CLUT 7 at box_x + 0x10.

History: labels between the open byte and the continuation

An earlier note read the 2-byte entries after the open byte as the option labels. They are the jump table; the labels follow the continuation byte. An earlier parser also required the dispatch-byte continuation and so found zero pickers in town01. See do-not-re-walk.

How we know

FunctionAddressWhat it provesDump
Stride walkerFUN_8003CA38Byte classification: terminator <= 0x1E, (b & 0xF0) == 0xC0 is 2-bytefuncs/8003ca38.txt
Width measurerFUN_80036044Substitution gate (b + 0x40) < 8; name-table pointers 0x80084708 + XX*0x414, PTR_DAT_8007436C, PTR_s_Magic_800754D0, DAT_80075EC4, DAT_80073F24funcs/80036044.txt
Substitution expanderFUN_800365140x5E / 0xFF aliases; inline name copyfuncs/80036514.txt
RendererFUN_80036888Glyph emit + spacing forwardfuncs/80036888.txt
PagerFUN_801D84D0; dispatch chain 0x801D8FDC, jump table 0x801CEBC026 states (_DAT_801F2734), rows at _DAT_801F3540, lines-per-box _DAT_801F2740 = 3, control-byte tablefuncs/overlay_*_801d84d0.txt
Picker cursor handler0x801D941COption count by state (0x18 → 4, 0x16 → 3, else 2); state 0x12 clampssame dump
Actor dialog SMFUN_80039B7CAdvance loop treats 0xC? + argument as one tokenfuncs/80039b7c.txt
Inline-script control handlerFUN_80038050Applies the relative jump on confirm; lists 0x2A with 0x27..0x29; cursor at DAT_801C6EA4+0xC, PC at actor[+0x9E]funcs/80038050.txt
Box emitterFUN_8002C69C, skin FUN_80034B6C, hands FUN_8002B9949-slice frame from DAT_80073A00; double gouraud fillfuncs/8002c69c.txt
Disc-gated testsfield_dialog_boxpack_disc, field_dialog_pickers_disc, tetsu_spar_picker_discBox packing, jump targets inside their own script, immediate-labels formcrates/mes/tests
Live blobRAM 0x80109270, 3893 bytesTown-overlay capture validating the parser end to endsave state

Field dialogue has no dedicated opcode: it is the actor's inline interaction-script text, triggered by the touch / button-press interaction rather than an opcode (op 0x3E with op0 < 100 is the scripted-battle install). Op 0x3F is the scene change, not a dialog op.

CLI

mes info       <PATH>             # detect variant + report header
mes disasm     <PATH>             # walk the bytecode, print decoded ops
mes json       <PATH>             # emit machine-readable JSON
mes events     <PATH> [--index N] # walk the interpreter for one message
mes stats-all  <PATH>             # event-type histogram across every message

Source of record: docs/formats/mes.md.

See also