Files
llamamod/notes/editor-cross-import.md

11 KiB
Raw Permalink Blame History

Level editor – importing object types from other levels ("All worlds")

Goal (owner): import objects of all worlds into the current level, so a fresh map does not start from scratch. Packages: format.stpcmerge (STPC analysis + merge, no editor dependency) and editor.tools.catalog (CatalogEditorPlugin: catalogue, asset tab, drag & drop, bulk import, project integration). Tests: StpcMergeTest, CrossImportTest (need -Dllamamod.cd).

All worlds

STPC layout (exact for all 33 level WADs)

u32 nModels, nModels × (0x8c header + data)        loader FUN_0041f770, one call per model
u32 nAnims,  nAnims × animation set                FUN_0041f8b0
[WFPC & 0x100: model block – used by no CD level]
code region: u32 nUnits, nUnits × {u32 nWords, nWords × u32}     ← new finding

The code region is a list of units, one per compiled strat source file (village: 53, lab 1: 65); the chain ends exactly at the chunk end in every level. A unit is self-contained: name strings, inline data, handler tables and code of one or more strats (e.g. SinkingStatuePad + SinkingStatue, Spider + RopeForSpider). The loader never parses units; MAP strat records and the bytecode address them by absolute STPC offsets. Other references to STPC: MAP zone field stpcOffset (always a model header: sky models) – nothing else (DAT_006d9dbc is used only by the zone loader, the strat record loader and opcode 0xb2).

Strat VM (FUN_0054d180)

u32 words, low 16 bits = opcode. Opcodes < 0x45: table 0x5796bc, the signed high 16 bits are the argument. Opcodes 0x45..0x1b1: table 0x578ff4, operands inline (0x1b2+ alias the short table and never occur). Operand counts were measured by emulating all 435 handlers of groove.exe (unicorn, fake strat struct, IP into a sentinel buffer, several operand/stack fillings) and checking the variable ones by hand (StratOps):

Op Operands / meaning
0x0c a, slot: event handler slot; entry {type, param, rel} at pc+8+a·4, handler = pc+8+rel·4 (= the MAP "slots")
0x10/0x11 call next+arg·4 (pushes the return address); 0x12–0x15 conditional, 0x16 jump, 0x2b jump+yield
0x17 switch: table at next+arg·4+4: n, n × {case (0x44 short / 0x45 imm), rel}
0x02/0x03 skip list: n, then n items (2 words for 0x45/0xea)
0x05/0x09 push word / address at next+arg·4 (inline data)
0x45, 0xd7 push immediate (0xd7 = sound sample id)
0xb2 push STPC base + operand (≥ 0: absolute STPC offset of a model, animation set, unit code or data; < 0: DEFANIM.WAD animation); self-patches to 0x45 at run time
0xb3 jump to popped address (return)
0xd4 0xea rel strat name
0xe0 spawn: pops code address, 5 inline type-field words
0xf0, 0xfa 1 operand: text index (LGPC)
0x94/0x95 5/6 inline words; 0xa5 0xa6 0xb1 0xd0 0xd3 0xdb 0x115 0x11c 0x14b 0x14d 0x162 0x167 0x175: 1 word
0xb4, 0x8e, 0x8c end/kill; with 0x16/0x2b/0xb3 the terminators
0xd5/0x99/0x131 play / stop / is-playing sound (id from the stack; bit 0x80 = front-end sound)
0x5a, 0xde, 0x187, 0x1a9 take a TEXT texture index from the stack (sprite/shadow at strat+0x128, particles, screen effect); the compiler pushes it with 0x45 right before
0x11a/0x11b swap the alternative tile models of the strat's cell

Disassembler (StratCode): recursive descent from MAP entries, name sites, 0xb2 code targets, switch/handler tables; then code after terminators is decoded tentatively and kept if clean (unplaced spawn stubs). Result on all levels: no decode errors, ~92 % of the code region covered (rest: strings, inline data), and no word 0xb2 outside a decoded instruction – the relocation set is complete.

Level-dependent operands were confirmed by diffing the same unit across levels (units equal modulo operands): every differing word is a 0xb2 operand, a 0xd7 sound id, a 0xf0/0xfa text index or a 0x45 texture index before a texture op – nothing else.

Merge (StpcMerge, TextMerge, SoundMerge, DialogMerge)

New STPC = [target models][imported models][target anims][imported anims][target units][imported units]. Models must stay in the model block (the loader turns their offsets and face texture indices into pointers), so appending is only possible at the end of each block: target model offsets stay; target animation offsets move by the imported model bytes, target code offsets by models + anims. Every absolute reference of the target is relocated with that piecewise map: 0xb2 operands (from the disassembly) and MAP strat codes (zones point at models and stay).

Per import request (source level + MAP entry):

  1. Closure: the entry's unit, then transitively every unit, model and animation set reached by its 0xb2 operands (sub-strats: shards, projectiles, effects, heads …), plus sound ids, texture indices (code constants + model faces) and text indices of the copied units.
  2. Units identical to a target unit (bytes with the level-dependent operands masked) are not copied but mapped onto the target's unit (Coin, EnemyCoin, Sparkles …); models identical after texture remapping and identical animation sets are shared too.
  3. Textures: needed entries (+ flip-book frames of TEXT animations). Pages identical to a target page are reused; mostly used pages are copied whole; otherwise the used rectangles (+1 px border) are packed into new 256×256 pages per page class. Entries identical to a target entry are reused.
  4. Sounds: SMPC samples appended (identical ones reused); 0xd7 operands remapped.
  5. Texts: LGPC groups (language strings + speech slot) appended, 0xf0/0xfa operands remapped; a speech slot #hex is an SRPC index – SRPC entries (offset/size into the global Music/<lang>.CVS) are copied and the slot rewritten (# + upper-case hex, FUN_0040d890 reads only upper case).
  6. Write, relocate imported 0xb2 operands (source → new offsets), then verify: disassemble the merged STPC again; the set of 0xb2 sites and their values must equal the expected relocation exactly and every value must hit a model header, animation set or unit.

Deterministic: same inputs → same bytes (test). All 925 (level, type) entries of the CD merge into the village and verify (test everyPlacedTypeMergesIntoTheVillage).

Engine limits

Limit Value Source
level memory 16 MB block for STPC, TEXT (compressed pages + 20 B/entry), TRAK, MAP, LGPC, strat pool FUN_0041eea0 / FUN_0041ef00 (returns 0 when full)
sound samples 128 (id is a signed char, bit 0x80 = front end) FUN_00548760
texture pages 127 (signed byte page index) TEXT entry
texture entries 65535 (u16 face field)
strat pool poolExtra + 30 slots MAP
The editor estimates level memory from the scene (CrossImport.limits); originals use ≤ 7 MB. The
import preview and Build check report it (errors above the hard limits, warnings near them).

Editor integration

  • Project file: level.json key importedTypes: [{"types":[{"level":"t1l2m001","type":"Spider","code":…}]}, …] (batches in order). No game data. ProjectIO load hook (ProjectIO.addLoadHook, registered by CrossImport) merges every batch into both the reference scene and the current scene before the stored changes are applied, so the diff still only contains the user's edits; instances of imported types are ordinary added strats. Builds (editor, Play, --editor-build, LlamaMod level mods) use the merged scene → deterministic.
  • At run time an import merges into reference + scene (strat codes, STPC/SMPC/LGPC/SRPC chunks, new texture pages/entries/anims inserted after the reference ones – user textures behind them are renumbered, catalog with remapped codes + the imported variants). Code offsets move, so the undo history is cleared and replaced by a permanent "Import …" entry. Imports cannot be removed.
  • Catalogue (ObjectCatalog): every placed type of every level (906 types, 33 levels, ~3 s): name, level/world/chapter, refined category (enemies, collectibles, mechanisms, characters, animals, effects …), instance count, parameters, dependency counts/size, thumbnail (software rasteriser, posed skinned models), importable + reasons/notes. Not importable: player starts, cameras and cut scenes (126); notes: texts copied, tile swaps, waypoint chains. Cached in <data>/editor/catalog/ (catalog.json + thumbs/), keyed by the CD's WAD sizes.
  • UI: Assets → All worlds (search, world/category filters, "Importable only", ✓ for imported types, tooltips with dependencies). Drag into the viewport = import (background) + place at the drop point; double-click = view centre; context menu: import only, import all of that level. Import world… (Level menu, Ctrl+Shift+I, tab button): all types of a world or level with checkboxes, sizes, and Preview (exact merge result vs. limits). Inspector: "Imported object – from …". Level check: limits. Imported types then also appear in Assets → Objects like the base's own.

drag & drop import world

Verified in game (apptest, display :6, 2026-11-07)

  • New level on the village base (flat floor, chapter 5): Spider (Jungle at Night 1) drops on its rope when the llama comes near (xobj_game_spider.jpg); Grapes (City 2) render and are collected (fruit counter); CitySwingingBlade (City 2) and MerryGoRound (Mountain 1) render; Bucky (Jungle 1, dragged in the editor, with texts and sounds) runs around and throws nuts (xobj_game_bucky.jpg). Plus the whole of Jungle at Night via Import world… (28 types, +1.2 MB, 44/127 pages, 98/128 sounds): builds and loads, no crash.
  • Edited original village (t1l1m001 + Spider, 2 Grapes, JumpingBeetleBlue (jungle), CitySwingingBlade): intro dialogue still shows the village's own texts, the blade swings, beetle and grapes are there (xobj_game_village_*.jpg), no crash.
  • Sounds are verified only structurally (merged bank, remapped ids; the test sessions are muted).

Known limits

  • Objects that belong to level geometry (tile swaps, doors in walls, river/roller-coaster specials) import fine but need matching geometry; waypoint chains are copied relative to the object.
  • Parameters are those of the source's first instance (edit them in the inspector).
  • A type whose code is identical to the target's (e.g. Coin) is shared, i.e. it keeps the target's model/textures.
  • Level-memory figure is an estimate (sum of the loaded chunks), not measured at run time.
  • Imports cannot be undone or removed from a project (re-create the level or edit level.json).