Expansion packs: quests, challenges and dialogue
Two files of an expansion pack describe what the player is asked to do and what the pack's characters say: quests.toml (quests and challenges) and dialog.toml (dialogue lines). The engine tracks quest progress as a deterministic state machine, plays pack lines through the retail dialogue runner, and saves one pack's progress as the LXP1 expansion block. Every hook is the identity with no pack active: no event is built, no battle command is refused, no line starts, and a save carries no expansion block.
At a glance
- Files
quests.toml,dialog.tomlat the pack root; both optional.- Parser
legaia_expansion_format::{quest, dialog}; the line compilercompile_lineand the markup encoder sit indialog, so the validator compiles every line too.- Runtime
legaia_engine_expansion::quest(the tracker),quest_challenge(rules and scores),flags(pack flags and counters),dialog(the line on screen),quest_save(the save block);engine-core'sworld::expansion_questswires them to theWorld.- Ids
quests.tomldeclaresquestandchallenge;dialog.tomldeclaresline.- Save
- The
LXP1block, carried in anLGSFfile asLGXDand in a retail SC block'sLGXEtail. - Used by
- Battles (fights a quest starts) · maps (NPC talks, triggers) · the pause menu's Journal (field menu)
Ids and namespaces
A quest names everything by id. Retail content is named by its coordinate: a runtime formation id (FormationDef::formation_id), a 1-based monster id, a CDNAME scene name, an item id, a retail system flag index. Pack content is named by the string id the pack gives it: an NPC, a dialogue line, a mode, a counter, a flag, a battle.toml formation.
A pack never writes a retail story flag
Pack flags and counters are the pack's own store (PackFlags), saved under the pack id. A pack reads retail system flags as prerequisites but never writes one: a quest reward, a dialogue choice and a script event all write pack flags. The compiled dialogue bytes carry no field-VM flag op for the same reason (below).
quests.toml
[[quests]]
id = "tower_trial"
title = "The Warden's Trial"
description = "..."
giver = "tower_guide"
prerequisites = { quests = ["tower_rumour"], min_level = 30 }
start_dialog = "guide_trial"
objectives = [
{ kind = "win_battles", count = 3, text = "Win three battles" },
{ kind = "reach_level", level = 40 },
{ kind = "talk", npc = "tower_guide" },
]
complete_dialog = "guide_done"
rewards = { gold = 10000, set_flags = ["tower_open"] }
| Key | Meaning |
|---|---|
id | Pack-unique; the key the save records progress under. |
title, description | Journal text. |
giver | NPC id whose talk starts the quest. |
auto_start | Start as soon as the prerequisites hold. |
prerequisites | Start conditions, all of which must hold. |
ordered | true (default): objectives complete in order. false: all at once. |
objectives | The goals; the quest completes when every one is done. |
rewards | Granted on completion. |
repeat | Whether the quest can run again. |
start_dialog, complete_dialog | Line ids shown on start and on completion. |
active_dialog | Line id shown when the player talks to the giver while the quest is active. |
start_battle, start_boss | A battle.toml formation or boss fought once the quest's start line is done. |
start_cutscene, complete_cutscene | A cutscenes.toml cutscene played after the start or completion line, once the player is in the field (cutscenes). |
A quest with neither giver nor auto_start starts only from a script (World::expansion_start_quest).
Prerequisites
| Key | Holds when |
|---|---|
quests | Each listed quest has completed at least once. |
story_flags | Each listed retail system flag is set. |
story_flags_clear | Each listed retail system flag is clear. |
flags | Each listed pack flag is set. |
min_level | The present party's highest level is at least this. |
The system flags are the field VM's flag bank (ops 0x50 / 0x60 / 0x70, World::system_flag_test), the one place the story records how far the player has come, so a story point is named by the flag its scene script raises there.
Objectives
Each objective is a table with a kind, that kind's keys, and an optional text for the journal.
kind | Keys | Done when |
|---|---|---|
defeat_formation | formation, count | count battles against the formation are won. |
defeat_monster | monster, count | count won battles had the monster in them. |
win_battles | count | count battles of any kind are won. |
win_boss | boss or formation (optional) | A boss battle is won (optionally that one). |
enter_scene | scene | The player enters the scene (CDNAME name or pack map id). |
reach_level | level | The party's highest level reaches level. |
collect_item | item, count, hand_in | The bag holds count of the item. |
talk | npc | The player talks to the NPC. |
win_mode | mode, count | The pack mode is won count times. |
counter | counter, at_least | The pack counter reaches at_least. |
flag | flag | The pack flag is set. |
count defaults to 1. A formation is a number (a runtime formation id) or a string (a battle.toml formation id, resolved through QuestWorld::pack_formation_id to its compiled 0xE000+ id). A win_boss naming a battle.toml boss matches a won battle against that boss's formation; one naming a formation, or nothing, matches a won battle whose formation record carries a non-zero header byte (FormationDef::header_flags: retail's scripted and boss rows, and every pack boss's formation).
- Event objectives (
defeat_*,win_*,enter_scene,talk) count events. - State objectives (
reach_level,counter,flag) are re-tested against the world and latch once met. collect_itemtracks the bag live and does not latch: dropping the items takes the progress back. Withhand_in = true, completing the quest takes the items.
In an ordered quest only the first objective that is not done takes an event, so one event never counts twice down the list; the objectives after it are re-tested only for state. The talk that starts a quest is not counted toward that quest's own talk objective: events reach the active quests before the giver check starts new ones.
Rewards
| Key | Grant |
|---|---|
gold | Added to the purse (clamped at 9,999,999). |
xp | EXP for each present party member, unscaled. |
items | [{ item, count }] added to the bag. |
cards | Trading-card ids, handed to the card collection. |
set_flags | Pack flags raised. |
counters | [{ counter, add }] added to pack counters (add defaults to 1). |
Quest EXP goes through the level-up tracker per member and keeps the record words the battle EXP path keeps (cumulative and next-level EXP, the level byte, grown maxima), without the battle's 3/4 scale and living-member split.
Repeat
| Key | Meaning |
|---|---|
repeatable | The quest can be taken again after completing. |
cooldown_seconds | Play-clock seconds that must pass after a completion. |
max_completions | Cap on completions. |
The cooldown runs on the save's play clock (clock.play_time_seconds), so a "daily" quest is daily in play time and the tracker reads no wall clock.
Challenges
[[challenges]]
id = "bare_hands"
formation = "ogre_pack"
rules = { turn_limit = 10, no_items = true, no_magic = true }
score = { base = 1000, turn_bonus = 100 }
rewards = { gold = 2000 }
A challenge is armed (World::expansion_arm_challenge, the Journal's Challenges page) and then applies to the next battle against its formation, or to the next battle at all when it names none. Arming is refused when the present party already breaks a party rule, or when a named formation does not resolve.
| Rule | Effect |
|---|---|
turn_limit | The battle must be won within this many rounds. |
no_items | The battle command ring refuses the Item command. |
no_magic | The battle command ring refuses the Ra-Seru Magic command. |
solo | Only this roster slot may be in the party. |
level_cap | No party member may be above this level. |
enemy_scale_permille | Enemy HP and attack scale, 1000 = retail. |
The command refusals sit beside the special-battle word's Ra-Seru refusal in the command ring: the ring stays up and nothing commits. Rounds are counted as each round's execution begins. solo and level_cap are checked when the challenge is armed and again when the battle is won. enemy_scale_permille is answered by QuestRuntime::enemy_scale_permille for the battle setup to apply.
The battle's end clears or fails the challenge and disarms it. A win within the rules scores
base + turn_bonus * (turn_limit - rounds) + level_bonus * (level_cap - party level)
with each bonus term present only when its rule is set and never negative. The save keeps the best score and the clear count per challenge; rewards are granted on the first clear. A loss, an escape, a win past the turn limit and a win that broke a party rule each fail the challenge with their own reason.
dialog.toml
[lines.guide_intro]
speaker = "Tower Guide"
text = """
A tower rose from the mist where
nothing stood before.
Will you hear what it asks?"""
choices = [
{ label = "Tell me.", set_flags = ["tower_listened"] },
{ label = "Who are you?", reply = "Only one who watches the mist.", next = "guide_more" },
]
Each line of text is one dialog-box row and a blank line starts a new page. Rows are short: no retail text surface wraps (dialog font). speaker is metadata for journals and tools; the box shows only the text, as retail boxes carry no name plate.
Text is the translation pipeline's markup: printable ASCII as itself, {xx} for any byte, {op:arg} for a two-byte token ({c1:00} is party member 0's name, {cf:31} a colour change), with typographic quotes, dashes and the ellipsis folded onto their ASCII glyphs. dialog::encode_markup is the dialog-segment subset of legaia_translate::markup::encode: the same grammar and the same bytes, without the patcher stack. Bytes 0x00..=0x1F are refused as a token's opcode, since they would end the segment; the symbol aliases ({btn:x}) are not part of the subset.
A line may end on a menu of two to four choices. Each choice has a label, an optional reply (rows and pages as text), pack flags it raises (set_flags) and clears (clear_flags), a next line shown once the talk ends, and a fight (battle, a formation id, or boss) started once the talk and any chained lines are done.
Dialogue on the inline runner
World::expansion_say(line_id) compiles the line (dialog::compile_line) into the inline interaction-script bytes an NPC talk hands the field VM's dialogue runner (World::start_inline_dialogue), so a pack line types, pages and opens its menu through the same pager and picker as a retail line (MES dialog):
page = row+ row = 0x1F <encoded markup> 0x00
text = page (0x24 page)* 0x24: next page, same box
line = text 0x21 no menu: the talk ends
| text <open> j0..jN-1 label*N branch*N
open = 0x27 / 0x28 / 0x29 two / three / four options
jK = i16 LE, relative to jK's own offset, to branch K
label = 0x1F <encoded label> 0x00 immediate-labels form
branch= [text] 0x21 the option's reply, then the end
The runner records the committed option on the pack line's own run (matched by the identity of the compiled buffer), and the world tick that sees the run finished applies the option's flags and queues its next line. A quest's start_dialog, complete_dialog and active_dialog lines queue the same way and start when no dialogue owns the player. A fight a choice or a quest start names waits behind them: once no line is pending or queued and the player is walking the field or the world map, the world tick starts it through World::start_expansion_battle or World::start_expansion_boss.
The expansion workbench checks each line as it is typed: row lengths, markup with no glyph and the compiled size.
Validation
Both sections implement validate::SectionRules (validation). The checks:
| Check | Severity |
|---|---|
| A prerequisite quest, line, formation or boss that is not defined | error |
| A line that does not compile (no text, a menu outside 2..=4 options, text with no glyph) | error |
| A quest or choice naming both a battle and a boss | error |
A quest requiring itself; reach_level outside 1..=99; a turn limit or enemy scale of 0; solo past slot 3 | error |
| An item or monster id out of range | error |
A quest with no giver and no auto_start; repeat settings on a one-shot quest; a count of 0; a row over 36 characters | warning |
Runtime
The tracker (QuestRuntime) is a deterministic state machine: the same pack, progress and events give the same notices, grants and progress bytes. It reads world state through QuestWorld (party levels, bag counts, system flags, the play clock, named formations) and consumes QuestEvents:
| Event | Raised by |
|---|---|
BattleEnded | World::finish_battle, with the seated formation, its monsters and its boss byte. |
SceneEntered | World::set_active_scene_label, on every scene entry. |
Talked | World::expansion_talked: a map's NPC or talk trigger, and every talk to a retail NPC (World::trigger_field_interact). |
ModeWon | World::expansion_mode_won, from a pack mode's host. |
CounterAdd, FlagSet, FlagCleared | Scripts, dialogue choices and hosts. |
Refresh | Every world tick outside battle. |
An NPC id is a pack's own name (tower_guide, a pack map NPC or a map trigger) or a retail NPC: <scene>:<slot>, the CDNAME scene and the placement slot the touch post engages (town01:12, World::retail_npc_id). Every talk to a retail villager reports its id while a pack is active, so a quest can name one as a giver or a talk objective; the retail talk runs unchanged and a pack line it raises waits for that dialogue to close. The engine logs each id at debug level (expansion: talked to retail NPC ...), which is how a modder reads off a villager's slot.
The per-tick Refresh re-tests state objectives and prerequisites, which is how a level up, an item gained or a story flag set reaches quests without a hook on each of those paths. After each event the tracker starts the quests whose giver was talked to, completes the quests whose objectives are done, and auto-starts what may start, repeating until nothing moves so a completion's flags can open the next quest of a chain in one call.
The tracker queues QuestNotices for the host (started, objective updated, completed, challenge armed, cleared, failed; World::take_expansion_notices) and Grants the World applies at once. QuestRuntime::journal lists the started quests with each objective's progress and target; the pause menu's Journal is built on it (legaia_engine_expansion::journal, field menu).
Each notice also becomes a toast both hosts draw on the banner rows (legaia_engine_expansion::toast, World::expansion_toast): Quest started: <title>, <title>: 2/3 (or objective 2/3 done for a one-step objective), Quest complete: <title>, and the challenge outcomes. An objective that completed its quest in the same step shows as the completion alone. A reminder shows no toast; its line is the reminder.
Saves
The save carries one pack's progress as the expansion block, a byte string quest_save owns (quest_save.rs). str is a u16 byte length then UTF-8; integers are little-endian; maps are written in key order, so equal progress encodes to equal bytes. An armed challenge, queued notices and the line on screen are not saved.
The block
Variable-length fields; widths are not to scale.
| Field | Per entry |
|---|---|
| quests | str id, u8 status (0 active, 1 completed), u32 completions, u32 last_completed_at, u16 objective count, u32 progress each |
| flags | str |
| counters | str id, i64 value |
| challenges | str id, u32 best, u32 clears |
Tail records
Zero or more tagged records follow the challenges, each a u8 tag and its body. Each is written only when it carries something, so a block with none of them is the layout an older writer made, and such a block loads with an empty list, no map and no cards.
Tag 1 - cleared bosses (a once-only battle.toml boss is beaten; the battle runtime's cleared_bosses):
Tag 2 - map (the save is made on a pack map; map::MapLocation, maps):
Tag 3 - cards (the player has any card state; CardProgress, card economy):
The card flags' bit 0 says the starter cards were handed over. The collection is str id + u32 copies each, in binder order; boosters str id + u32 unopened each; the deck and the seen list str each.
| Tag | Record | Written when | Owner |
|---|---|---|---|
1 | Cleared bosses | A once-only battle.toml boss is beaten | The battle runtime's cleared_bosses |
2 | Map | The save is made on a pack map | map::MapLocation |
3 | Cards | The player has any card state | CardProgress |
4 | Pack items | The player holds or wears a pack item | ItemProgress (items) |
An unknown tag rejects the whole block
The reader takes records until the block ends; a tag it does not know, or a block with bytes left over, decodes to nothing (ExpansionProgress::decode answers None), so a new record kind needs a tag the reader learns at the same time.
Where the block lives
legaia_save stores the block opaquely as SaveExtV2::expansion. An LGSF file carries it as the optional LGXD block after LGXC; a retail SC block carries it as a third u16-length segment of the LGXE engine-ext tail, written only when it fits whole after the LGX2 and LGX4 bodies (save screen). A save made with no pack writes neither, so its bytes are the bytes the engine wrote before the block existed.
- Activation.
World::activate_expansionis the one activation path: it compiles the battle tables and brings the quest runtime onto the pack, adopting a dormant block saved for it and handing its cleared bosses to the battle runtime. - Deactivation.
World::deactivate_expansionfolds the cleared bosses into the progress and parks it as the dormant block, so a re-activation picks it up. - Save and load.
WorldSaveState::save_fullwrites the active pack's progress;load_fullrestores it when the block's pack id is the active pack's. A block for any other pack, or one loaded with no pack active, is kept dormant and written back verbatim on the next save, so playing a session without the pack does not erase its progress; activating that pack later adopts the dormant block. A save without a block loads under a pack as fresh progress. A renamed committed pack keeps its old saves: decoding maps a block's pack id throughquest_save::RENAMED_PACK_IDS(the sample pack wasrebirth), so the block matches the pack under its current id.