Files
llamamod/notes/editor-api.md
T

11 KiB
Raw Blame History

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; 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<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, resolved per base by BaseTextures), "#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 are the village's levelbuild names; on other bases they resolve to the same picture (levelbuild.BaseTextures, pixel fingerprint); names the base lacks are not offered (GeoWidgets) and a brush using one is skipped with a warning (the editor has no CD access to copy them over).
  • 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.