Expansion packs: maps
A pack map is a walkable field scene a modder lays out tile by tile, with no disc bytes in the pack. It borrows its look from a retail base scene - that scene's texture pages, palettes and geometry pack stay on the player's disc and are named only by coordinate - and supplies everything that makes it a place: walls, floor heights, props, NPCs, spawns, exits, trigger zones and encounter zones. One file per map, maps/<id>.toml, the file stem its id.
At a glance
- File
maps/<id>.tomlin a pack (layout);maps/is flat- Schema
legaia_expansion_format::map(MapDef)- Validation
legaia_expansion_format::map_rules- Field image
legaia_engine_scene::expansion_mapbakes the tiles into a.MAP-shaped image- Entry
SceneHost::enter_expansion_map(engine-scene-host,scene_host/expansion_map.rs)- Runtime
- Zones and NPCs
legaia_engine_expansion::map; entranceslegaia_engine_expansion::entrance - Editor
- The expansion workbench's tile painter edits the file 1:1
- Sample
packs/sample/maps/genesis_1f.toml
File layout
| Key | Meaning |
|---|---|
name | Display name; shows as the place-name banner on entry. |
size | Optional [width, height] in tiles; must match tiles.rows. |
base | scene (a scene DiscRef) and geometry ("none" or "keep"). |
floor_heights | Up to 16 tier heights in world units, up positive. Empty takes the base scene's ladder. |
tiles | origin, rows, legend: the painter's grid. |
props | Placed meshes from the base scene's geometry pack; a prop with a top is standable. |
npcs | Field NPCs wearing a retail placement's model, saying pack lines. |
spawns | Named arrival points; the first is the default. |
exits | Tile rectangles leading to a retail scene or another pack map. |
triggers | Tile rectangles that raise a pack event (battle, dialogue, quest). |
encounters | Tile rectangles naming a pack encounter table. |
entrances | Tile rectangles in retail field scenes that lead into the map. |
bgm | A music DiscRef (sound-test id). |
camera | "default", "retail", "far" or "farther": the follow camera's distance presets. |
Every coordinate a map carries is a map-local tile [column, row], counted from the first character of the first row: the column grows with world +X, the row with world +Z. A rectangle is inclusive, [col0, row0, col1, row1]. The one exception is an entrance, whose rectangle is in the retail scene's own field grid (below).
The tile layer
rows is a list of strings, one character per tile, and legend maps each character to a style. A space is void - no floor, every quarter wall - unless the legend names it, and a row shorter than the widest is padded with void. That pair is the whole tile format, so a 2D painter reads and writes it 1:1: the palette is the legend and the canvas is the rows.
A worked example: a small walled room with a raised step, a half-walled tile and an opening in the east wall.
[tiles]
origin = [16, 16]
rows = [
"#######",
"#.....#",
"#.^^.h#",
"#.^^...",
"#######",
]
[tiles.legend]
"#" = { label = "wall", walls = "all" }
"." = { label = "floor", ground = { kind = "base_cell", cell = [8, 11] } }
"^" = { label = "step", height = 1 }
"h" = { walls = ["tl", "br"] } # two of the four quarters
[[spawns]]
id = "start"
tile = [1, 1]
[[exits]]
rect = [6, 3, 6, 3]
to = { kind = "retail", scene = { kind = "scene", name = "town01" } }
[[triggers]]
rect = [2, 2, 3, 3]
event = { kind = "dialog", line = "step_hint" }
[[encounters]]
rect = [4, 1, 5, 3]
table = "room_1"
64 x 64 quarters, so h walls only its top-left and bottom-right quarters; the opening at column 6 of row 3 is floor, which is where the exit sits.A legend style takes these keys:
"#" = { label = "wall", walls = "all" }
"h" = { walls = ["tl", "br"] }
"^" = { height = 1, ground = { kind = "texture", tpage = 28, clut = 32576, tile = 0 } }
"." = { ground = { kind = "base_cell", cell = [8, 11] } }
"d" = { ground = { kind = "mesh", index = 4 } }
"W" = { walls = "all", wall = { kind = "box", height = 160, tpage = 28, clut = 32576, tile = 2 } }
wallsis"none","all", or a list of quarters. A tile is four64 x 64sub-cells:tllow X / low Z,trhigh X / low Z,bllow X / high Z,brhigh X / high Z - top-left to bottom-right in the painter's view.heightis a tier, an index intofloor_heights.groundis what draws on the floor: atexture(one32 x 32cell of the8 x 8atlas on texture pagetpagethrough paletteclut, all in the base scene's VRAM), abase_cell(the ground texture of one of the base scene's own cells, in its field-grid coordinates), or amesh(a base-pack mesh stamped on the tile as a decoration cell). Nogrounddraws nothing.wallis what stands on the tile so it can be seen (visible walls). It draws only: collision stayswalls.
Tiers are corner heights
The retail floor is a vertex grid, not a set of flat tiles. The sampler FUN_80019278 reads a tile's tier as the height at its low-X / low-Z corner and interpolates across the cell from the four corner tiles' tiers (field locomotion); the ground mesh is built the same way. A cell is flat only where its four corners agree, so a raised block of N x N tiles is flat over its first (N-1) x (N-1) cells and slopes along its edges. The player walks the same surface the ground draws. Void tiles take the tier of a floor neighbour above or to the left, so the map's own edges stay level.
The base scene
The map is entered as an ordinary field entry of the base scene with a synthetic field layer laid over it. The base scene's VRAM, its geometry pack and its sound load as they would for the retail scene, which is what draws the map. Its MAN does not run: no entry script, NPCs, object binds, shops or encounter table of the base scene take part.
geometry | What draws | Use |
|---|---|---|
"keep" | The base scene's own placed objects, decoration cells and ground, under the map | A retail room re-purposed with new walls and zones |
"none" | Only the map's own ground and props | A new layout in the base scene's materials |
The synthetic field image
The engine bakes the tile layer into a .MAP-shaped image in the layout of the per-scene field map and seats it on the scene; every .MAP reader - collision grid, floor sampler, placements, decoration cells, ground - parses it unchanged. The image is the modder's layout run through the documented format: 0x12000 bytes of map plus the 0x800-byte fallback window after it.
To scale. The window is where a retail scene's .PCH walk-on triggers land.
| Region | What the map writes |
|---|---|
+0x04000 collision grid | Tile (c, r)'s tier in byte (c, r)'s low nibble; its wall quarters in byte (c, r + 1)'s high nibble. Everything off the map is wall. |
+0x00000 object records | One record per distinct ground texture, decoration mesh and prop. |
+0x08000 object cells | Each tile's record index; the draw gates are derived by the grid-mark refresh. |
+0x10000 trigger block | The empty four-table header chain. |
+0x12000 fallback window | Zeros: no .PCH triggers. Exits and zones run in the pack runtime instead. |
One grid byte, two tiles
The one-row shift of the wall nibble is the wall probe's +2 Z bias (field locomotion): the probe reads a point's wall bits from the grid row below the one its floor nibble sits in, so a wall painted on tile row r is stored in byte row r + 1. That is why a map keeps one free grid row below it: the origin row plus the map's height must stay under 127.
The draw gates come out of the record flags the way they do on a retail map. FUN_80017BEC mirrors flag bit 0x1 into cell bit 0x1000 (the ground heightfield's gate) and bit 0x2 into 0x2000 (the decoration / placed-object gate), and clears flag bit 0x1 when the record's +0x16 countdown is zero. On a ground record +0x16 is the palette word, which is never zero for a real palette, so a textured tile keeps its gate.
| Record | Flags | Fields |
|---|---|---|
| Ground texture | 0x0001 | +0x14 atlas cell, +0x15 tpage, +0x16 clut |
| Decoration mesh | 0x0012 | +0x10 pack mesh |
| Wall | 0x0012 (0x0013 on a textured tile) | +0x10 wall mesh, the tile's ground texture |
| Prop | 0x0016 (0x0017 on a textured tile) | +0x00..+0x0C offset and rotation, +0x10 pack mesh, the tile's ground texture |
A cell names one record, so a prop's record also carries the ground texture of the tile it stands on, and a tile holds at most one prop.
Props
[[props]]
mesh = { object = 7 } # base geometry pack mesh 7
tile = [2, 2]
offset = [0, 0, 0] # world units from the tile centre, y up
rotation = [0.0, 45.0, 0.0] # degrees; y is the yaw
scale = 0.75 # uniform; 1 when left out
solid = true # close the prop's tile
top = 64 # standable: a floor 64 units up
footprint = [128, 128] # the top's extent, world units
mesh takes four forms:
| Form | Mesh |
|---|---|
{ object = N } | Mesh N of the base scene's geometry pack. |
{ scene = "town01", object = N } | Mesh N of any field scene's pack (meshes from other scenes). |
{ tmd = { kind = "tmd", prot = P, index = N } } | Mesh N of the pack held in PROT entry P, whichever scene's block holds it. |
{ asset = "models/x.glb", colors = 256 } | A glTF model the pack carries (model assets). |
scale folds into the prop's model matrix after its rotation, where a retail placed object's render scale lands. Props carry no collision box of their own: solid closes the tile. animate and light are the prop's animators and light. A cell names one record, so a prop whose tile's cell already draws a mesh (a kept retail cell, a wall, a mesh ground) anchors its record on the nearest free tile within three, its offset carrying the draw back; only with none in reach does it replace the mesh, and the entry logs it.
The prop's record carries its anchor tile's ground texture (the style's, or on a kept map the texture the retail record drew), so a prop on a kept floor leaves no hole. Records 1..=3 stay unwritten, so the record layout stays where every built map already has it; retail draws a placed record on those ids like any other. A prop's height offset is from the floor the player walks on its tile: 0 stands it on the floor.
A kept room's floor
Many retail rooms raise no floor through the tier grid: their stairs, daises and terraces stand on cells flagged 0x800 whose kind-2 trigger records give the step. A kept room (geometry = "keep") carries those records into its image, so the player walks the retail floor, and props, decorations and decals on a flagged tile stand on the same step (KeptFloor). The workbench's map preview shows each tile's walk height (floor).
Standable props
A prop with a top is a floor box: a flat surface top world units up (the floor_heights convention) over its footprint, centred on the tile centre plus offset, the whole tile when the footprint is left out. It does not close its tile, so solid is unread.
The floor sampler (World::sample_field_floor_height, the FUN_80019278 port, and its camera-ladder sibling) answers the higher of the grid floor and the top inside the box. The tier grid is untouched: a top is not a corner height, so it neither slopes into its neighbours nor bends the ground mesh around it, which is why it is a separate surface rather than a raised tier. The prop's mesh draws on the tile's floor as before; matching top to the mesh's visible height is the modder's call.
Resting placement. on = "<id>" sets an item's lowest point on another prop's or decoration's top (a standable top, else its mesh's highest point), offset[1] lifting it from there; the engine resolves it from the meshes' vertices at map load (map_rest::rest_on). The resting check (map_rest::rest_issues) reports an item more than 10 units over the highest ground under its footprint that is not on the top of the support its footprint is most over: floating, hovering over or sunk into a top, or overhanging it. A flat card (a flame, a sprite tree) is read from its origin, not its lowest vertex. Every [[scatter]] mesh and [[edges]] run is judged over its placements and reported once at its worst. hangs = true on an item, a brush or a run passes it over: a lantern on a chain, sconces on a wall.
| Mover | On a standable prop |
|---|---|
| Action movement | Stands, walks across, climbs a rise up to its step height, jumps onto a taller top and lands on it, falls off its edge. |
| The retail walk | Refused at the edge of any top higher than the one it stands on: the walk climbs nothing. Walking off a top is not refused. |
| NPC patrols | Walk the sampled floor; route them around a top. |
NPCs
[[npcs]]
id = "tower_guide" # the quest talk key
model = { kind = "npc", scene = "tower", index = 3 }
tile = [7, 8]
facing = 0 # compass sector, 0..8
clip = 2 # optional idle clip id
patrol = [[9, 8], [9, 6]] # optional loop of tiles
walk_clip = 4 # optional clip a patrol leg plays
line = "tower_guide_hello" # a dialog.toml line
shop = "tower_dealer" # optional: a cards.toml dealer
An NPC is not an asset. A retail NPC is one record of its scene's MAN actor placement list, whose model byte indexes the scene's model pool and whose clip byte names a record of the scene's ANM bundle (ANM, script VM). A pack NPC borrows one by coordinate, { kind = "npc", scene, index }, the placement's partition-1 index - the slot the site's NPC page lists; 0 is the scene controller. Its model and idle clip come from that record; clip overrides the clip with another record of the same bundle (id = record + 1). The model scene must be the base scene - the mesh indexes the base scene's pool and samples its VRAM - unless the placement is a party member or save point (model byte 0xF0 and up), whose mesh is the resident global pool.
At entry the engine reads each NPC's placement off the disc (expansion_map::resolve_map_npcs) and seats it as an actor placement of its own, slot 1 + n for the map's nth NPC (map::npc_slot): the pack map runs no MAN, so nothing else holds those slots. From there the NPC is a field NPC on every path a retail one takes:
| Path | What reads the pack NPC |
|---|---|
| Draw | The live-scene placement list (npc_catalog::live_scene_placements, catalog_live_scene_npcs) both hosts build their NPC meshes and clips from, posed by the base scene's ANM bundle. |
| Collision | Its anchor in World::npcs.positions: the moving-actor box (FUN_801CFC40's moving arm, ±40 units) blocks the player as a village NPC does. |
| Talk | The facing probe (FUN_801CF9F4's port) picks it on Cross; World::trigger_field_interact turns it to the player and hands the talk to the map. |
| Patrol | A loop of scripted walk legs through the motion VM (World::start_field_npc_motion): tile, each waypoint, back, each leg cueing walk_clip the way a 4C 51 walk cues its move clip. A leg starts only while no dialogue holds the field. |
Talking to an NPC raises the quest event Talked(id) (World::expansion_talked), so a quest naming the NPC as giver or in a talk objective advances (quests), and runs its line through the pack dialogue runner (World::expansion_say). When the talk starts, completes or reminds a quest that has a line of its own, that line is the answer and the NPC's line is not said before it: one box per talk. An NPC with a shop opens that counter instead: an items.toml shop id the pack item shop (World::open_item_shop), a cards.toml [[dealers]] id the dealer's counter (World::open_card_dealer); an id that is both opens the item shop. It says its line only when the counter cannot open. An NPC with a cutscene plays it in place of its line, and one whose present gate is shut stays off the map for the visit (parked on retail's off-map hide box) until a cutscene's show step brings it on.
Visible walls
A legend style's wall draws on every tile of that style:
{ kind = "box", height, tpage, clut, tile }- a box the tile's size,heightworld units tall, each side and the top textured with one32 x 32atlas cell of the base scene's VRAM (the three words of atextureground). Faces taller than128units stack.{ kind = "mesh", index }- the base scene's geometry-pack meshindex.
The box is built when the map loads (expansion_map::wall_box_tmd): the pack carries the five numbers, and the engine runs them through the Legaia TMD encoder. The scene's resource build appends each distinct box to the base scene's geometry pack after the pack's own meshes, so box k is pack mesh geometry_len + k, where geometry_len is the pack's length as the field load's descriptor walk registers it (scene_resources::field_geometry_pack). Each wall tile is a decoration cell naming that mesh, which both hosts draw through the ordinary decoration-cell path.
A scene whose geometry the descriptor walk cannot enumerate has no length to append to, and a box on it is refused at entry. A tile holds one cell mesh, so a style cannot carry both a mesh ground and a wall.
Meshes from other scenes
A mesh from another scene's pack samples that scene's VRAM, which the map does not load. At entry the engine builds the source scene's field resources, takes the mesh and relocates what it samples (expansion_map::detail):
- For every texture page its primitives read, the bounding rectangle of their UVs; for every palette, its 16 or 256 entries.
- Each the map's VRAM already holds word for word stays put - the boot-resident system UI, a page two scenes share.
- The rest are copied into free VRAM (
VramAlloc) and the primitives' CLUT, page and UV words are rewritten to the copies.
Free VRAM is what the base scene's build leaves unwritten (its TIMs, the system UI, the effect-texture pool and the field character atlas count as written), minus the battle transition's capture pages (320 <= x < 640) and the dialog font's page. A texture rectangle stays inside one page and a palette starts on a 16-halfword boundary. The copies are written by the scene's resource build after its own uploads, so both hosts and every rebuild - a battle's return included - carry them, and the base scene's own words are never written.
Model assets
{ asset = "path" } names a glTF 2.0 file under the pack's assets/, converted at entry (expansion_map::gltf): the default scene's node tree is baked into the vertices, triangle primitives (POSITION, TEXCOORD_0, indices) become Ft3 primitives, and a material's PNG baseColorTexture is box-filtered to at most 256 x 256, median-cut to colors (16 or 256) and written into free VRAM as a 4bpp or 8bpp page. One glTF unit is 100 world units, +Y up; at most 16384 triangles. Skins, morph targets and animations are not read.
A model is always palettised: there is no full-colour path, which keeps it inside the scene's one shading law - PSX mode snaps and dithers it like the disc's meshes, clean mode draws the same texels without the snap. scripts/models/generate_ember_crystal.py writes the sample pack's crystal as a worked example.
Decorations
A tile holds one prop but any number of decorations. They draw only. When the map loads, every decoration of a tile is baked with the tile's own cell mesh into one mesh (tmd_ops::bake, each part's primitives byte for byte, only its vertices moved), drawn through the ordinary decoration-cell path on both hosts. Three sections paint them, expanding deterministically into one list:
[[decor]] # one at a time
mesh = { scene = "town01", object = 34 }
tile = [1, 9]
offset = [-30, 0, -36]
[[scatter]] # a seeded brush
rect = [1, 1, 13, 11]
on = ".,"
density = 0.45 # expected meshes a tile
seed = 1101
jitter = 44
scale = [0.5, 0.9]
meshes = [{ mesh = { scene = "rugi", object = 18 }, weight = 2 }, { mesh = { object = 27 } }]
[[edges]] # along wall edges
mesh = { scene = "rugi", object = 15 }
walls = "#"
every = 3
inset = 6
y = 120
A brush paints floor tiles, and a walled tile only when its on names that tile's character (a forest's trees on the thicket that walls a path in). A brush's draw for a tile is a pure function of its seed, its index and the tile; an edge copy is pushed against the walled side and turned to face the room. Limits: 8192 decorations a map, 32 a tile. A map decorated on more tiles than it has free records (of 508) bakes cells of c x c tiles onto one anchor tile each. Decorations on the tile a placed prop's record anchors on are left out, with a validator warning.
Organic placement
Scatter brushes jitter, turn, scale and tilt each mesh, and a cluster = { centre, radius, falloff } thins a brush from a dense centre to a sparse rim, and a clearance = { radius, front, keep } thins it round the walkable floor (below). Edge runs take jitter, yaw_jitter, scale_range and a seed; decal brushes jitter position, yaw and size.
Clearance
The follow camera stands on a map's -Z side (the top of the painter's grid) looking toward +Z, so a forest scattered on the thicket that walls a path in stands trunks and canopies between the camera and the path. A brush's clearance keeps them off: a band radius tiles wide to the sides and behind every walkable tile and front tiles deep toward the camera keeps keep of the brush's density, ramping back to full at half the band again past it. The West Voz Forest is laid out the same way. The camera-occlusion fade covers decorations like any field geometry, but it arms only when the player is completely hidden - a canopy that half covers them is what the clearance is for.
Decals
[[decals]]
cell = { tpage = 28, clut = 32577, tile = 4 }
rect = [1, 1, 6, 5] # a brush; or tile = [c, r]
density = 0.9
size = [90, 90]
sides = 9 # 4 = rectangle, 3..=16 a polygon
span = 1 # atlas cells a side
A decal is a flat textured polygon in the floor plane - crack, moss, stain, rune - built at load and baked into its tile's cell like any decoration. It also caps a mesh: the Sky Gardens Tower's pillar tops are authored as untextured black G4 faces (the tower's ceiling hides them from the retail camera), so the Atrium's pillars wear octagonal stone decals at their tops.
Animated props
animate = [{ kind = "spin", period = 300 }, { kind = "bob", amplitude = 10, period = 150, phase = 0.25 }]
# also: sway (axis, degrees), orbit (radius), pulse (amount)
Each animator is a pure function of World::frame; phase is a fraction of the period, and a prop's animators compose in list order. The pose reaches both hosts through the placed-draw tables they already fold in each frame - World::object_draw_turn_matrices and object_draw_displacements - under a record key of the prop's own (PROP_RECORD_BASE + n). Animation is draw only: collision and standable tops stay where the prop rests.
Disc animation clips
A disc-animated object is a scene actor in retail, a MAN placement with a clip. A pack map gets one as an [[npcs]] entry with scenery = true - drawn and clip-played by both hosts' NPC draw, with no anchor (nothing collides with it, nothing talks to it) - or by base = { ..., scenery = true }, which seats every animated, non-talking, non-door placement of the base scene's MAN where its header places it. A kept retail room otherwise stands still, since a pack map runs no MAN.
Lights
[lighting]
mood = "cave" # day, dusk, night, cave
lamps = [{ tile = [7, 6], offset = [0, 140, 0], color = [110, 160, 255], radius = 760, flicker = 0.1, period = 40 }]
[[props]]
light = { color = [255, 150, 70], radius = 560, flicker = 0.25, period = 18 }
# a slow, even breath instead of a flame's waver (a rootlight shrub)
lamps = [{ tile = [12, 9], offset = [0, 40, 0], color = [120, 255, 150], radius = 360, breathe = 0.6, breathe_period = 260 }]
Enhanced lighting lights a pack map as it lights a retail scene - from the emitter primitives of the meshes it draws, so a torch carried in from another scene lights its wall - plus the map's lamps and prop lights. mood stands in for the base scene's under the time of day Auto. flicker wavers a light like a flame; breathe swells and fades it evenly every breathe_period frames (default 240), each light at its own place in the breath, and its halo breathes with it. Both hosts read mood and lights through one kernel (engine-screens::field_frame). With enhanced lighting off the map renders retail-faithful: baked colours, no lamps, and no emitter marked with_lighting.
Animated materials
[[materials]]
kind = "cycle" # palette cycling: clut, start, count, period, reverse
[[materials]]
kind = "hsv" # hue / brightness: clut, start, count, period, hue, value
[[materials]]
kind = "tint" # standing recolour: clut, start, count, hue, value, color = [r, g, b]
[[materials]]
kind = "scroll" # texel scrolling: cell = { tpage, clut, tile }, dx, dy, period
A material rewrites words of the scene's VRAM each frame (World::step_expansion_materials, inside the field VRAM step both hosts run and re-upload after), from a capture of the words it first found, so every primitive sampling them animates. hsv runs the retail mode-3 HSV cell kernel; tint recolours entries once and holds them (a static hue turn and brightness shift through that kernel, then a per-channel multiply, 128 unchanged), so a kept room's art takes the map's mood; scroll rotates an atlas cell by whole texels - texture flowing over still geometry; a floor that moves is a ground motion. A material with from = "scene" names a palette or cell in that scene's VRAM, resolved at load to the copy made for a mesh carried in from it (detail::resolve_materials) - so a scrolling water or cycling lava texture can come from another scene.
Living ground
[[ground_motion]]
kind = "swell" # breathe, swell or ripple
chars = "LlQ" # legend characters whose tiles move
amplitude = 10 # world units either side of rest
period = 150 # frames a cycle
phases = 4 # phase steps: ladder rungs per tier
wavelength = 4.0 # tiles crest to crest
angle = 0.0 # swell direction, 0 = +Z; a ripple takes centre = [x, z]
A living floor moves its geometry, not its texture - the way retail animates its own. jou's organic floor, jouina's pulsing path and concnow's flesh pits ride the scene's floor-height ladder, which field-VM op 4C 90 sets oscillating one rung at a time; the ground pass takes every ground vertex's corner tile through the live ladder each frame, the decoration sweep lifts decoration cells by it, and the floor sampler reads it, so what moves is drawn and walked as one shape (menu-control nibble 9).
A ground motion takes free rungs of the sixteen - one per phase step for every tier its characters stand on, each resting at that tier's height - writes its tiles' collision nibbles as those rungs, and every tick the world sets them to rest plus the wave (ActiveMap::ground_motion_ladder). Both hosts already redraw the ground and decoration cells from the live ladder, so neither draws anything new. breathe moves every tile together, swell rolls crests along angle, ripple spreads rings from centre; a step lags the one before by period / phases frames. The wave is the closed form of retail's rung oscillator FUN_801DA930 (constant acceleration toward rest, no damping: parabolic arcs), integer arithmetic on World::frame.
As in retail, the party rides a walkable moving floor (the validator warns past 24 units), a still tile beside a moving one leans toward it, and placed props stand still. A terrain map, or one without its own floor_heights, takes none. The sample pack's Atrium dais ripples out from the Warden's Stone and the Ember Stair's lava heaves; scroll stays for texture flowing over still geometry - the terrain's water - as retail's own water is a VRAM animation.
Fog
[fog]
regions = [{ rect = [1, 1, 13, 3], angle = 90.0, spread = 40.0, speed = 18 }]
Arms the retail field fog pool over the map: each region becomes a MAN-section-4 region record and the ambient particle gate goes up (World::arm_expansion_fog). The wisps are the pool's own, drawn by both hosts through World::fog_render_step; the pool draws from the world's random stream as retail's does.
Surround
[surround]
ground = { tpage = 5, clut = 30720, tile = 0 } # the drop's floor, one cell a tile
side = { tpage = 11, clut = 32523, tile = 38 } # the floor edge's face down to it
depth = 448 # world units below tier 0
reach = 8 # tiles past the map's edges
shade = 72 # vertex colour, 128 = the texel as is
backdrop = { tpage = 11, clut = 32523, tile = 38, height = 1152, distance = 2, sides = "esw", shade = 96, fade = 0 }
A map with no base geometry draws nothing past its floor, which a follow camera at its edge frames as a black void. The surround lays a flat ground depth below tier 0 on every void tile and up to reach tiles past the map, with side a face from the floor's edge down to it, so the floor reads as a ledge. backdrop stands a wall distance tiles past the map along its sides, rising to height and darkening over its top two fifths to fade: the far side of a hall the camera looks over. The default "esw" leaves the camera's -Z side open. Both are drawn, never walked, through the decoration-cell path (expansion_map::surround).
Ceiling
[ceiling]
height = 2048 # world units above tier 0
cell = { tpage = 11, clut = 32517, tile = 27 } # the underside, one cell a tile
shade = 48
chain = { tpage = 11, clut = 32517, tile = 27, width = 6, shade = 64, items = ["lantern_hall"] }
A room built from scratch has no ceiling, so a lantern marked hangs hangs from nothing. chain gives every hung prop and decoration named in items (every one when items is empty) a square shaft width units across from the item's top up to height; items keeps chains off wall-mounted items such as banners. The chain does not follow a prop's sway.
cell draws a flat underside over the map's rectangle at height, wound to face down, so only a camera below it sees it. The follow camera looks down from above the walls, so it never sees the underside, and the camera-occlusion gate leaves out faces seen only from beneath. Both go in through the decoration bake and draw on both hosts. The validator holds height to 1..=4096, the atlas cells to 0..64 and width to 1..=64, and errors on an items id that names no hung prop or decoration.
Particles
[[emitters]]
kind = "embers" # embers, sparkles, dust, leaves, drips, smoke
rect = [3, 4, 3, 4]
height = [40, 90]
count = 14
seed = 11
# optional over the kind's preset: life, velocity, spread, gravity, sway,
# size, color, color_end, additive
with_lighting = false # true: part of the enhanced-lighting layer, drawn only with it on
Each particle is a camera-facing sprite drawn through the field screen-effect pass the retail fog sheets use (HostView::fog_prims, both hosts), projected by the same view. Its whole life is a pure function of the seed, its index and World::frame - no state, no random stream - so it is the same on both hosts and in every run. The sprites are the engine's own (a soft dot, a spark, a leaf, a drop, drawn at load into free VRAM), additive for the glowing kinds, opaque for leaves.
Light beams
[[beams]]
tile = [30, 22] # the tile the shaft lands on
offset = [0, 0, 0] # from the tile centre on its floor, y up
height = 900 # length from the foot up
tilt = 12.0 # degrees from vertical
angle = 200.0 # heading the top leans toward, 0 = +Z
width = [80, 150] # at the top, at the foot
color = [255, 236, 190]
intensity = 80 # peak additive brightness
profile = "fall" # fall (from above) or rise (a glow from below)
pool = true
breathe = 0.15
sway = 1.5
period = 480
motes = 10
A soft shaft of light falling through a gap, with dust drifting in it. It is an enhancement: both hosts draw it only with enhanced lighting on (HostView::beam_prims), and with it off nothing of it draws, so the faithful frame is untouched. The shaft is a camera-facing ribbon of additive Gouraud quads through the field screen-effect pass - dark at both edges, shaped along its length by profile - each corner carrying its axis point's scene depth, so the scene in front hides it. A falling beam can pool light on the floor at its foot; the motes are the particle sheet's soft dot.
The lean, the breath and every mote are pure functions of the frame, the same on both hosts and invisible to a replay. A negative offset[1] sinks the foot into a pit: with profile = "rise" it is a glow far below. A beam costs a few dozen screen quads and no VRAM of its own.
Pits and water
[surround]
ground = { tpage = 5, clut = 30720, tile = 0 }
side = { tpage = 11, clut = 32523, tile = 38 }
depth = 448
fade = 20 # the faces darken to this at the drop
[[surround.regions]] # void tiles inside rect take this drop instead
rect = [10, 6, 18, 14]
depth = 1600
fade = 4
A void tile inside the map is a wall for every mover and draws the surround's drop, so a walkway over a pit is walkway tiles with void tiles beside them: collision stops the player at the edge and the walk height is the walkway's own tier. fade shades the edge faces from shade at their top down to fade at the drop, so a pit's walls go down into the dark; a region gives the void tiles of a rectangle a drop of their own (its ground, side and shade optional over the surround's), the first region holding a tile winning.
Water round a jetty or a stilt platform is a shallow region with an animated water cell for its ground. On a terrain map, a standable prop's top over the water keeps it from walling the deck's footprint, and abyss darkens a sculpted pit with depth.
Terrain
[terrain]
resolution = 4 # vertices a tile side: 1, 2 or 4
smooth = 2 # blur passes over the tier base
max_step = 40 # height a 64 x 64 sub-cell may span and stay walkable
skirt = 8 # tiles drawn past each edge
shapes = [
{ kind = "ridge", from = [14.0, 9.0], to = [27.0, 6.5], radius = 2.6, height = 30 },
{ kind = "plateau", rect = [40, 18, 41, 33], height = 22, edge = 0.18 },
{ kind = "flatten", at = [9.5, 9.5], radius = 2.6 },
]
materials = [{ name = "sand", chars = ".", tint = [134, 130, 122], cells = [{ tpage = 12, clut = 32005, tile = 3 }] }]
cliff = { slope = 0.95, cells = [{ tpage = 12, clut = 32006, tile = 6 }] }
water = { level = -10, cells = [{ tpage = 9, clut = 31880, tile = 56 }], foam = 16, wade = 5, surf = 150 }
sky = { tpage = 10, clut = 31104, radius = 6 }
A map with [terrain] floors on a heightfield instead of the tier grid: resolution vertices a tile side. The tiers are its base (smoothed into slopes), and the shapes sculpt it in order - raise, ridge, plateau, ramp, flatten (to a height, or the mean under the brush), smooth, noise, stroke (a brush dragged along a path - what the workbench's 3D sculpt brush writes) and heightmap (a grayscale PNG under assets/, black to white over a height range). It is a pure function of the pack.
Walking the terrain
The floor sampler answers the terrain's surface, interpolated over the very triangle the mesh draws, so the player stands on the drawn ground to the unit. Tiers are written 0; props, decorations, NPCs, lamps and emitters stand on the surface. A sub-cell steeper than max_step, or deeper than wade under the water, is a wall quarter: slopes walk, cliffs and deep water block.
Drawing the terrain
Each tile's quads bake into its decoration cell, so both hosts draw it on the path they already run. Ground quads take the material with the highest weight at their centre after a dithering noise (blend), so material boundaries are organic, not the tile grid; each tile turns its cell one of eight ways against repeats; steep quads take the cliff material. Vertex colours carry the material tints across boundaries, a macro brightness variation and a baked sun, under the retail field's own texel * colour / 128 law. Water is a flat textured surface coloured by depth, meeting the slope in the depth buffer; an additive foam band rolls in every surf frames; sky rings the map with a retail sky page.
Detail. With detail = 2 or 4 a quad on a material boundary is cut into finer sub-quads lying on its own two triangles, so a boundary steps every 16 or 8 units instead of 32 while the surface the player walks stays the same. detail_reach keeps that detail, and the soft blends, to the tiles within that many of the walkable floor: ground a forest or a cliff band walls off is seen only through what stands on it, where the finer boundary is triangles nobody sees (0 draws every boundary in full). Borrowed textures: a material, the cliff or the water with from = "<scene>" names that retail scene's VRAM; the map copies each cell and its palette into free VRAM at entry, so a cave can paint with another scene's rock. Decals drape over the slope under them.
How large a map can be
One map is one field grid: at most 120 x 120 tiles of 128 world units, about 15,000 units a side - larger than any retail room. Two budgets bound what it holds: 508 object records (ground textures, walls, props and decorated cells share them) and the VRAM the base scene leaves free for foreign meshes and model textures. A larger world is several maps joined by map exits, which enter at once with no streaming.
Validation
The validator (expansion-pack validation) checks each map against the sections it names, with or without a disc:
| Check | Severity |
|---|---|
tiles resolve (legend, sizes, tiers); at most 16 floor tiers | error |
| Every spawn, exit, zone, prop, NPC tile and patrol waypoint lies on the grid | error |
| The map has a spawn; spawn, exit, trigger, encounter and prop ids are unique in the map | error |
| NPC ids are unique across the pack | error |
A trigger's battle / boss / dialog / start_quest / card_dealer names a defined formation, boss, line, quest or dealer | error |
An NPC's line and shop name a defined line and dealer | error |
An encounter zone's table is a battle.toml [[encounters]] scene | error |
... whose table has no steps (it can never roll on a map) | warning |
A talk trigger's NPC id that no quest gives from or talks to | warning |
| An exit to another map names a map of the pack and a spawn it has | error |
Facings are sectors 0..8; a retail exit's tile is on the 128 x 128 grid | error |
| Disc refs: the base scene, retail exit scenes, NPC models, BGM, prop TMDs (range rules, then the disc when one is loaded) | error |
One prop and one NPC a tile; a style with both a mesh ground and a wall | error |
Wall box height 1..=512; standable footprint 1..=1024 a side | error |
| An NPC model from a scene other than the base scene | warning |
| A spawn, NPC or waypoint on a void or fully walled tile; an NPC on a prop's tile | warning |
With a disc: a geometry-pack index ({ object = N } props, mesh grounds and walls) past the base scene's pack | error |
Exits, triggers and encounter zones
Zones fire on entry - stepping onto a tile of the rectangle from outside it - the way a retail walk-on trigger fires on a tile crossing. Standing still fires nothing, and the spawn tile does not fire on arrival.
Exits
to = { kind = "retail", scene = { kind = "scene", name = "town01" }, tile = [20, 44], facing = 4 }raises the named scene change a door's op0x3Fraises, so it parks behind the streaming hold and arrives on that field-grid tile facing that compass sector. With notilethe player lands where the scene's own doors land them.to = { kind = "map", map = "...", spawn = "..." }enters another map of the same pack at once.
Triggers
A trigger's event names content of another pack section, and the scene host hands it to that section's runtime the tick it fires:
event | Runs |
|---|---|
{ kind = "battle", formation = F } | World::start_expansion_battle(F) - a battle.toml formation (battles) |
{ kind = "boss", boss = B } | World::start_expansion_boss(B) - a battle.toml boss |
{ kind = "dialog", line = L } | World::expansion_say(L) - a dialog.toml line |
{ kind = "toast", line = L } | Shows line L's first page as one toast row - a cue that takes no input and stops nothing |
{ kind = "talk", npc = N } | World::expansion_talked(N) - a quest's talk objective |
{ kind = "start_quest", quest = Q } | World::expansion_start_quest(Q) |
{ kind = "set_flag", flag = X } | Raises pack flag X |
{ kind = "counter", counter = C, add = 1 } | Adds to pack counter C |
{ kind = "card_dealer", dealer = D } | Opens the cards.toml dealer D's counter (World::open_card_dealer; empty = the first dealer) |
{ kind = "shop", shop = S } | Opens the items.toml shop S's counter (World::open_item_shop; empty = the first shop) |
{ kind = "cutscene", cutscene = C } | Plays the cutscenes.toml cutscene C (cutscenes) |
{ kind = "sound", sound = S } | Plays a retail cue or a pack sound (audio) |
A map's [[enter_cutscenes]] (cutscene, requires, repeat) plays on arrival: the first entry whose gate holds and whose cutscene is unseen, or any with repeat = true. While a cutscene runs, the map's zones and exits fire nothing.
once = true fires a trigger once ever: firing raises the pack flag map.<map id>.<trigger id> (map.<map id>.<index> for a trigger with no id), which saves with the pack's progress, and a trigger whose flag is up stays quiet on every later visit. Quests can read the flag like any other.
press = true makes a trigger something the player examines: walking onto it fires nothing, and the action button fires it while the player stands on it or faces it from the next tile. Use it for objects (a brazier, a bell, a notice board) and quest givers that are not NPCs, and keep walk-on triggers for what must happen as the player arrives. A line the player did not ask for is best a toast.
Encounter zones
A zone's table names a battle.toml [[encounters]] entry by its scene key - a free string, so a pack map's tables sit beside the retail scenes' overrides:
# battle.toml
[[encounters]]
scene = "tower_1f"
steps = [12, 24]
formations = [{ formation = "ogre_pack", weight = 2 }, { formation = "forge_guard", weight = 1 }]
On a map, the pack's encounter lookup keys on the table of the zone under the player instead of the scene label, so the pack's own step counter and formation pick - the same roll a retail scene override uses - run while the player walks inside a zone. Outside every zone the map rolls nothing; it never rolls the base scene's retail encounters. A table needs steps to roll on a map: with no retail rate to borrow, a rate-less entry has nothing to fight on.
Entrances
An entrance is the way into a map from the retail game: a rectangle of field-grid tiles in a retail scene that enters the map when the player steps onto it. It lives on the map it leads into.
[[entrances]]
id = "rim_elm_mist_stair"
scene = { kind = "scene", name = "town01" }
rect = [17, 43, 18, 43]
spawn = "entrance"
requires = { min_level = 30 }
| Key | Meaning |
|---|---|
id | Name, for logs. |
scene | The retail scene (a scene DiscRef). |
rect | Inclusive [x0, z0, x1, z1] in the scene's 128 x 128 field grid, 0..=127 per axis. |
spawn | The spawn of this map the player arrives at; the first when empty. |
requires | The quest prerequisite shape: story_flags, story_flags_clear, flags, quests, min_level. Empty is always open. |
Coordinates here are retail field-grid tiles, not map-local ones: tile x covers world x * 128 .. x * 128 + 127, the world >> 7 quantisation the retail walk-on trigger dispatcher uses (field locomotion). An exit's arrival tile is in the same space, so a map's exit can land beside its own entrance.
SceneHost::enter_expansion_map. Any scene change out of the map drops its layer; a battle fought on the map returns to it.An entrance fires the way a zone does: on a step onto its rectangle from outside it, while the gate holds, in a plain field tick the player walks freely - no pack map, no scene change parked, no dialogue or cutscene holding the player. Any other tick forgets the last tile, so arriving on an entrance (a door, a battle return, a Load) does not fire it. A shut entrance stays shut until the player steps off and back on after it opens. World::step_expansion_entrance holds the rule and SceneHost enters the map it names (scene_host/expansion_entrance.rs); the kernel is legaia_engine_expansion::entrance::EntranceWatch.
Picking a rectangle
Pick tiles the scene's walk reaches and that no retail trigger shares: a retail door's walk-on tile fires its own record on the same step. The validator checks the scene coordinate, the tile range, the spawn and the quests requires names; it cannot see the scene's walls without the disc.
The pause menu's Travel page (the Journal, Select on the root list) enters a pack map the party has been to on this save directly, and lists a map whose entrance gate holds now before its first visit; the Unlock all Travel maps cheat (--cheat-travel, the play page's Cheats panel) lists every map, which is the way in for testing a map whose entrance is out of reach (field menu).
Saving on a map
A save made on a map writes the map id, the player's world position and facing as tail record 2 of the pack's expansion block (LXP1, saves); a save made off every map writes no such record, so its block is the one it was before. The save's resume scene is the base scene, so a Load lands there first; BootSession::resume_save - the native window's resume and the browser page's play_resume_save alike - then re-enters the map with the player seated where the save was made. Without the pack active the save lands in the base scene.
Running a map
legaia-engine play-window --disc "Legend of Legaia (USA).bin" --expansion packs/sample --map genesis_1f [--spawn dais]--expansion takes a pack directory or an .lxpack bundle and activates it through World::activate_expansion; with --resume-save the save's own map (if any) is the landing instead of --map. Without --map the pack is simply active, for the title's New Game and Continue.
The browser play page loads an .lxpack through LegaiaRuntime::load_expansion and enters a map through enter_expansion_map, the same SceneHost::enter_expansion_map entry; its scene build reads the map's ground, walls and props off the same Scene (browser host). Any scene change - an exit, a scene picker - leaves the map and drops its layer; a battle fought on the map returns to it. The map's name shows as the place-name banner on entry. Step-by-step: expansion packs guide.
Gaps
- Camera preset on an in-world entry. A map entered through an entrance or a Travel pick keeps the follow camera's distance; the map's
camerapreset applies on a host's direct entry (--map, a pack map row in the play page's scene list). - NPC clips. A patrolling NPC keeps
walk_clip(or its idle clip) at the ends of its legs; nothing cues the idle clip back between legs.