At a glance

Source form
A directory: pack.toml plus optional section files, maps/ and assets/.
Shareable form
One .lxpack file (bundle), byte-deterministic.
Encoding
UTF-8 TOML for every manifest, section and map.
Schema
Version 3 (manifest::SCHEMA_VERSION); schemas 1 to 3 load.
Disc
NA release only, SCUS-94254.
Parser
ExpansionPack::from_files - one parser for a directory, a bundle and the browser workbench's input.
Writer
ExpansionPack::to_files, bundle::write_bundle.
Validator
validate::validate, validate::validate_files.
Sharing
share::import_bundle (the import gates), diff::diff_packs (two versions compared).
CLI
asset expansion validate / bundle / unbundle / info / diff, legaia-engine packs.
Crate
legaia_expansion_format; the runtime is legaia_engine_expansion.

A pack carries no bytes from the disc. Every disc asset it uses is named by a coordinate (DiscRef) and resolved against the disc the player supplies - the same rule a shareable language pack follows. The sample pack, the sample pack, is written with exactly the format and tooling on this page.

Layout

Paths are relative to the pack root with / separators. Every file but pack.toml is optional; an absent section file is that section's default (empty).

my-pack/
  pack.toml          manifest (required)
  battle.toml        monsters, formations, encounter overrides, bosses
  progression.toml   level curve, XP and rewards
  quests.toml        quests and challenges
  dialog.toml        dialogue lines
  cards.toml         trading cards
  cutscenes.toml     scripted scenes
  modes.toml         game modes
  items.toml         items, equipment, sets and shops
  maps/
    <id>.toml        one custom map per file; the file stem is the map id
  assets/...         the modder's own files (art, models, sound, text)
  README.md  LICENSE[.md|.txt]  CHANGELOG.md

The loader reads the files above and ignores anything else; the validator reports an ignored file as a warning, so a misspelt section name does not vanish silently. maps/ is flat: maps/a/b.toml is not a map.

The whole tree is one PackFiles value (BTreeMap<path, bytes>). The directory walker, the bundle reader and the browser workbench each produce one, so a pack parses the same way whichever form it arrived in.

The manifest

pack.toml is manifest::PackManifest. Unknown keys are an error, so a typo fails loudly.

KeyTypeRequiredMeaning
idstringyesMachine id, [a-z0-9_-], at most 48 characters, starting with a letter or digit. Namespaces every content id in the pack.
namestringyesDisplay name.
short_namestringnoA label for narrow places (the launcher's Expansion button): one line of at most 24 characters; unset, those places show name. Schema 3.
versionstringyesSemantic version, MAJOR.MINOR.PATCH[-pre][+build].
authorsstring listnoWho made it.
descriptionstringnoWhat the pack adds.
schemaintegernoPack-format version the pack is written against; defaults to the current one.
discstringnoThe disc the coordinates point into; SCUS-94254, the only value accepted.
licensestringnoLicence for the pack's own content: an SPDX expression or free text.
homepagestringnoWhere the pack is published, an http(s) URL.
tagsstring listnoDiscovery tags, pack-id syntax.
requiresstring listnoPacks that must be loaded alongside this one: id, or id@range (dependencies).
conflictsstring listnoPacks that must not be, in the same form; a range limits the conflict to those versions.
coverstringnoThe cover image: a PNG under assets/, written from the pack root (assets/cover.png). Schema 2.
min_enginestringnoThe oldest engine version the pack runs on, semver. Schema 2.
id = "tower"
name = "The Tower"
version = "0.1.0"

Versions and compatibility

Three versions meet when a pack loads: the pack's own version, the pack format it is written in (schema), and the engine reading it (manifest::ENGINE_VERSION, the workspace version every engine crate shares).

GatePasses whenRefusal
Pack formatschema in 1..=3"written in pack format N, newer than this engine reads ... update the engine"
Enginemin_engine absent, or not newer than this engine"needs engine X or newer; this engine is Y"

ExpansionPack::from_files runs both gates first, on a lenient read of pack.toml (ManifestPeek), before the strict parse - so a pack from a newer format is refused with "update the engine", not with "unknown field" for whatever new key it carries. Every reader parses through from_files, so the native --expansion, the launcher, the play page and the workbench all refuse alike. Old packs keep loading: schemas 2 (cover, min_engine) and 3 (short_name) only added optional keys.

Dependencies

A requires or conflicts entry is a pack id, optionally @ and a version range (PackDep): comparators that must all hold, separated by commas or spaces, with cargo's operators.

EntryMatches
baseany version
base@1.2.3, base@^1.2.3>=1.2.3, <2.0.0; below 1.0.0 the caret holds the leftmost non-zero part
base@~1.2.3>=1.2.3, <1.3.0
base@=1.2, base@1.2.*any 1.2.x
base@>=1.2 <2both bounds

A pre-release satisfies a range only when a comparator names a pre-release of the same version. check_dependencies checks a manifest against the installed packs and names each missing pack, out-of-range version and installed conflict. The play page's pack library runs it on every installed pack and lists the findings under each row, a conflict with the active pack in red.

One active pack

The engine plays one pack at a time: activating a pack replaces the active one, so requires and conflicts are checked and shown, never acted on, and a key qualified with another pack's id resolves to nothing at runtime. Layering several packs is a design, not yet a feature:

What a pack addsRule under layering
Its own content (monsters, quests, cards, maps, items, arts)Kept side by side: every id is namespaced by its pack
Allocated id spaces (formations and tracks from 0xE000, queue bytes from 0x33, aliased item ids)Numbered per pack at activation, in load order, through an indirection the save records
Overrides of retail coordinates (encounter cells, a retail monster or boss, an entrance, shop stock)An error, unless the later pack requires the earlier: then the later wins
Global settings (XP curve, multipliers, mode music)The last pack in load order wins, with a warning
Progress (the save's expansion block)One block per pack id, as a dormant block is kept today

Load order would be the requires graph's topological order, ties in the player's order, with a cycle or an active conflict refusing the set. The cost is the runtime: the battle tables, formation and item seats, arts bank, audio track table, map seat and save block each hold one pack today.

Content ids and namespacing

Content a section defines (a monster, a quest, a card, a map) has a local id: [a-z0-9_], at most 48 characters, unique within its kind across the whole pack. The pack id is its namespace, so tower_king in pack sample is sample:tower_king to the engine and to other packs.

  • tower_king or sample:tower_king (the pack's own id): must be defined somewhere in this pack.
  • other:thing: content of pack other, which must be listed in requires; the engine resolves it once that pack is loaded, not the validator.

A definition never carries a :; writing one is an error.

Disc references

refs::DiscRef is the only way a pack names disc content: a TOML inline table tagged by kind.

boss_model = { kind = "monster", id = 77 }
arena      = { kind = "scene", name = "town01" }
mesh       = { kind = "tmd", prot = 874, index = 3 }
guide      = { kind = "npc", scene = "town01", index = 12 }
battle.toml base = { kind = "monster", id = 109 } DiscRef check_range: no disc 1..=186 monsters Player's disc PROT 867 record 109 mesh, textures, anims DiscResolver what is shared: the pack numbers, names of its own, coordinates never shared the disc's bytes
A coordinate costs the modder nothing: the engine reads the asset from the player's own disc, and the pack stays legal to post.
kindFieldsCoordinate spaceRange checked without a disc
monsterid1-based monster record id in the PROT 867 archive (battle)1..=186
scenenameCDNAME scene name (scene names)[a-z0-9_], at most 32 characters
tmdprot, indexPROT extraction index (PROT.DAT), indexth TMD objectprot in 0..=1232
timprot, indexPROT extraction index, indexth TIM in its packprot in 0..=1232
itemidItem id (item table)the u8 range
bgmidSound-test id (music tracks)0..=80
npcscene, indexA retail NPC placement: the scene's MAN partition-1 record index (maps)scene-name syntax; index not 0 (the scene controller)

PROT numbers are extraction indices, the NNNN in extracted/PROT/NNNN_*.BIN, not raw TOC indices (numbering space).

Checking happens in two halves. DiscRef::check_range needs no disc and rejects a coordinate no NA disc can satisfy. refs::DiscResolver is the disc-backed half: the engine (and the workbench) implement resolve(&DiscRef) over the player's disc - does entry 874 hold a TMD with an object 3 - and validate::validate_with runs every coordinate a section recorded through it.

Sections

Each content section is a TOML file whose type lives in its own module of legaia_expansion_format. Every key is optional, so a pack writes only what it uses.

FileTypeDeclaresReference
battle.tomlBattleSectionmonster, formation, bossbattles
progression.tomlProgressionSection-progression
quests.tomlQuestSectionquest, challengequests
dialog.tomlDialogSectionlinedialogue
cards.tomlCardSectionits own card, booster and dealer idsbelow
cutscenes.tomlCutsceneSectioncutscenecutscenes
modes.tomlModeSectionits own instance idsbelow
items.tomlItemSectionitem, item_set, shopitems
audio.tomlAudioSectiontrack, soundaudio
arts.tomlArtSectionart, art_chainarts
spells.tomlSpellSectionspellspells
maps/<id>.tomlMapDefmap, npcmaps
  • battle.toml - monsters derived from a retail record (set or scaled stats, drop, steal, element, spells), formations of up to four monsters with optional waves, per-scene encounter overrides and named bosses. Formations compile to engine ids from 0xE000; a once-only boss's victory is saved.
  • progression.toml - the XP curve (retail, scaled, banded or explicit), EXP and gold multipliers, enemy level scaling and level-up item rewards.
  • quests.toml - quests (giver, prerequisites keyed to retail system flags, party level and other quests, ordered or unordered objectives, rewards, repeat rules) and challenges (rule sets over one battle with a score).
  • dialog.toml - lines in the translation markup, as rows and pages, with two- to four-option menus that set pack flags, chain a line or start a fight. Every line is compiled at validation, so text with no glyph is an error.
  • cutscenes.toml - scripted scenes: step lists (walks, turns, camera moves, fades, lines that branch on the option picked, flags, items, a closing fight) on a main track and named tracks beside it, run on the engine's own sequencer and played by a map trigger, an arrival, an NPC or a quest moment.
  • maps/<id>.toml - a tile grid with walls and floor tiers over a retail base scene, props, NPCs borrowed from retail placements, spawns, exits, trigger and encounter zones, and the entrances that lead in from the retail game.

cards.toml

Trading cards, the boosters they come in, the dealers that sell them and the rules that hand them out. Card, booster and dealer ids are the section's own (cards keep hyphenated ids such as mist-wisp, which save files key on), so the section checks them itself; it references formation and boss, and records each card's art and each monster reward source as a disc coordinate.

KeyMeaning
[[cards]]id, name, flavor, rarity (common / uncommon / rare / legendary), optional element, sides = [top, right, bottom, left] (each 1..=10), art (a DiscRef), optional sell
[sell_prices]Gold per sold card by rarity; a card's own sell overrides; omitted, a built-in table
starterCard ids handed over once, the first time the collection opens
[[boosters]]id, name, cards_per_pack, pool (empty = every card), weights by rarity, guaranteed, price (unpriced = never sold)
[[dealers]]id, name, stock (empty = every priced booster), buys_cards (default true), sell_percent (default 100)
[[rewards]]source, then booster and / or card, chance_percent (default 100) and count (default 1)
Reward sourceRolls when
{ kind = "monster", id = N }A won fight seated retail monster N, once per seat - any fight while the pack is active, retail fights included. A pack monster's base id is its key.
{ kind = "formation", id = F }The pack formation F was won.
{ kind = "boss", id = B }The pack boss B was beaten.
{ kind = "mode", id = M }The player won mode instance M.

The validator rejects a repeated or empty id, a side outside 1..=10, a pool, starter, reward or stock naming a card or booster that does not exist, all-zero weights, a reward with neither a booster nor a card, a chance over 100 and a mode source no modes.toml instance has; it warns on a dealer stocking an unpriced booster or paying over the sell value. The runtime is on game modes.

modes.toml

Instances of the port's own game modes: the pack picks the numbers and the layout, the rules live in legaia_engine_modes (game modes). Each instance has an id (what a kind = "mode" reward source names), a name and an optional location (DiscRef).

TableKeys (defaults)
[[poker]]seats (4, 2..=6), buy_in (1000), small_blind (10), big_blind (20), currency (gold / coins), chip_value (1), ai_tier, max_hands, blind_double_every
[[card_duels]]deck (five card ids), ai_tier, elemental_cells, chain, stakes, win_booster
[[golf]][[golf.holes]]: par (3), rows, heights, max_strokes (0 = par + 5)
[[races]]course (circuit / sprint), laps (3), points, half_width (64), boost_segments, pickups, racers (4), ai_tier, rivals (AI racers' names); on a map map, spawn, waypoints, bodies; across a kingdom overworld scene (map01..map03) with waypoints in its tiles
[[prop_hunt]]props, decoys, bounds, blockers, rounds (3), hunters (1), players (4), hide_frames, round_frames, hunter_health (100), miss_penalty (10), ai_tier

A golf hole is one character per tile: . fairway, , rough, s sand, w water, # wall, space out of bounds, T tee, O cup, and ^ v < > fairway sloping downhill that way. A race's pickups are { kind, at, respawn }: boost, hop, shield or slow, at a tile on a map or overworld race (world units on a board race), coming back respawn frames after it is taken (600 when 0). The validator checks the party nights, the duel tables and the races (one of map / scene, an overworld scene, enough points, no doubled point, the half-width, racer count, boost segments, waypoints and pickups on the map, and a warning for a pickup beyond a hop's reach); the rest of an instance is built when it is entered (legaia_engine_modes::active), and one whose data cannot make a session is refused there, the mode picker showing the reason.

The .lxpack bundle

The shareable form of a pack is one file: bundle::write_bundle archives a PackFiles tree and bundle::read_bundle restores it. The bundle adds no metadata of its own - the manifest is pack.toml, one of the files - and no timestamps, so the same tree always gives the same bytes. All integers are little-endian.

Header (16 bytes)

0x00magic LXPACK\x1A\n8 B
0x08version 1u16
0x0Aflags 0u16
0x0Centry countu32

Entry-table record (44 bytes + path)

+0x00path lenu16
+0x02methodu8
+0x030u8
+0x04raw lenu32
+0x08stored lenu32
+0x0CSHA-256 of raw bytes32 B
+0x2Cpath, UTF-8path len

The digest cell is drawn at a quarter of its width.

The whole file

0x00header16 B
0x10entry table, path ordercount x record
...data: stored bytes back to back, table order, no paddingsum of stored lens
end - 32trailer: SHA-256 of all before32 B
  • Method 0 is stored, 1 raw DEFLATE (RFC 1951). The writer deflates an entry only when that makes it smaller.
  • The reader trusts nothing first. It checks the magic, version, flags and trailer before the table, refuses unsorted or duplicate paths, unknown methods, a stored entry whose lengths differ and trailing bytes, inflates each entry to exactly its declared length, and checks each entry's digest.
  • Paths stay inside the output directory. Relative, /-separated, at most 255 bytes, no empty, . or .. component and none of \, : or NUL - on every host.
  • Limits. 16384 entries, 64 MiB per entry and 512 MiB in total once decompressed.
  • Error kinds. Every refusal carries a kind - not_a_bundle, corrupt, unsupported_version, too_large, bad_path - so a reader words it for a person. The version is read before the checksum, so a bundle from a newer writer says so.
  • Layout only. asset expansion bundle writes the files of the layout, everything the loader reads, so a bundle unpacks to exactly the editable tree and the workbench's import-then-export reproduces it byte for byte.

Validation

validate::validate_files checks a raw tree (paths, layout, parse errors) and then the parsed pack; validate::validate checks a parsed pack alone. Both return every issue rather than stopping at the first. An issue has a severity, its file, a dotted field path and a message. An error stops the engine loading the pack and stops asset expansion bundle; a warning does not.

RuleSeverity
pack.toml missing or unparsable, or any section or map unparsableerror
id, version, tags, requires or conflicts malformed; empty name; short_name empty, over 24 characters or more than one lineerror
schema outside the formats this build reads; disc other than SCUS-94254error
min_engine malformed or newer than this engine; a requires / conflicts entry or range malformederror
cover not a .png under assets/, absent, or over 1 MiBerror
A file anywhere named like a program, script or scriptable document (trust)error
A pack in its own requires / conflicts, or one pack in botherror
Content id malformed, pack-qualified at its definition, or defined twiceerror
Reference to undefined content, or to a pack not in requireserror
DiscRef out of range, or rejected by the supplied resolvererror
Any no-disc-bytes violationerror
Empty description, authors or license; no cover; non-URL homepage; a repeated list entry; a manifest key newer than the manifest's schemawarning
A file outside the layoutwarning

Adding a section's checks

A section implements validate::SectionRules, whose two methods default to doing nothing. Validation calls declare on every section first, then check on every section, then resolves references, so a quest may name a boss from battle.toml whatever order the sections run in.

  1. Declareevery section registers the ids it defines
  2. Checkevery section checks its fields, names what it references, queues its coordinates
  3. Resolvereferences against the declared ids; coordinates through the disc resolver
Checker methodUse
declare(kind, id, field)Pass 1: register a content id the section defines.
reference(kind, id, field)Pass 2: name content of kind, resolved after both passes.
disc_ref(field, &DiscRef)Pass 2: range-check a coordinate and queue it for the resolver.
error / warning(field, msg)Report against the current file.
pack(), qualified(id)Read the whole pack; form pack:id.

The battle, progression, quest, dialogue and card sections implement the trait in their own modules, and maps in map_rules; modes.toml alone keeps the empty impl in validate.rs, since its instances are checked when they are built.

The no-disc-bytes rule

Every file under assets/ must pass all four:

  1. Allowed typeextension png, gltf, glb, ogg, wav, txt, md or toml (any case)
  2. Its type's signaturethe PNG header, glTF version 2, OggS, RIFF....WAVE, or valid UTF-8 text with no NUL (a .gltf opens with {)
  3. Not shaped like a disc formatno TIM, Legaia or Sony TMD, VAB, SEQ, VAG, PS-X executable, raw-sector sync or ISO 9660 descriptor at its start, and no PS-X EXE header or sector sync anywhere
  4. Sizeat most 8 MiB each, 64 MiB for all assets together

Anywhere in the pack, a file named like a disc file is refused: PROT.DAT, DMY.DAT, SCUS_942.54, SYSTEM.CNF, CDNAME.TXT, a disc image (.bin, .iso, .cue, .img, .ccd, .sub, .mdf, .mds, .chd, .ecm, .pbp), streamed media (.str, .xa), or a retail format's extension (.dat, .exe, .tim, .tmd, .vab, .vag, .seq, .lzs, .man, .mes, .anm, .mdt, .dpk, .spk, .pch).

Packs are made to be shared, and the disc's bytes are not the project's to share. The type allowlist does most of the work: an LZS stream, a DATA_FIELD chunk or a headerless table has no PNG or Ogg signature, so it fails rule 2 even though it has no magic of its own for rule 3 to catch. The checks guard against accidents, not a proof; text a modder pastes in from the game is still the modder's responsibility.

Sharing and trust

A pack is downloaded from strangers and opened in a browser tab or beside a game, so its contents are data only. Nothing in a pack is executed or interpreted as code: the engine reads TOML into typed structures, decodes PNG, glTF, Ogg and WAV with its own decoders, and shows text as text. The format has no scripting language - a cutscene, a boss script or a quest is a fixed vocabulary of steps the engine's own kernels run - and no field is a URL the engine fetches.

share::import_bundle is the one gate a shared .lxpack passes through on every reader (the workbench's Open and drop, the play page's library, the launcher), in this order:

  1. Bundlemagic, bundle format, size limits, checksum, table, every path inside the pack directory - not_a_bundle, unsupported_version, too_large, corrupt, bad_path
  2. Trustno disc file, no program, script or scriptable document anywhere (.js, .html, .svg, .wasm, .exe, .sh, ...), every asset an allowed type with its signature and no disc bytes, the asset budgets - unsafe
  3. Formatthe pack-format and engine gates - newer_format, older_format, newer_engine
  4. Parsepack.toml and every section - unparsable

A pack that passes is opened; ordinary validation errors are left for the workbench to show and keep the engine from activating it. The play page re-reads its stored copy through the same gates on every activation. Packs are stored only in the viewer's own browser storage; nothing is uploaded.

The checks guard against accidents and obvious lures, not against every way to hide data inside a valid PNG or Ogg, and they cannot tell where pasted text came from. A pack's author answers for its content; the engine's part is that opening one can do nothing but add content to the game.

Save compatibility

The save's expansion block (quests and saves) records the pack version it was written under and names pack content by id. When a save made with one version loads under another, save_compat::reconcile runs as the progress is adopted for the active pack - on a load with the pack active, or when it activates after the load:

Saved entryWhen the pack no longer defines it
Quest, challenge, once-only bossDropped; a kept quest's objective progress is resized to its current objectives (new ones start at zero).
Pack-map locationDropped; the resume lands in the map's base scene.
Card, booster setDropped with every copy; the duel deck keeps the cards that remain.
Pack item held or worn, art, spellDropped; a worn slot falls empty.
Pack flags and countersKept: names, not definitions.

Nothing fails and nothing is invented. Both hosts show one toast (Saved with sample 0.1.0: 3 saved entries are gone from the pack and were dropped), the play page's expansion status repeats it, and the next save writes the reconciled progress under the new version.

Diff and changelog

diff::diff_packs(old, new) compares two versions through each file's TOML value, so comments and layout do not count. A top-level key holding a table of entries (monsters) or an array of tables with an id (quests, cards) is a collection: its entries are matched by key or id and reported added, removed or changed, a change listing each differing field as a dotted path with old and new values. Other keys are the file's settings; each map, asset and the manifest is an entry of its own.

The diff reads as semver: a removal is a major bump (it can break saves and dependent packs; below 1.0.0, a minor one), an addition minor, a change alone a patch. suggested_version applies it, and changelog drafts a Markdown section - Added, Changed, Removed - for the top of CHANGELOG.md. The workbench's Share panel and asset expansion diff both run it.

Tooling

asset expansion info                                   # schema, bundle version, layout
asset expansion validate packs/sample [--strict]      # a directory or an .lxpack
asset expansion bundle packs/sample -o sample.lxpack # refuses a pack with errors
asset expansion unbundle sample.lxpack -o out/        # out/ must be empty or absent
asset expansion info sample.lxpack [--json]
asset expansion diff old.lxpack packs/sample [--changelog | --json]
legaia-engine packs [--dir DIR] [--json]                # the installed packs
legaia-engine play-window --expansion sample           # an installed pack, by id
legaia-engine play-window --expansion packs/sample --map genesis_1f --expansion-watch  # reload on every save

validate exits non-zero on any error, and with --strict on any warning. None of these reads a disc. The native host's installed packs live in packs/ under its data directory: legaia-engine packs lists each with its version, authors, pack format and whether it plays, --expansion takes a pack id as well as a path, and the launcher's pack picker opens there. The same checks run in the browser on the expansion workbench, with the visitor's disc resolving every coordinate.

See also