Expansion packs: battles and progression
Two files of an expansion pack shape its fights: battle.toml (monsters, formations, encounter overrides, bosses) and progression.toml (the XP curve, EXP and gold multipliers, enemy level scaling, level-up rewards). Both are optional and every field in them is optional, so a pack writes only what it changes. With no pack active every hook is the identity, and the retail game plays bit for bit.
At a glance
- Files
battle.toml,progression.tomlat the pack root; both optional.- Parser
legaia_expansion_format::{battle, progression}(battle.rs, progression.rs).- Runtime
- Kernels
legaia_engine_expansion::{battle, battle_waves, boss_script, progression}; theWorldhooks incrates/engine-core/src/world/expansion.rsandexpansion_boss.rs. - Ids
- Declares
monster,formationandboss; pack formations compile to engine formation ids from0xE000. - Limits
- Four monsters standing at once; up to 16 waves after the first; up to 64 arrivals in a
refillwave. - Retail check
engine-coretestexpansion_battle::no_pack_and_a_dropped_pack_play_the_retail_fight.- Used by
- Quests (fights a quest starts,
win_bossobjectives) · maps (trigger fights, encounter zones) · battle
Ids
Every key in these files is local to the pack. The engine qualifies it with the pack id: key ash_court in pack sample is sample:ash_court. A reference may use either form; a reference into another pack is refused.
Disc content is named only by coordinate (DiscRef): a retail monster by its 1-based monster-archive id ({ kind = "monster", id = 73 }), an item by its item id ({ kind = "item", id = 12 }), a music cue by its sound-test index ({ kind = "bgm", id = 30 }).
Monsters
[monsters.ashen_caruban]
base = { kind = "monster", id = 73 }
name = "Ashen Caruban"
hp = 30000 # a bare number sets the stat
attack = { mul = 1.8 } # { mul = x } scales the base monster's
exp = { mul = 12.0 }
drop = { item = { kind = "item", id = 12 }, chance_pct = 50 }
steal = { item = { kind = "item", id = 9 }, chance_pct = 25 }
element = 3
spells = [0x81]
A pack monster is always derived from a retail one. It keeps the base monster's mesh, animations, attack set, sounds and Seru, which the engine reads off the player's disc; the pack changes its numbers.
| Field | Effect |
|---|---|
base | The retail monster it is built on (required). |
name | The battle HUD name. |
hp, mp, exp, gold | Set or scaled; held to 0..=65535. |
attack, defence, agility, speed, intelligence | The record stats ATK, UDF + LDF, AGL, SPD, INT. |
drop, steal | The victory drop and the Evil God Icon steal, item and percent. |
element | Element id 0..=7 (7 neutral). |
spells | Spell ids the monster AI may cast, at most three; replaces the base's. |
script | The monster's own battle AI in place of the base's retail picks (boss scripts). |
The battle stats are record values, not what the fight installs. The battle loader boosts the record by the fight's class exactly as it does a retail one: a scripted fight installs ATK ×5/4 and both defences ×2, a random encounter defences ×7/4 and INT ×5/4 (MonsterDef::installed_stats, battle). Record stats clamp to 0x3FFF so the scripted doubling cannot wrap 16 bits.
Pack monsters fight under their base monster's id
The monster id a battle seat carries keys every disc read for that seat - mesh, action clips, the AI's swing entries, the battle heap layout - in both play hosts. A pack monster therefore keeps its base monster's id. For the length of a fight the engine swaps the pack monster's numbers into the catalog under that id and restores the scene's entry when the fight ends (World::expansion_prepare_battle / expansion_end_battle).
One version of each base monster per formation
A formation of two ashen_caruban is fine; ashen_caruban beside a retail Caruban, or beside a second Caruban variant, is a validation error.
Formations
[formations.ash_court]
label = "Ash Court"
monsters = ["ashen_caruban", { kind = "monster", id = 12 }]
scripted = false
A slot is a pack monster key or a retail monster ref. A formation may continue in waves. scripted = true makes the formation a retail-style boss row: no escape, the boss battle intro, the alternate seat layout and the scripted stat boost (the formation header byte record[+0], encounter record).
Pack formations register in the formation table at ids 0xE000 and up, in key order, beside the scene's own MAN rows. They are re-registered before every pack fight, since a scene's MAN install fills the table.
Formation size
A formation holds at most four monsters at once. The battle actor table has eight seats: the party takes seats 0..=2, and seat 7 is the summon seat every Seru cast poses its creature on, which leaves four monster seats beside a full party. Retail's largest formations are four monsters, so four is both the retail and the engine limit; a fifth monster would share the summon seat with the first summon cast. A fight with more enemies than that brings them in waves: later monsters take the seats earlier ones vacate.
Waves
[formations.tower_gauntlet]
label = "Tower Gauntlet"
monsters = ["ironhide_ogre", "ironhide_ogre"] # wave 1
[[formations.tower_gauntlet.waves]] # wave 2
monsters = ["ember_lizard", "ember_lizard", "ember_lizard"]
[[formations.tower_gauntlet.waves]] # wave 3
monsters = ["runed_golem", "ironhide_ogre"]
arrival = "refill"
count = 6
max_standing = 3
monsters is the first wave, the monsters the battle opens on. Each [[formations.<key>.waves]] table after it is one more wave, in order. The battle never returns to the field between waves: a wave's monsters take the monster seats of the fight in progress, a "Wave N / M" banner stands for the battle timed message's hold (about two seconds) as a wave begins, and when the last wave is down the ordinary victory runs once.
wipe wave seats together when the side is empty; a refill wave trickles in, capped by max_standing, until it has sent its count.| Field | Effect |
|---|---|
monsters | The wave's monsters, pack keys or retail refs. |
arrival | wipe (the default): the whole wave seats together once every monster standing is down, at most four. refill: one monster per freed seat, at the start of a round. |
count | refill only: arrivals in all, cycling through monsters (default one per listed monster, at most 64). |
max_standing | refill only: no arrival while this many monsters stand (default four). |
A formation lists at most 16 waves after its first.
When a wave arrives
A wipe wave arrives at the moment the action that downed the last standing monster resolves: the battle action SM's end-of-action wipe scan asks the World for more monsters before it signals the victory, and seats them before any frame sees an empty monster side. The round then runs on: party members still owed a turn act against the new arrivals, and the next round opens with every seat's initiative rolled afresh.
A refill wave tops up freed seats as each round opens, ahead of the round's initiative seed, so its arrivals act in that round; a wipe also refills every free seat at once. When a wave starts that stands more monsters than the seats laid out, the seat layout widens to it and the monsters still standing move to the wider layout's seats. The next wave, of either kind, takes over once a refill wave has sent its count.
Spoils
Every monster a wave seats joins the fight's formation, so the one victory pays all of them together: the EXP of every wave summed (then the pack's xp_mul, the retail 3/4 share), the gold accumulated over every monster (then the retail halving and the pack's gold_mul), and one drop roll over every seat. A fight the party flees or loses pays nothing, as any fight.
One version of each base monster
A pack monster fights under its base monster's id, and one catalog swap covers the whole battle, so the one-variant rule holds across all of a formation's waves together: an ashen_caruban in wave 1 and a retail Caruban in wave 3 is a validation error.
Escape and bosses
Escape stays open between and during waves unless the formation is scripted (scripted = true, or a no_escape boss's formation). A boss whose formation has waves pays its reward_items and reward_gold once, after the last wave.
Encounter overrides
[[encounters]]
scene = "chitei2"
retail_weight = 3
formations = [{ formation = "ogre_pack", weight = 2 }, { formation = "forge_guard" }]
steps = [120, 240]
An override changes what one scene (by CDNAME name) rolls.
- Without
steps, retail's own rate decides when a fight comes - the region roll or the mean-rate stand-in, every Incense and accessory modifier included - and the override decides which: onerand()picks among the listed formations by weight, with the retail roll's own formation keepingretail_weight(default0, which replaces every retail fight in the scene). - With
steps = [min, max], the pack owns the rate: a counter drawn inmin..=maxcounts field steps and fights when it runs out, so a scene retail never fights in can hold encounters. The session's transition and post-battle grace, the Incense window and the engaged hold still gate it.
The scene key is a free string, so a pack map's encounter zones name their tables here too (maps).
Bosses
[bosses.ash_king]
formation = "ash_court"
no_escape = true # the default
music = { kind = "bgm", id = 30 }
reward_items = [{ item = { kind = "item", id = 233 }, count = 1 }]
reward_gold = 50000
once = true
A boss is a formation plus staging. no_escape marks its formation scripted. music replaces the battle track for this fight (when the host's battle-music swap is on). reward_items and reward_gold pay on victory on top of the formation's spoils, and land in the spoils' drops and gold. A once boss refuses a second start after a victory (World::expansion_boss_cleared).
Battle stage
[bosses.ash_king]
formation = "ash_court"
stage = { scene = "jou", index = 1, tint = [150, 110, 170] }
[formations.ash_court]
stage = { scene = "rikuroa" } # the block's own pick
A fight is fought on the retail stage of the room it starts in. stage names another by coordinate, on a boss or a formation, and a boss's own wins: scene is a CDNAME block, index the block's index-th battle-stage stream in bundle-slot order (left out, the block's own pick). The stream brings the whole stage - the backdrop shell drawn twice, its second-copy transform and outdoor class, and the ground-grid tile (battle stage).
keep_object_1 keeps the shell object battle init otherwise drops. tint multiplies the stage's own palettes per channel (128 unchanged, 255 about double): the textured shell and the ground tile take it, the shell's untextured panels do not. The tint is applied as the stage stream re-uploads on the battle edge (resolve_battle_stage), so both hosts draw one VRAM. With a disc the validator checks the index against the block's stage streams; at run time a coordinate that names none leaves the room's stage standing. With no pack the pick is retail's.
Quests and maps start fights through two World entry points: start_expansion_battle(formation) and start_expansion_boss(boss). Both go through the ordinary encounter path - transition, battle music, begin_encounter_battle - as World::force_encounter does.
Boss scripts
[monsters.cinder_warden.script]
target = "random"
immune = ["sleep", "confuse", "stone"]
last_stand = { line = "warden_last" }
enrage = { when = { round = 15 }, line = "warden_inferno", attack = { mul = 1.25 } }
[[monsters.cinder_warden.script.phases]] # phase 1 opens the fight
moves = [
{ attack = 0, weight = 3 }, # the base monster's first swing
{ spell = 0x25, weight = 1, cooldown = 2 },
]
[[monsters.cinder_warden.script.phases]]
when = { hp_below_pct = 50 }
line = "warden_rage"
attack = { mul = 1.3 }
summon = ["pyre_wisp"]
moves = [
{ attack = 2, hits = 2, weight = 3, target = "lowest_hp" },
{ spell = 0x46, weight = 2, charge = true, charge_line = "warden_charge" },
]
A pack monster with a script table fights with the script's AI in place of its base monster's retail AI: on each of its turns the script, not the retail picker, chooses the action and the target. A pack monster without one keeps the base's AI, and a fight with no scripted monster in it never reaches the script runtime. Parser legaia_expansion_format::boss_script, kernel legaia_engine_expansion::boss_script, World hooks in crates/engine-core/src/world/expansion_boss.rs.
- Round opensphase checks for every scripted seat; pending summons take free seats ·
World::expansion_boss_round_start - Its turnphase checks again, then the pick: a due charged move, or one weighted draw ·
World::expansion_boss_pick - Chargea telegraphed pick spends the turn; its banner stands ·
MonsterAction::Wait - Acta swing through the attack band, or a spell through the cast band · the retail paths
- Each frameimmunities stripped, banners dequeued, the last stand's catch-all ·
World::expansion_boss_frame
Moves are disc content
attack = nis thenth (0-based) swing of the base monster's attack list: its archive swing records, the entries the retail picker rolls over (tags0x0C..=0x1Fwith an AGL cost, listed byasset monster-archive --id Nand by the workbench's swing picker). It stages through the ordinary attack band, so the clip, the hit events and the power byte are the disc's.hitschains it up to eight times.spell = idis a spell-table id, cast through the retail cast path as a pack monster'sspellslist is: MP debit, cast band, the move-power record and impact status. A capture-class special resolves off the disc table as the retail AI's own scripted casts do. A cast the monster cannot afford falls back to a physical strike, as in retail.
A swing index past the base's list, or a spell id the disc does not hold, makes that turn fall back to the retail pick; the disc-gated test expansion_boss_script_disc checks every sample-pack move against the disc.
Phases
phases is an ordered list. The first opens the fight and takes no when; each later one begins when any condition its when sets holds. Phases run forward only, and one check may begin several in order.
when | Holds when |
|---|---|
hp_below_pct | The monster's HP is below this percent of its maximum. |
round | The battle has reached this round (the first is 1). |
allies_down | At least this many monsters of the fight have fallen. |
| Phase field | Effect, once as the phase begins |
|---|---|
line | A pack dialog line shown as the battle banner. |
moves | The weighted move list; a phase that lists none keeps the moves before it. |
target | The phase's targeting rule. |
attack, defence, agility, speed, intelligence | Set or scale ({ mul = x }) the live battle stat and its base half. |
element | The monster's element; the affinity matrix reads it both ways, so it changes what the monster resists. |
heal_pct | Restores this percent of maximum HP. |
summon | Monsters seated into free monster seats at the next turn boundary. |
flash | { color, frames }: a full-screen flash fading out as the phase begins. |
tint | { color, frames, mode }: a colour held over the battle from this phase on, added or subtracted; black clears it. |
Picks, targets and telegraphs
Each turn draws once from the battle RNG over the standing moves' weights (none when one move is ready), so a fight replays bit for bit. cooldown keeps a move out of the draw for that many of the monster's own turns. target is random, lowest_hp, highest_attack or healer (a member who knows an ally-side spell, the most MP first); the move's rule wins over the phase's, the phase's over the script's. A spell whose shape is not one enemy keeps its shape's targets.
A spell move the monster cannot afford becomes a swing on the retail cast path; ignore_mp = true casts it whatever MP is left and spends none.
A move with charge = true is telegraphed: the turn it is picked the monster only gathers power under charge_line (or a generic line), and the move lands on its next turn.
Enrage, immunities, last stand
enrage: a phase keyed onwhen.roundalone, applied once over whatever phase stands; itsmoves, when set, replace the phases' for the rest of the fight.immune: statuses that never take hold (venom,toxic,numb,sleep,confuse,rot,curse,stone), stripped every battle frame.last_stand: the first lethal blow of the fight holds at 1 HP, under itsline; the next kills.
Banners and summons
Every line shows on the battle's timed-message line, the one the wave banner uses, for about two seconds; a banner raised while another stands queues behind it. A banner is one row (the line's first page, rows joined), and the validator warns past 40 characters.
The three stock banners (an enrage with no line, a charge with no charge_line, a last stand with no line) read the pack's own dialog.toml lines boss_enraged, boss_charging and boss_last_stand when it has them, so a pack in another language words them its way; without them the engine's English wording stands ("<name> is enraged!", "<name> is gathering power!", "<name> refuses to fall!"). <name> reads as the boss's name.
A summon takes a free monster seat through the wave seating path at the next safe point: one raised as a round opens is seated before the initiative seed and acts that round; one raised on the boss's own turn mid-round is seated as that turn ends and first acts the round after. Its mesh binds on both hosts and it joins the spoils. When the seats laid out are full the block widens and the standing monsters move; with four standing the summon is dropped. Summons count toward the one-version rule with the formation's waves.
Validation
Refused: a first phase with a when or no moves, a later phase without one, HP thresholds that do not fall phase by phase, a move with both or neither of attack and spell, hits outside 1..8 or without attack, a zero weight, a charge_line without charge, an ignore_mp without spell, an enrage keyed on anything but round, an unknown summon, more than four summons in a phase, more than eight phases or twelve moves, a duplicated immunity. Every line must name a dialog line of the pack.
The expansion workbench's battle tab edits scripts phase by phase, with the base monster's swings and the disc's spells in the move pickers.
Progression
xp_mul = 1.5
gold_mul = 1.25
[xp_curve]
kind = "banded"
bands = [{ from_level = 60, mul = 0.35 }, { from_level = 80, mul = 0.3 }]
[enemy_scaling]
from_level = 50
per_level = 0.02
max_mul = 1.8
scope = "pack"
rewards = true
[[level_rewards]]
level = 99
items = [{ item = { kind = "item", id = 237 } }]
character = 0
| Field | Effect |
|---|---|
xp_mul, gold_mul | Scale every won battle's summed EXP (before the retail 3/4 share) and credited gold. |
xp_curve | retail; scaled { mul }; banded { bands }, retail's per-level steps scaled from each band's from_level; explicit { levels }, 98 cumulative values for levels 2 to 99. |
enemy_scaling | Past from_level, each level of the strongest present member adds per_level to a stat multiplier capped at max_mul. |
level_rewards | Items granted when a character first reaches level; character (roster slot) restricts it to one. |
Every curve is held rising and within the record's 9,999,999 ceiling. The pack's curve replaces the tracker's table at activation and the previous one returns at deactivation; the Noa / Gala threshold corrections still apply on top of it.
Enemy scaling multiplies HP, MP, ATK, both defences, INT and SPD, and EXP and gold when rewards is set. It leaves AGL alone, since AGL is the action gauge a monster's swings spend and scaling it changes how many attacks a turn holds. scope = "pack" reaches the pack's formations only; scope = "all" reaches retail fights too.
The site's expansion workbench draws a pack's curve against retail's as it is edited.
Engine wiring
A pack fight passes through the same World paths as a retail one; each hook below is the identity with no pack active.
- Activateformations installed, XP curve swapped ·
World::activate_expansion - Scene entrybase monsters merged from the disc's archive ·
SceneHost::merge_expansion_monsters - Encounterthe override picks the formation ·
World::on_field_step - Seatcatalog swap for every wave's monsters ·
enter_battle_from_formation - Wavesarrivals at the wipe hold and round start ·
World::expansion_seat_wave - Spoilsscaling, boss reward, level rewards; catalog restored ·
apply_battle_loot,finish_battle
| Hook | Where |
|---|---|
| Activation, formation install, XP curve swap | World::activate_expansion / deactivate_expansion |
| Base monsters merged from the disc's monster archive at each scene entry | SceneHost::merge_expansion_monsters |
| Catalog swap at seat time (every wave's monsters), restore after the spoils | World::enter_battle_from_formation, finish_battle, abandon_live_battle |
| Wave arrivals | World::expansion_seat_wave, from the action SM's wipe hold (BattleActionHost::reinforce_monster_wipe) and World::begin_battle_round |
| Wave meshes | battle_stage::stage_wave_monsters (world half), the play window's bind_wave_monsters, the browser page's bind_wave_monsters_web |
| Encounter override | World::on_field_step |
| Spoils scaling, boss reward | World::apply_battle_loot |
| Level-up rewards | World::apply_battle_xp |
| Steal override | the death-spoils steal in battle/steal_attack.rs |
| Boss track | World::swap_to_battle_bgm |
| Boss script pick | World::expansion_boss_pick, the top of World::pick_monster_action; a swing stream through World::arm_monster_strike_budget |
| Boss phase checks, summons | World::expansion_boss_round_start, from World::begin_battle_round |
| Last stand | World::expansion_survives_lethal, from apply_battle_hp_delta and apply_combo_total |
| Immunities, banner queue | World::expansion_boss_frame, ahead of the battle frame's dead-marking sweep |
A boss's once record lives in the runtime (BattleRuntime::cleared_bosses) and is saved as tail record 1 of the save's expansion block (saves), so a beaten once-only boss stays beaten across a Load.