MES dialog format Confirmed
MES is the container for Legaia's dialog text - every line an NPC speaks lives in one. The text is not ASCII: each byte names a tile in the dialog font, and a few prefix bytes splice in live data - the hero's name, an item, an art - at display time. Two on-disc variants share an offset table + bytecode tail, and unusually the interpreter is in the main executable rather than an overlay.
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: recurring0x44 0x78marker pairs, no fixed header - Size / stride
- Glyph bytes are 1 byte; escapes
0xC0..0xCFare 2 bytes; a line is0x1F <glyphs> 0x00 - Parser
crates/mes(CLImes); box packinglegaia_mes::dialog_box; pickerslegaia_mes::picker- Engine
engine_dialog::dialog+engine_field::inline_dialogue; pager SM inengine-dialog- 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
A line is glyph indices with 2-byte escapes. Consecutive lines fill a three-row box; the control byte after the page says what the button press does - next page, close, or open a choice menu.
Container variants
| Variant | Discriminator | Where |
|---|---|---|
| Compact | First u16 = 0x0404 | Small blobs embedded inline in efect.dat and similar |
| Records | First two bytes 0x44 0x78 | Larger dialog blobs; the form found in RAM in town overlays |
Compact header
| Offset | Size | Field | Meaning |
|---|---|---|---|
+0x00 | 4 | magic | 0x00000404 |
+0x04 | 0x24 | - | Unused padding |
+0x28 | 4 | back_ptr | Runtime pointer, patched on load |
+0x2C | 4 | forward_ptr | Runtime pointer |
+0x30 | 4 | expanded_size | Byte count of the expanded blob |
+0x34 | 4 | count | Number of messages |
+0x38 | 16 | metrics | i16[8] per-line metrics |
+0x48 | 26 | - | Further header fields (unknown) |
+0x62 | 3 × N | offset table | u24 offsets from 0xC8; up to 86 entries |
+0xC8 | - | bytecode | Message 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.
| Byte | Stride | Meaning |
|---|---|---|
0x00..0x1E | 1 | Line terminator; walkers stop here |
0x1F..0x5D, 0x5F..0xBF, 0xD0..0xFE | 1 | Glyph (font tile index); 0x1F is also the line lead |
0x5E XX | 2 | Alias, rewritten to 0xCE (XX - 0x2D) |
0xC0 XX, 0xC6 XX, 0xC8..0xCD XX | 2 | Wide glyph, no substitution |
0xC1 XX | 2 | Character name from the save record XX; 99 = current party leader |
0xC2 XX, 0xC4 XX | 2 | Item name (item table); two consumer sites, one table |
0xC3 XX | 2 | Magic name (spell table) |
0xC5 XX | 2 | Tactical Art name (arts-name table), key (character = XX >> 6, art = XX & 0x3F) |
0xC7 XX | 2 | Terrain / quest name (8-byte stride table) |
0xCE XX | 2 | Spacing: XX is a horizontal offset |
0xCF XX | 2 | Colour: the text after it draws in ink XX; the pair itself draws nothing |
0xFF | 1 | Alias, 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.
Speaker tags are coloured. When a Ra-Seru speaks, the line opens on its name wrapped in two colour pairs: CF 04 C7 01 CF 07 prints Terra in green and switches back to the dialogue white for the colon and the line; Meta takes ink 9 (orange) and Ozma ink 5 (teal), and art names ink 6 (gold). The field pager resets the ink to white once a frame before it draws its rows, so an ink left standing at the end of a row carries into the next one.
Japanese build: count-led Shift-JIS lines
The table above is the Latin builds' encoding (USA and PAL). The Japanese disc frames the same script positions differently, measured from its scene MANs:
| Latin builds | Japanese build | |
|---|---|---|
| Line | 0x1F <glyph bytes> 0x00 | count byte N (0x01..=0x1F) then exactly N two-byte tokens, no terminator |
| Blank row | 1F 20 00 | 00 |
| Substitutions | C1 xx name, C2 xx item, … | moved to F0..FF: F1 xx party name, F2 xx item |
| Glyphs | single bytes | Shift-JIS double-byte characters |
Both satisfy the pager's line test (b & 0x7F) < 0x20, which is why the Latin builds kept 0x1F - the Japanese build's longest count - as their lead byte. Three Latin-build scene MANs still carry Japanese-framed lines that no Latin script reaches.
The four interpreters
- Stride walker - 16 instructions returning the glyph count to the next terminator; the pager uses it for line lengths.
- Glyph counter - the same walk plus substitution dispatch, counting the substituted name's glyphs too. It returns a count, not a pixel width (it never reads the font width table): the count paces the typewriter reveal. Nothing wraps text at run time - line breaks are authored (dialog font).
- 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. In the port: engine-core's dialog_window (rows, scroll, confirm) and dialog_pacing (the typewriter).
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
0x1Flines are one page: a line right after a line's0x00means "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 in0x00..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_pagedecodes a page;pack_box/pack_boxescut 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 emulator trace pins every arm.
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 byte | Pager states | Effect |
|---|---|---|
0x24 | 3 → 4 | Next line, same box (rows preserved) |
0x48 | 9 → 0xA | Box reset, open animation - a fresh box |
0x25 | 0 → 1 | Box reset, row buffer cleared (teardown) |
0x4C 0xFF | 6 → 7 | Box reset, row buffer cleared (teardown) |
0x2A | 0x11 → 0x12 | 2-option menu in a small top-right box that slides in from the right edge |
0x27 | 0x13 → 0x14 | 2-option Yes / No menu; the box slides up from the bottom edge |
0x28 | 0x15 → 0x16 | 3-option menu, same slide |
0x29 | 0x17 → 0x18 | 4-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.
The pager runs and draws every game tick, gated on its state alone, so a box outlives the press that dismisses it. Every dispatch draws the box once more, without the page mark. After 0x25 the next state draws nothing, so the box lasts that one frame; after 0x24 the idle state keeps the rows on screen while the script runs its opcodes, until the next line loads beneath them. A picker's commit dispatches on its continuation byte the same way: an inn's Yes / No offer ends on 0x24, so the question stays up, without the picker, while Vahn nods. The port keeps the dismissed box as a held panel both hosts draw until the next line (one tick for the other bytes).
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:
- 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
0x1Fsegments 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
izumibook menu) or the first label's0x1Fdirectly (Rim Elm's spar menu). The parser accepts both. 0x2Ais a real 2-option picker whose cursor clamps instead of wrapping. Every inn's Yes / No offer is one (inn); a decoder that stops at0x29finds no inn menus.
In the port: legaia_mes::picker decodes the menus; engine-field::inline_dialogue runs the whole interaction script through the field VM (flag tests, scene changes) and pauses only at each 0x1F segment, applying the picked jump on confirm.
The option cursor
Every box shares one cursor word, *(DAT_801C6EA4 + 0xC). The pager's Up / Down handler at 0x801D941C is its only stepper and never resets it. The reset is the script runner's: each time FUN_80039B7C hands a line to the pager it copies the preset word +0xE into the cursor and zeroes the preset (lhu v1,0xe(v0) / sh zero,0xe(v0) / sh v1,0xc(v0) at 0x80039F90..0x80039F98).
- Every picker therefore opens on row 0, unless op
4C E0 <n>withn >= 100set the preset ton - 100ahead of it (op 0x4C).izumi's statue pages do (4C E0 65..67), the only carriers on the disc. - A box carried on over a
0x24continuation keeps its rows but not its cursor:teien's Mist question loops back to itself on No, and the second ask opens on Yes.
Port: OwnedDialogPanel::seat_picker_cursor, called with the spent preset from World::apply_dialog_window_format on every line load.
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.
How we know
Provenance: the four interpreters, the pager, the picker handlers and the box emitter
| Function | Address | What it proves | Dump |
|---|---|---|---|
| Stride walker | FUN_8003CA38 | Byte classification: terminator <= 0x1E, (b & 0xF0) == 0xC0 is 2-byte | funcs/8003ca38.txt |
| Glyph counter | FUN_80036044 | Returns a glyph count over a counter advanced only by 1 or an expanded-substitution count; substitution gate (b + 0x40) < 8; name-table pointers 0x80084708 + XX*0x414, PTR_DAT_8007436C, PTR_s_Magic_800754D0, DAT_80075EC4, DAT_80073F24 | funcs/80036044.txt |
| Substitution expander | FUN_80036514 | 0x5E / 0xFF aliases; inline name copy | funcs/80036514.txt |
| Renderer | FUN_80036888 | Glyph emit + spacing forward | funcs/80036888.txt |
| Pager | FUN_801D84D0; dispatch chain 0x801D8FDC, jump table 0x801CEBC0 | 26 states (_DAT_801F2734), rows at _DAT_801F3540, lines-per-box _DAT_801F2740 = 3, control-byte table | funcs/overlay_*_801d84d0.txt |
| Picker cursor handler | 0x801D941C | Option count by state (0x18 → 4, 0x16 → 3, else 2); state 0x12 clamps | same dump |
| Actor dialog SM | FUN_80039B7C | Advance loop treats 0xC? + argument as one token | funcs/80039b7c.txt |
| Inline-script control handler | FUN_80038050 | Applies the relative jump on confirm; lists 0x2A with 0x27..0x29; cursor at DAT_801C6EA4+0xC, PC at actor[+0x9E] | funcs/80038050.txt |
| Box emitter | FUN_8002C69C, skin FUN_80034B6C, hands FUN_8002B994 | 9-slice frame from DAT_80073A00; double gouraud fill | funcs/8002c69c.txt |
| Live blob | RAM 0x80109270, 3893 bytes | Town-overlay capture validating the parser end to end | save 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.