# 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 ```java 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`, 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 `":"` (`tile:12`, `strat:3`, `wp:40`, `zone:0`, `model:7`, `tex:120`, `page:2`, `light:0`, `anim:1`, `chunk:5`), new nodes `":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`. 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; helpers `vertexCount()`, `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>, 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 `TileModel`s/`Tile`s/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 `TileModel`s/`Tile`s/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.