At a glance

Files
quests.toml, dialog.toml at the pack root; both optional.
Parser
legaia_expansion_format::{quest, dialog}; the line compiler compile_line and the markup encoder sit in dialog, 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's world::expansion_quests wires them to the World.
Ids
quests.toml declares quest and challenge; dialog.toml declares line.
Save
The LXP1 block, carried in an LGSF file as LGXD and in a retail SC block's LGXE tail.
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"] }
Prerequisites holdquests, flags, story flags, level Startedauto_start, giver talk or script Objectives advanceordered unless ordered = false Completedrewards granted, dialog shown QuestEvent BattleEndedSceneEntered, Talked ModeWon, flagsRefresh (each tick) repeatable: again after the cooldown, up to max_completions
One quest from start to completion. The tracker re-runs the whole cycle after every event until nothing moves, so a completion's flags can open the next quest of a chain in one call.
KeyMeaning
idPack-unique; the key the save records progress under.
title, descriptionJournal text.
giverNPC id whose talk starts the quest.
auto_startStart as soon as the prerequisites hold.
prerequisitesStart conditions, all of which must hold.
orderedtrue (default): objectives complete in order. false: all at once.
objectivesThe goals; the quest completes when every one is done.
rewardsGranted on completion.
repeatWhether the quest can run again.
start_dialog, complete_dialogLine ids shown on start and on completion.
active_dialogLine id shown when the player talks to the giver while the quest is active.
start_battle, start_bossA battle.toml formation or boss fought once the quest's start line is done.
start_cutscene, complete_cutsceneA 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

KeyHolds when
questsEach listed quest has completed at least once.
story_flagsEach listed retail system flag is set.
story_flags_clearEach listed retail system flag is clear.
flagsEach listed pack flag is set.
min_levelThe 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.

kindKeysDone when
defeat_formationformation, countcount battles against the formation are won.
defeat_monstermonster, countcount won battles had the monster in them.
win_battlescountcount battles of any kind are won.
win_bossboss or formation (optional)A boss battle is won (optionally that one).
enter_scenesceneThe player enters the scene (CDNAME name or pack map id).
reach_levellevelThe party's highest level reaches level.
collect_itemitem, count, hand_inThe bag holds count of the item.
talknpcThe player talks to the NPC.
win_modemode, countThe pack mode is won count times.
countercounter, at_leastThe pack counter reaches at_least.
flagflagThe 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_item tracks the bag live and does not latch: dropping the items takes the progress back. With hand_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

KeyGrant
goldAdded to the purse (clamped at 9,999,999).
xpEXP for each present party member, unscaled.
items[{ item, count }] added to the bag.
cardsTrading-card ids, handed to the card collection.
set_flagsPack 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

KeyMeaning
repeatableThe quest can be taken again after completing.
cooldown_secondsPlay-clock seconds that must pass after a completion.
max_completionsCap 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.

RuleEffect
turn_limitThe battle must be won within this many rounds.
no_itemsThe battle command ring refuses the Item command.
no_magicThe battle command ring refuses the Ra-Seru Magic command.
soloOnly this roster slot may be in the party.
level_capNo party member may be above this level.
enemy_scale_permilleEnemy 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:

CheckSeverity
A prerequisite quest, line, formation or boss that is not definederror
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 bosserror
A quest requiring itself; reach_level outside 1..=99; a turn limit or enemy scale of 0; solo past slot 3error
An item or monster id out of rangeerror
A quest with no giver and no auto_start; repeat settings on a one-shot quest; a count of 0; a row over 36 characterswarning

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:

EventRaised by
BattleEndedWorld::finish_battle, with the seated formation, its monsters and its boss byte.
SceneEnteredWorld::set_active_scene_label, on every scene entry.
TalkedWorld::expansion_talked: a map's NPC or talk trigger, and every talk to a retail NPC (World::trigger_field_interact).
ModeWonWorld::expansion_mode_won, from a pack mode's host.
CounterAdd, FlagSet, FlagClearedScripts, dialogue choices and hosts.
RefreshEvery 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

+0"LXP1"4 B
+4pack idstr
pack versionstr
questsu16 + n
flagsu16 + n
countersu16 + n
challengesu16 + n
tail records0..n

Variable-length fields; widths are not to scale.

FieldPer entry
questsstr id, u8 status (0 active, 1 completed), u32 completions, u32 last_completed_at, u16 objective count, u32 progress each
flagsstr
countersstr id, i64 value
challengesstr 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):

+001u8
+1countu16
+3boss key eachstr × count

Tag 2 - map (the save is made on a pack map; map::MapLocation, maps):

+002u8
+1map idstr
xi16
zi16
facingu16

Tag 3 - cards (the player has any card state; CardProgress, card economy):

+003u8
+1flagsu8
+2card streamu64
+10collectionu16 + n
boostersu16 + n
decku16 + n
seenu16 + n

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.

TagRecordWritten whenOwner
1Cleared bossesA once-only battle.toml boss is beatenThe battle runtime's cleared_bosses
2MapThe save is made on a pack mapmap::MapLocation
3CardsThe player has any card stateCardProgress
4Pack itemsThe player holds or wears a pack itemItemProgress (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_expansion is 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_expansion folds 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_full writes the active pack's progress; load_full restores 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 through quest_save::RENAMED_PACK_IDS (the sample pack was rebirth), so the block matches the pack under its current id.