11 KiB
Level editor core – API (editor.model, editor.model.cmd, editor.io)
Status: phase 1 done (core, no UI): src/main/java/com/eliasmueller/llamamod/editor/{model,model/cmd,io},
LlamaMod integration in mods/levels (notes/mod-levels.md). Tests: src/test/java/.../editor/io.
Project file format: notes/editor-format.md. Design brief: notes/editor-design.md.
Quick start
byte[] wad = Files.readAllBytes(cd.resolve("wads/t1l1m001.wad"));
Scene s = WadImporter.importWad(wad, "t1l1m001", rel -> read(cd.resolve(rel))); // original level
History h = new History(s); // undo/redo
s.addListener(e -> viewport.repaint()); // change events
h.execute(Edits.translate(List.of(strat), 1.0, 0, 0, null)); // move 1 cell east
h.undo();
byte[] out = WadBuilder.build(s); // Scene → WAD (byte-identical if unedited)
Bases bases = Bases.fromCd(cd); // original CD folder
Project p = ProjectIO.load(path, bases); // .llamalevel (p.scene(), p.warnings())
ProjectIO.save(p, path, bases); // stores only the changes
Project n = Project.create(bases, "new", "t1l1m001"); // empty level on the village base
Project e = Project.create(bases, "edit", "t1l3m001"); // edit an original level
Coordinate spaces (important for rendering)
| Space | Used by | Axes |
|---|---|---|
| export space | TileModel vertices (local), Tile.x/y/z (cells, z ≤ 0), Light x/y/z |
right-handed, y up; world vertex = tile.transform().point(vx, vy, vz) |
| game space, 20.12 fixed point (4096 = 1 cell) | Strat, PathNode, EnvZone target, collision records |
x east, y up, z = grid row (positive) = −export z |
| game space, cells (double) | Brush parameters, MeshData |
same, unscaled |
Angles: ints in 1/4096 turns (PropType.ANGLE). Strat yaw 0 = facing +z (game), 1024 = facing +x.
Tile transform (export space): X = x + vx·cos a − vz·sin a; Y = y + vy; Z = z + vx·sin a + vz·cos a,
a = rot/4096·2π (format.Geometry.TileXform). Grid cell of a tile = (floor(x), floor(−z)).
Collision records (TileModel.collision0..2, 32 bytes) are stored in game space; decode with
TileModel.collisionPolygon(g, k) (→ format.Collision.Decoded, already converted to export
space) and Collision.polygon(decoded) for the corners (local to the tile; apply the tile transform).
Placed in the world: tile.worldCollisionPolygons() / model.collisionPolygons(xf) (corners, normal,
material; polygons with fewer than 3 corners skipped).
Scene (editor.model.Scene)
Singletons: settings() (LevelSettings), regions() (Regions). Ordered lists (NodeList<T>,
read-only List with O(1) indexOf; order = WAD order):
| Accessor | Node | WAD source |
|---|---|---|
tileModels() |
TileModel |
TRAK models (vertices, faces, planes, 3 collision groups, bbox header) |
tiles() |
Tile |
MAP tiles: x, y, z, rot, model (ref), swap (alt model, door/bridge), vertex colours |
strats() |
Strat |
MAP objects: type (name), code, position/rotation (fixed), params, type fields, waypoint (ref), flags |
pathNodes() |
PathNode |
MAP waypoints/camera/respawn nodes; next (ref) chains |
zones() |
EnvZone |
MAP 90-byte env/teleport/respawn zones incl. fog, colours, music |
lights() |
Light |
LGHT (directional / point) |
pages() |
TexturePage |
TEXT pages (256×256 ABGR1555, argb() for images) |
textures() |
TextureEntry |
TEXT texture table (faces use the index in this list) |
textureAnims() |
TextureAnim |
TEXT texture animations |
brushes() |
Brush |
editor-only primitives → tiles at build time |
meshAssets() |
MeshAsset |
imported OBJ/glTF (mesh() = parsed MeshData) |
chunks() |
RawChunk |
WAD chunk order; unmanaged chunks (STPC, sound, …) verbatim |
Other: node(id), listOf(kind), catalog() (StratCatalog: placeable object types of the base
level, variants with type fields, default params and relative waypoint chains), assets()
(project files), chain(pathNode), playerStart(), referencesTo(node), create(kind) /
createBrush(kind) (detached node with a fresh id), add/remove/set (with events, no undo).
Stable ids: imported nodes "<kind>:<index in the WAD>" (tile:12, strat:3, wp:40, zone:0,
model:7, tex:120, page:2, light:0, anim:1, chunk:5), new nodes "<kind>:n<N>", singletons
settings:0, regions:0. Ids never change, also not when other nodes are deleted.
Nodes and properties
Every node: id(), kind() (NodeKind), label(), props() → List<Prop>. A Prop has
name(), label(), group() (inspector section), type() (PropType), readOnly(),
nullable(), refKind(), choices(), get(node). Value classes by PropType:
INT/FIXED/ANGLE/FLAGS/COLOR → Integer, DOUBLE → Double, BOOL, STRING/CHOICE → String,
REF → Node, INT_ARRAY int[], DOUBLE_ARRAY double[], BYTES byte[]. Arrays are shared: never
mutate a returned array, set a new one. Typed getters exist on every node class (tile.x(),
strat.gx() (cells), model.vertices() …).
Key render data:
TileModel.vertices()6 doubles/vertex (x y z nx ny nz),faces()6 ints/face (flags, v0, v1, v2, tex, pad),planes()4/face; helpersvertexCount(),faceCount(),faceFlags(f),faceVertex(f,k),faceTexture(f),collisionCount(g),collisionMaterial(g,k).- UVs:
format.Geometry.faceUvs(flags, Geometry.texUv(entry.toFormat(), page.width(), page.height())); flat colours:TextureEntry.isFlat(),flatRgb(). Face flags: 0x400 double-sided, 0x4000 ignore vertex colours, 0x8 batch start. Tile.colors(): frames × nv RGBA words (0x80 = 1.0, engine ×2), first frame =colors[0..nv).Regions.visibleFrom(camCellX, camCellZ, cellX, cellZ, width)= engine visibility rule.- Collision materials: 0 normal, 16 exit floor (LevelDoor), 17/18 selective, 30 no floor/death.
StratCatalog.category(type)→ player, exit, checkpoint, camera, trigger, collectible, force, other.
Commands (editor.model.cmd)
History(scene): execute(cmd), undo(), redo(), canUndo/Redo, undoLabel/redoLabel,
begin(label)/end() (group into one step), markSaved()/isDirty(), addListener(Runnable).
Commands: SetProp.of(node, "prop", value) / SetProp.of(node, prop, value, mergeKey),
AddNode(node[, index]), RemoveNode(scene, node) (clears optional references, refuses if a
tile still uses a model), Batch(label, cmds[, mergeKey]). Drags: give every step of one
gesture the same mergeKey object → consecutive steps merge into one undo entry.
Edits helpers: translate(nodes, dx, dy, dz, mergeKey) (cells, game space; strats, waypoints,
tiles, brushes, zone targets, point lights), rotateTo(node, degrees, mergeKey),
placeStrat(scene, type, variant, x, y, z, yawDeg) (copies type fields/params/waypoint chain from
the catalog), delete(scene, nodes) (also removes waypoint chains used only by deleted strats),
addBrush(scene, kind, params), set(label, Map<Node, Map<prop, value>>, mergeKey).
Events (SceneListener, SceneEvent): ADDED(node, index), REMOVED(node, index),
CHANGED(node, prop, oldValue), RELOADED (bulk change, rebuild everything). Fired synchronously
on the changing thread (use the EDT).
Brushes (Brush, BrushKind)
Kinds FLOOR, BOX, PLATFORM, PILLAR, RAMP, STAIRS, FENCE, TREE, SEA, KILL_PLANE, QUAD, COLLIDER, MESH
– the levelbuild.LevelBuilder primitives; parameters = props named as in BrushKind.params()
(Param(name, label, type, default, role, choices); role X/Y/Z/XZ_LIST/XYZ_LIST/XZ_RECT marks
coordinates). brush.bounds() gives an approximate box. Texture parameters: village names
(LevelBuilder.TEX), "#index", flat colour names (LevelBuilder.FLAT) or "rgbRRGGBB".
For a preview of the generated geometry: BrushCompiler.compile(scene, warnings) → generated
TileModels/Tiles/textures (same as the build).
IO (editor.io)
| Class | Purpose |
|---|---|
WadImporter.importWad(bytes, base, cd) |
original WAD → Scene (cd: loader for other CD files, may be null) |
WadBuilder.build(scene) / buildWithReport(scene) |
Scene → WAD (+ Stats, warnings); validates first, throws IllegalStateException with all errors |
Validator.validate(scene, warnings) |
errors (unbuildable) + warnings, for a problems panel |
BrushCompiler.compile(scene, warnings) |
brushes/meshes → generated TileModels/Tiles/pages/entries (ids "gen:…", not in the scene) – usable as a preview of the build |
Project |
scene(), thumbnail()/setThumbnail(png), warnings(); Project.create(bases, "edit"/"new", base) |
ProjectIO |
load/read(bytes), save(p, path, bases)/write, info(bytes) (header only), sha256 |
Templates |
original(bases, base), newLevel(bases, base) (reference), clearBaseLeftovers(scene), DEFAULT_BASE |
Bases |
original files: Bases.fromCd(dir) (case-insensitive), wad(base), file(rel) |
MeshImport.importFile(scene, path) |
OBJ(+MTL, map_Kd), glTF (.gltf + .bin/data URIs, .glb) → detached MeshAsset (add with AddNode); files copied into scene.assets(); load(scene, asset) re-parses |
TextureImport.importImages(scene, images, size) |
images → Result(command, entries, pages): execute the command, then use "#" + entries[i] as a texture |
Images |
page ↔ PNG/BufferedImage (pageImage, pagePng, toAbgr1555) |
Meshes at build time: transformed (scale, yaw, position), then every triangle goes through
LevelBuilder.quad (split into 1×1 cells / 10-unit height bands, collision polygon per triangle
with the brush's material, lit vertex colours). The engine has no free UVs – a triangle always
shows one half of a texture rectangle – so each triangle gets the rectangle around its UVs
(rounded to uvSteps per texture) on a copy of its material texture (flipped in U and/or V when
that fits the corner order better). Rectangular quads map exactly; arbitrary UV layouts are
approximated; tiling UVs (> 1) are clamped. Material textures are packed into new 256×256 pages
at texSize (power of two). Untextured materials use flat colours (Kd / baseColorFactor);
vertex colours tint the lighting.
Limits / unknown (phase 1)
- Strat code (STPC) and object models are kept verbatim: only types present in the base level are
placeable (
StratCatalog), plus RubyIdol borrowed from t1l1m002 for the village (as levelbuild). - Meaning of many raw fields is unknown (
unk*props of zones/waypoints, texture entry flags, collision groups B/C, region value) – they are editable but not interpreted. - Tile swaps (WFPC 0x10000) can only exist in bases with that flag.
- Texture names exist only for the village (levelbuild names); other levels use
#index. - Lights: types other than 1/2/4 carry no data and are ignored by the engine.
- Removing original tiles shifts tile indices used by strat scripts (see editor-format.md).
- The engine limits: ≤ 8000 vertices per tile model, collision within ±8 of the tile origin (handled by the brush compiler), texture pages ≤ 127.