initial commit: llamamod 0.2.0 – reverse-engineered level formats (wad, map, trak, text, stpc), python tools and notes, java mod manager (iso install, mod pipeline with exe patching, save editor, wine/windows launcher) with mods (test world, debug wireframe, mirror, randomizer, fog, cheats, intro skip, practice, free camera, boot to level, level projects) and the 3d level editor (geometry, objects, cross-world import, environment, textures, play-test, manual)

This commit is contained in:
MineTec committed 2026-11-07 08:15:40 +01:00
commit b36a366240
826 files changed
+105304

No files matched your search

+292
View File
@@ -0,0 +1,292 @@
# Level editor – render package (`editor.render`)
Phase 1 of the level editor (notes/editor-design.md): OpenGL viewport, level rendering, overlays,
camera, picking, transform gizmo. Package `com.eliasmueller.llamamod.editor.render`; it has no
dependency on `editor.model`/`editor.io` – the UI adapts the scene model to `RenderScene`.
![montage](img/editor/00_montage.jpg)
## Build / platform
* Gradle: LWJGL **3.4.2** (`lwjgl`, `lwjgl-opengl`, `lwjgl-jawt`, `lwjgl-egl`) + **lwjgl3-awt 0.2.5**
(`AWTGLCanvas`, a heavyweight AWT canvas inside Swing; `transitive = false`, its POM pulls
Vulkan/macOS natives) + **JOML 1.10.8** (math). Natives `natives-linux` and `natives-windows`
of `lwjgl` and `lwjgl-opengl` are runtime dependencies, so the fat jar (and with it both packages)
carries `linux/x64/…/*.so` and `windows/x64/…/*.dll`; LWJGL extracts them to a temp dir at runtime.
Fat jar 9.7 MB.
* Fat jar manifest gets `Multi-Release: true` (LWJGL is a multi-release jar), `module-info.class`
files are excluded. jlink modules + `jdk.unsupported` (LWJGL/JOML use `sun.misc.Unsafe`).
`packageLinux`/`packageWindows` otherwise unchanged.
* **OpenGL 3.3 core profile, no 2.1 fallback** (decision): shaders are GLSL 330, texture pages are
one `GL_TEXTURE_2D_ARRAY`, VAOs/VBOs. 3.3 is supported by every GPU/driver since ~2010 (Intel HD
2000+, Mesa llvmpipe gives 4.5); a 2.1 path would double the renderer for hardware that cannot run
the rest of the toolchain comfortably anyway. The canvas first asks for 4× MSAA and retries
without multisampling; if no 3.3 context can be created the viewport shows a message instead
of crashing (`Viewport.glError()`). No swap interval is requested (Xvfb/GLX has no swap control,
lwjgl3-awt then fails context creation).
* Tested:
* Linux native (Xvfb + llvmpipe, `DISPLAY=:8`): fat jar and the jlinked `LlamaMod-linux-x64`
runtime – rendering, picking, gizmo drags, fly mode via xdotool.
* **Windows package under Wine 6.0.3** (own win64 prefix, `JDK_JAVA_OPTIONS=-XX:ActiveProcessorCount=4`,
`runtime\bin\java.exe -cp LlamaMod.exe …ViewerDemo`): Wine's WGL gives a 3.3 core context on the host
llvmpipe; the screenshot is **pixel-identical** to the Linux one. `LlamaMod.exe --mods` still works.
Real Windows hardware not tested.
* `-Dllamamod.editor.msaa=0` disables multisampling (2–3× faster on software GL).
## Demo
```
java -cp build/libs/llamamod-*-all.jar com.eliasmueller.llamamod.editor.render.ViewerDemo \
<level.wad | testworld> [--cd DIR] [--size 1280x720] [--collision] [--no-markers]
[--hide waypoints,zones] [--view top|front|side] [--cam x,y,z,yawDeg,pitchDeg]
[--select o12,t5,w3] [--frame] [--gizmo translate|rotate|scale] [--shot out.png] [--bench] [--list]
```
`testworld` builds the Testwelt (TestWorldMod) from the CD's village WAD. The window has layer
check boxes, view and gizmo mode selectors and a status bar (hover/pick info). `--list` prints
all strats with ids and positions. `--bench` orbits 120 frames and prints timings.
## Controls (Viewport)
Navigation presets (`Navigation.Preset`, View › Navigation, Preferences › Viewport & navigation,
Help › Viewport navigation; setting `viewnav.preset`, default **Fusion 360**):
| Input | Fusion 360 (default) | Blender | Unity / Unreal |
|---|---|---|---|
| middle drag | pan | orbit | pan |
| Shift+middle drag | orbit | pan | pan |
| Ctrl+middle drag | zoom | zoom | zoom |
| double-click middle | fit all | – | – |
| Alt+left drag | orbit | orbit | orbit |
| right drag | look around + **W/A/S/D** move, **Q/E** down/up, **Shift** ×4, wheel = fly speed (all presets) |||
| Alt+right drag | zoom (all presets) |||
| wheel | smooth exponential zoom towards the cursor (all presets) |||
| Input | Action |
|---|---|
| view cube (top right) | click a face / edge / corner = look from there (animated, pivot and projection kept), drag = orbit, house = home (iso view from front-right-top + frame all) |
| F / Home | frame selection / frame all tiles (smooth 220 ms transition) |
| numpad 7 / 1 / 3 / 5 | top / front / side (orthographic) / toggle perspective |
| W / E / R, Q (when not flying) | gizmo translate / rotate / scale, toggle world/local |
| left click | pick (listener); left drag on a gizmo handle = transform; Ctrl = invert snapping; Esc = cancel drag |
* **Zoom** (2026-11-07): constant percentage per wheel notch (`zoomFactor`, default 12 %, 2–40 %,
"invert zoom"), fractional touchpad deltas scale smoothly, animated 120 ms (ease-out; further
notches accumulate on the target). Anchor (`anchorDistance`): the depth-buffer hit under the
cursor if it lies in front of the near plane and closer than 3× the pivot depth (and the far
plane), else the current pivot depth along the cursor ray – pointing at the sky or a far cliff
no longer makes a huge step, a near wall no longer stalls. The anchor becomes the new pivot depth.
Old behaviour: a fixed `0.15 × distance` dolly step along the ray, independent of what is under
the cursor (sky/near ground felt extremely fast/slow).
* **Orbit**: pivot = surface point under the cursor at drag start (fallback: selection centre,
then view centre), shown as a small RGB cross; the camera keeps its camera-space offset to the
pivot, so the pivot stays under the cursor; constant angular speed (default 0.35 °/px), pitch
clamped to ±89°. `Camera.viewMatrix` now uses the mirrored yaw direction as up vector when looking
straight **up**, so pitching across the vertical threshold no longer flips the picture.
* **Pan** moves the grabbed point (its view depth) exactly with the cursor (sky: pivot depth).
* Viewport API: `setNavigation/zoomAt/setZoomSpeed/setInvertZoom/setOrbitSpeed/setViewCubeVisible/
home/snapView`; math in `Navigation` and `ViewCube` (pure, `NavigationTest`, `ViewCubeTest`).
Plugin `tools.viewnav.ViewNavEditorPlugin` (menus, preferences dialog, cheat sheet; the status
bar shows a one-line summary on open and when the preset changes).
Orbiting a standard ortho view switches back to perspective. Hover picking runs at most every
40 ms while the mouse moves (`setHoverPicking(false)` turns it off).
![view cube, preferences](img/editor/cull_nav_prefs.jpg)
## API
```java
RenderScene scene = LevelSceneAdapter.fromWad(wadBytes).scene(); // or built by the editor UI
Viewport vp = new Viewport(); // JPanel; add to any Swing layout
vp.setScene(scene);
vp.setGrid(0, new float[] {0, -H, W, 0}); // grid plane y and bounds (x0, z0, x1, z1)
vp.setLayerVisible(LevelSceneAdapter.LAYER_COLLISION, true);
vp.setSelection(List.of(PickId.of(PickId.OBJECT, 12))); // highlight (+ unbatched tiles)
vp.gizmo().setVisible(true); vp.gizmo().setTarget(matrix); vp.gizmo().setMode(Gizmo.Mode.ROTATE);
vp.gizmo().setSnap(new Gizmo.Snap(true, 0.25f, 15f, 0.1f)); // grid snapping hook
vp.addViewportListener(new ViewportListener() {
public void picked(PickEvent e) { … e.id(), e.world(), e.modifiers(), e.clickCount() … }
public void hovered(int id) { … }
public void gizmo(GizmoEvent e) { … } // START / DRAG / END / CANCEL
public void cameraChanged(Camera c) { … }
});
vp.frameAll(); vp.frameSelection(); vp.setView(Camera.View.TOP);
vp.pick(x, y); vp.surfacePoint(x, y); // synchronous id buffer / depth reads
vp.screenshot(); vp.stats(); vp.glInfo(); vp.requestRender(); vp.dispose();
```
### Scene data (render-facing interface)
* `RenderScene`: `pages` (`TexturePage(w, h, int[] 0xRRGGBBAA)`), `instances`, `markers`,
`overlays` (world-space `Overlay` per layer name), `background` (0xRRGGBB, also fog colour).
After edits: `changed(instance)` (rebuilds only that instance's chunk), `changed()` (everything,
markers/overlays), `texturesChanged()`. Modify on the Swing thread.
* `RenderMesh`: triangle mesh in local space – per vertex positions/normals, per triangle
corner UVs (normalised to the page), per triangle page (-1 = untextured), colour (multiplied,
flat colours / translucency), flags (`DOUBLE_SIDED`, `NO_VERTEX_COLOR`, `LIT` = shade with
normals) and blend class (`BLEND_OPAQUE` = alpha-tested cut-out, `BLEND_ALPHA`, `BLEND_ADD`).
Optional `overlay` = local-space helper geometry that follows every instance (collision).
* `RenderInstance(id, kind, mesh)`: `transform` (JOML `Matrix4f`), `vertexColors` (game
convention 0x80 = 1.0, doubled with clamp), `tint`, `visible`, `layer`. Kind `TILE` instances are
baked into static batches; `OBJECT`/`PREVIEW` are drawn individually with their matrix.
* `Marker`: pickable icon (cube/diamond/flat arrow, pole, direction tick, colour, layer, label).
* `Overlay`: coloured lines + translucent triangles (`box`, `line`, `triangle`).
* Pick ids are plain ints, 0 = nothing; `PickId` is the convention used by the adapter
(kind << 24 | index: TILE, OBJECT, WAYPOINT, ZONE, LIGHT, USER…).
* `Legend`: collision colours by material/slope and marker category colours, same as
notes/debugviz.md (`Legend.category(stratName)`).
### Gizmo
`Gizmo` is pure math (unit-tested in `GizmoTest`): modes TRANSLATE (3 axes, 3 planes, centre =
screen plane), ROTATE (3 rings, back halves faded, edge-on rings fall back to screen angle), SCALE
(3 axes in local space, centre = uniform). World or local space (scale always local). Handles
keep a constant screen size (`sizePx`, default 95). Hit testing in screen space (9 px).
`GizmoEvent` carries the **total** delta since drag start (translation, axis+angle about the start
pivot, scale factors) plus the start matrix: `e.result()` = new target transform, `e.applyTo(m)` for
other selected objects. The UI shows a live preview on DRAG, emits one undoable command on END
and restores `e.start()` on CANCEL; it calls `gizmo().setTarget()` so the handles follow
(axes stay as at drag start). Snapping: `Snap(enabled, translate step, rotate degrees, scale step)`,
Ctrl inverts `enabled` while dragging.
## Coordinate conventions (handedness fix, 2026-11-06)
* **The view is left-handed like the game's Direct3D renderer.** The engine draws export-space data
(tile floats, TRAK/STPC vertices, strat (x, y, −z)) with a D3D camera, so a plain OpenGL view of
the same coordinates was the game's picture mirrored left-right (owner report: "levels mirrored").
`Camera.viewMatrix()` = `scale(−1, 1, 1) · lookAt(…)`; the renderer culls with `glFrontFace(GL_CW)`;
data, picking (`ray`/`project` use the matrices) and the scene model are unchanged.
* `Camera.yaw/pitch` equal the game camera angles (0x5790a4/0x5790a0, 4096 = 2π): yaw 0 looks along
game +z (export −z), yaw 90° along +x, **positive yaw turns left**. `forward()` = (sin y·cos p,
sin p, −cos y·cos p) (export), `right()` = screen right = (−cos y, 0, −sin y) (game: (−cos y, sin y),
as found by the freecam mod), `up()` = forward × right. Fly look/orbit: dragging right decreases yaw.
`ViewerDemo --cam x,y,−gz,yawDeg,pitchDeg [--fov vDeg]` reproduces a game camera.
* Rotate gizmo: the edge-on fallback uses the screen angle with the mirrored sign.
* Standard views: **top** = x to the right, game z (grid rows) downwards (cell (0,0) top left, as the
MAP/region grid is laid out; yaw π); **front** looks along game −z (x right); **side** looks along −x.
* Spaces: render/export space = (x, y, −z_game); model and inspector values (strats, waypoints,
zones, lights, collision) are game space, tiles store export floats. LGHT point lights/directions
are stored in game space (engine negates z on load). Strat yaw: `rotateY(−ry·2π/4096)` in export
space with model front = local −z → yaw 0 faces game +z, 1024 faces +x (verified: llama ry 3061 and
Pacha at his runtime yaw 830 face the same way as in game). Tiles: `rotateY(−rot)`.
* Evidence: in-game screenshots (apptest + freecam, camera read with winedbg from 0x5790b0 and the
look-at target 0x6d9d80) vs. ViewerDemo from the same camera: `img/editor/fix_compare_start.jpg`
(game / before / after), `fix_compare_back.jpg`, `fix_compare_yaw.jpg`, `fix_compare_top.jpg`.
The game's horizontal FOV matches 60° vertical at 16:9; its vertical FOV is ~15 % smaller
(non-uniform projection, not reproduced). Regression test: `CameraHandednessTest` (projected
pixel x of the llama and Pacha against the game screenshots, axis conventions).
## Rendering details
* **Batching**: tile instances are pre-transformed into 8×8-cell chunks; each chunk is one VBO
(36-byte vertices: position, normal, UV, colour, pick id, page layer + flags) sorted into 6
ranges (blend class × double-sided) plus a line VBO (collision outlines/fills). Frustum culling
per chunk AABB / per object (`Culling`, below). All pages live in one texture array (+1 white
layer for flat colours), so a chunk needs at most 6 draw calls. Objects use one VBO per mesh.
Selected tiles are taken out of their chunk and drawn alone, so dragging them never rebuilds a
chunk; `changed(instance)` on a tile rebuilds its chunk (~20k vertices, a few ms).
* Passes: opaque (alpha test, back-face culling except double-sided faces), **back sides** (below),
overlay lines,
translucent (alpha, then additive; depth write off, not sorted), overlay fills, selection
(orange tint + hidden-line ghost + wireframe; hover = pale wireframe), gizmo (depth cleared).
Optional distance fog (40–160 cells) in the level's backdrop colour.
* **Picking**: id-buffer pass into an offscreen RGBA8/depth FBO (scissored to a 9×9 window, the
nearest non-zero pixel wins so small markers are easy to hit), depth → world hit point.
Cut-out texels are not pickable (click through leaves).
* Textures: TEXT pages ABGR1555 → RGBA, 0x0000 transparent, STP=0 = half alpha on blend pages
(page class 4/5/7); transparent texels are dilated (no dark fringes), 3 mip levels.
Blend class per face: texture entry bits 12–14 (1/3 → alpha, 2/4 → additive) or, for mode 0,
page class (4/7 alpha, 5 additive) – an approximation of the engine's table at 0x573eb8; flat
colour entries (bit 0) use u0/u1/v0 as RGB, alpha in v1 when a blend mode is set.
## Culling and "disappearing objects" (2026-11-07)
Owner report: objects, especially large ones, disappear at the screen edge.
* **Root cause: not the frustum culling** but the Environment plugin's *fog preview* (on by
default): it sets a `FogOverride` with `clip` + `aroundPivot`, and the mesh shader **discarded
every fragment farther than the fog end from the orbit pivot** (radial). The pivot is the view
centre, so geometry towards the screen edges was cut away, and it popped in/out while panning.
Evidence: `img/editor/cull_root_fogclip.jpg` (old build, same camera: fog preview on / off – the
left islands are missing with it on). Fix (render package only): with `aroundPivot` the shader
draws a desaturated **ghost** beyond the radius instead of discarding (`uClipGhost`); the eye-based
far clip is unchanged.
* Frustum culling itself was correct (JOML plane extraction is orientation-independent; the mirrored
view only swaps the left/right planes). Hardened anyway: `Culling` boxes include every vertex
**and** the instance's overlay (collision lines/fills – a collision-only chunk had an empty box and
was always culled), objects use their local box transformed with the full instance matrix
(rotation/scale, 8 corners), skinned characters are culled with their posed vertices, + a small
absolute/relative margin. `-Dllamamod.editor.noCull=true` disables culling (diagnostics).
* Tests: `CullingTest` – random perspective/ortho cameras (any yaw/pitch, fov 30–100°, aspect),
random rotated/scaled meshes + every village instance: whenever any vertex projects inside the
clip volume the box must be visible (no false culls). Pixel check: ViewerDemo village, city
(t1l6m003) and Testwelt from inside the level at 8 yaws each, culling on vs `noCull`: **0
differing pixels** in all 24 pairs (`img/editor/cull_overview.jpg`).
### Back sides of one-sided faces
The game culls faces without flag 0x400 from behind; the editor now shows those back sides
(View › Show back faces, default on; opacity in Viewport preferences, default 30 %): pass 3 after
the opaque pass draws the single-sided opaque/cut-out ranges again with `glCullFace(GL_FRONT)`,
depth-tested without depth writes, blended, darkened + desaturated with a fine screen-space
diagonal hatch. Front faces look exactly as before (the pass only fills pixels whose depth is
not covered). Translucent one-sided faces (island skirts) get no back side (it showed through them
everywhere). Picking hits both sides; the shader sets bit 31 of the pick id for a back side
(`PickHit.backFace`, `Viewport.lastPickBackFace()/hoverBackFace()`), the status bar appends
"(back side)" to the hovered item. `img/editor/cull_backfaces.jpg` (one-sided cliff walls seen from
behind: game look / editor).
## Level adapter (`LevelSceneAdapter.fromWad`)
* Tiles: TRAK model per MAP tile, transform `translation(x, y, z).rotateY(-rot)` (same as
`Geometry.TileXform`), vertex colours = first colour frame (alt models of WFPC 0x10000 tiles are
not shown). UVs via `Geometry.faceUvs`, face flags 0x400 / 0x4000 honoured.
* Collision: every TRAK model's polygons (`Collision.decode/polygon`) as outlines 1 cm above the
face, coloured as in debugviz (floor green, slope yellow-green, wall blue, ceiling orange,
materials 16/17/18/30/… fixed colours); fills for material 16 (exit floor) and faintly 17/18.
Invisible-collision detection (red fill of the in-game debug mod) is not done.
* Strats: game space → export space (z negated), yaw = ry. **Object models**: the strat code sets
its model with `0xb2 <abs STPC offset of the model header>; 0x54` – `StratModels` scans the strat
from its entry to the next strat's name site and takes the first such pair; spawn stubs
(`b2 <code> e0`) are followed. **Characters** (skinned models, header +0x84 = 0, nBones × u16
vertex counts) are posed with frame 0 of an animation set with the same bone count (first one
referenced by `0xb2 <set>` in the strat code, else the first in STPC): bone matrices 4×4 f32,
row vectors. Result: villagers, kids, Pacha, Tipo, bunnies look right, the llama is shown in a
crouching pose (frame 0 of its set). Village: 20 of 32 placed types get a model (8 of them
skinned); the rest are invisible logic objects, except RisingColumns, SwitchPlatformPadVillage
and AppearingPlatformsVillage, which set their models some other way (shown as markers).
* Invisible strats (categories of debugviz: start, exit, checkpoint, camera, trigger, force,
secret, misc) become markers on poles (exit: purple cell box); start/checkpoint additionally
show their model. Objects without a resolvable model: small grey cube; unposable character:
grey diamond.
* Waypoints: every path node a pickable diamond (layer `waypoints`), chains (`next ≠ 0` → i+1)
and strat → first node as lines. Some chains are point lists across the whole level (e.g. the
llama's), so the layer gets busy – the UI should show it for the selection only.
* Environment zones: pink cell boxes + pickable marker (layer `zones`); the default zone's
backdrop colour (file offset 0x4e, 0x00BBGGRR) becomes `scene.background` (cyan village sky,
purple river fog, dark blue city).
## Performance (1280×720, llvmpipe software GL on this machine, 4× MSAA / no MSAA)
| Level | tiles/objects/markers | triangles drawn | draw calls | orbit avg MSAA 4 | no MSAA |
|---|---|---|---|---|---|
| village t1l1m001 (+collision) | 1458 inst., 218 markers | 27k | 125–145 | 17–27 ms | 9 ms (109 fps) |
| river t1l3m002 (+collision) | 1494 / 322 | 22k | 145–170 | 17–40 ms | 10 ms (103 fps) |
| city t1l6m003 (largest WAD) | 2177 / 439 | 41k | 120–140 | 26–50 ms | 18 ms (55 fps) |
(Ranges: several agents shared the CPU.) Scene conversion from WAD 160–290 ms. On any real GPU
this is far below 1 ms of GPU time; the CPU side is ~150 draw calls per frame.
## Known limits / next steps
* Only the first vertex colour frame and the primary tile model (no alt/swap models).
* Translucent faces are not depth-sorted (as the game does per batch; artefacts are rare).
* Engine blend table only approximated; no texture animations (TEXT nAnim block), no sky/backdrop
geometry, no lights (LGHT) yet.
* Characters: frame 0 of a guessed animation set; models whose pose fails show a diamond.
* Collision of object models (moving platforms, doors) is not drawn (same as the debug mod).
* `Viewport.dispose()` plus disposing the frame can hang on Xvfb without a window manager (AWT
focus round trip); the demo exits with `System.exit` after a shot.
Screenshots: `img/editor/01_village.jpg` … `12_viewer_ui.jpg` (village/river/city/Testwelt with
and without collision, ortho top views, translate/rotate gizmo, the demo window with a selection).